面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月31日 16:34

Kubernetes 亲和性和反亲和性如何控制 Pod 调度?

Kubernetes 亲和性和反亲和性,本质上是在回答“Pod 应该靠近谁、远离谁、只能去哪里”。nodeAffinity 关心节点标签,比如把需要 SSD 的服务放到 disk=ssd 的节点;podAffinity 关心已有 Pod,比如把网关和同可用区缓存放近一点;podAntiAffinity 则常用于把同一应用副本打散,避免一个节点故障带走所有实例。真正落地时不要把所有规则都写成 required,硬约束越多,调度失败的概率越高;通常是“必须满足资源和隔离,性能偏好用 preferred”。怎么用节点亲和性限制 Pod 去指定节点?节点亲和性比 nodeSelector 更灵活,支持 In、NotIn、Exists、Gt、Lt 等操作符。比如只允许 Pod 调度到带有 nodepool=ssd 的节点,并优先选择同区域节点,可以这样写:apiVersion: apps/v1kind: Deploymentmetadata: name: apispec: replicas: 2 selector: matchLabels: app: api template: metadata: labels: app: api spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: nodepool operator: In values: ["ssd"] preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: topology.kubernetes.io/zone operator: In values: ["cn-east-1a"] containers: - name: api image: nginx:1.25这里的 requiredDuringSchedulingIgnoredDuringExecution 是硬门槛,不满足就 Pending;preferredDuringSchedulingIgnoredDuringExecution 是加分项,不满足仍可调度。后半段的 IgnoredDuringExecution 很容易被误解:它表示 Pod 调度成功后,如果节点标签后来被改掉,Kubernetes 不会主动驱逐这个 Pod。这个边界很重要,亲和性解决的是“调度时放哪里”,不是“运行中持续纠偏”。如果要给节点补标签,先用命令确认标签是否稳定存在:kubectl label node node-1 nodepool=ssdkubectl get nodes -L nodepool,topology.kubernetes.io/zone不要把临时标签当成长期调度依据。比如临时给节点打 debug=true 后忘记清理,后续 Pod 可能被错误吸到这批节点上。更稳的做法是把节点池、可用区、实例规格这类由平台维护的标签作为调度依据,业务自己维护的标签则要有变更流程。追问required 和 preferred 应该怎么取舍?required 适合合规、安全、硬件能力这类不能妥协的条件,比如 GPU 任务必须上 GPU 节点,数据库实例必须上本地盘节点。preferred 适合性能优化和成本优化,比如优先同可用区、优先 SSD、优先空闲节点,但没抢到也允许服务先跑起来。踩坑最多的是把高可用诉求也全写成 required,节点数量一少、标签不齐或滚动升级时,Pod 会长时间 Pending。实际项目里建议先用 preferred 观察调度结果,再把确实不能退让的条件收紧成 required。Pod 亲和性和反亲和性常用在哪些场景?Pod 亲和性适合把强依赖、低延迟通信的组件放近,比如计算服务和本地缓存希望在同一可用区,减少跨区访问延迟。Pod 反亲和性更常见,用来把同一 Deployment 的副本分散到不同节点或不同可用区,降低单点故障影响。边界是它依赖目标 Pod 的 label,如果 label 写错或 selector 太宽,会出现“亲和到不该亲和的 Pod”的情况。生产环境里通常给业务 label 保持稳定,避免把版本号、临时灰度标签拿来做调度依据。topologyKey 选 hostname 还是 zone?kubernetes.io/hostname 表示按节点维度分散,适合防止同一个应用的多个副本落在一台机器上。topology.kubernetes.io/zone 表示按可用区维度分散,适合云上多可用区容灾,但跨区会带来网络延迟和流量成本。取舍点在于你要防的是“单机故障”还是“可用区故障”。如果副本数小于可用区数量,强制 zone 反亲和可能导致某些副本调度不上,这时 preferred 往往比 required 更稳。如何排查亲和性导致的 Pending?先看事件,调度器通常会直接告诉你哪些节点不满足规则:kubectl describe pod <pod-name>kubectl get nodes --show-labelskubectl describe node <node-name> | grep -A5 Taints如果事件里出现 didn't match Pod's node affinity,说明节点标签和 nodeAffinity 对不上;如果出现 didn't match pod affinity/anti-affinity rules,就要检查目标 Pod 的 label 和 topologyKey。另一个常见坑是污点和亲和性叠加:亲和性允许去某节点,但没有 toleration,调度器仍然不会放过去。排查时不要只盯 affinity 字段,资源不足、污点、PVC 绑定模式也可能同时参与过滤。亲和性需要和污点容忍一起用吗?需要,但两者解决的问题不同。亲和性是在“吸引”或“偏好”某些节点,污点是节点主动“拒绝”不合适的 Pod,容忍度则是 Pod 表示自己可以接受这个拒绝条件。比如专用 GPU 节点通常会打 taint,只有声明 toleration 且有 GPU nodeAffinity 的任务才能上去。边界是 toleration 不是调度目标,它只代表“允许去”,真正想让 Pod 去哪里仍要靠 nodeSelector、nodeAffinity 或资源请求。
服务端阅读 05月31日 16:34

Kafka 为什么曾经依赖 ZooKeeper?它到底负责什么?

在早期和大量存量 Kafka 集群里,ZooKeeper 负责保存和协调集群元数据:Broker 注册、Controller 选举、Topic 与分区信息、部分配置和权限数据。它不是 Kafka 的消息存储层,真正的消息仍然写在 Broker 本地日志里。ZooKeeper 更像一个一致性协调中心,帮助 Kafka 判断“谁还活着、谁来当 Controller、元数据现在是什么状态”。ZooKeeper 管哪些事Broker 启动后会连接 ZooKeeper,并在 /brokers/ids 下创建临时节点。临时节点和会话绑定,如果 Broker 宕机或网络断开超过会话超时时间,节点会消失,其他组件就能感知 Broker 不可用。这个机制让 Kafka 能快速发现集群成员变化。Controller 选举也依赖 ZooKeeper。多个 Broker 会竞争创建 /controller 节点,创建成功者成为 Controller。Controller 负责分区 leader 选举、副本状态变化、Topic 创建删除等集群级动作。ZooKeeper 在这里提供的是互斥和通知能力,避免多个 Broker 同时认为自己是控制者。/brokers/ids/1 # Broker 注册信息/controller # 当前 Controller/brokers/topics/orders # Topic 分区与副本元数据/config/topics/orders # Topic 级配置/kafka-acl/Topic/orders # ACL 信息这些路径有助于理解 ZooKeeper 的定位:它保存的是元数据和状态,不负责传输业务消息。Consumer offset 也在 ZooKeeper 吗这要看 Kafka 版本和客户端。早期 Consumer 会把 offset 写到 ZooKeeper 的 /consumers 路径下,但新版本 Kafka 默认把 offset 存到内部 topic __consumer_offsets。所以面试或排查时不能简单说“ZooKeeper 管 Consumer offset”,更准确的说法是:旧消费者协议依赖 ZooKeeper,新消费者组协调和 offset 存储主要由 Kafka 自己完成。这个细节很容易踩坑。运维老集群时,如果还有旧客户端,ZooKeeper 里可能仍能看到 consumer 路径;新集群则更多关注 Group Coordinator 和内部 topic。判断时看客户端版本和 kafka-consumer-groups.sh 查询结果,比背路径更可靠。ZooKeeper 配置要注意什么Kafka 侧至少要配置连接串和超时。生产环境不要只放一个 ZooKeeper 节点,通常使用 3 或 5 个节点组成 quorum。节点数用奇数,是为了在故障时仍能形成多数派。# server.propertieszookeeper.connect=zk1:2181,zk2:2181,zk3:2181/kafka-prodzookeeper.connection.timeout.ms=6000zookeeper.session.timeout.ms=18000# zoo.cfgtickTime=2000initLimit=10syncLimit=5dataDir=/var/lib/zookeeperclientPort=2181server.1=zk1:2888:3888server.2=zk2:2888:3888server.3=zk3:2888:3888zookeeper.session.timeout.ms 不是越短越好。太短会把短暂网络抖动误判成 Broker 下线,引发 Controller 处理分区变化;太长又会让真实故障发现变慢。生产里要结合网络质量、GC 暂停和业务可用性要求调整。KRaft 出现后 ZooKeeper 还重要吗Kafka 新版本已经引入 KRaft,用 Kafka 自己的 Raft 元数据 quorum 替代 ZooKeeper。新建集群可以优先评估 KRaft,部署和运维链路更简单。但很多公司仍有 ZooKeeper 模式的存量集群,迁移涉及版本、工具链、监控、备份和回滚方案,不是改一行配置就结束。因此 ZooKeeper 仍然是高价值知识点。理解它能帮助你排查 Controller 抖动、Broker 频繁上下线、Topic 元数据异常和老集群权限问题。追问ZooKeeper 会保存 Kafka 的消息数据吗?不会,Kafka 消息写在 Broker 的日志目录里,ZooKeeper 保存的是元数据和协调状态。把 ZooKeeper 当消息存储是常见误解,也会导致错误的备份方案。备份 ZooKeeper 只能保护部分元数据,不能恢复业务消息。真正的数据可靠性要看 topic 副本数、ISR、acks 和 Broker 磁盘。ZooKeeper 挂了 Kafka 会立刻不可用吗?如果 ZooKeeper 集群只是少数节点故障,多数派还在,Kafka 通常可以继续工作。如果 ZooKeeper quorum 整体不可用,已有 leader 分区可能还能短时间处理读写,但创建 Topic、Controller 选举、Broker 变更等元数据操作会受影响。边界在于“已有数据面”和“控制面变更”不是一回事。生产上不能因为消息还能写就忽视 ZooKeeper 故障。为什么 ZooKeeper 要部署奇数个节点?ZooKeeper 依赖多数派确认,3 个节点允许坏 1 个,5 个节点允许坏 2 个。偶数节点不会提升多数派容错能力,反而增加成本和通信开销。比如 4 个节点仍然需要 3 个形成多数,容错能力和 3 节点一样。常见取舍是中小集群用 3 个,跨机房或更高可用要求再评估 5 个。Kafka 迁到 KRaft 后就不用理解 ZooKeeper 了吗?新建 KRaft 集群确实不再依赖 ZooKeeper,但存量系统、面试和故障复盘里仍经常遇到 ZooKeeper 模式。迁移还要考虑 Kafka 版本、元数据迁移、监控指标变化和回滚路径。只知道 KRaft 的配置,不理解旧模式,很难解释老集群 Controller 抖动或 Broker 注册异常。更稳的做法是同时理解两套元数据管理方式。ZooKeeper 相关故障通常怎么排查?先看 Kafka Broker 日志里是否有 session expired、controller moved、zookeeper disconnected 等关键字。再检查 ZooKeeper 四字命令或监控指标,例如连接数、延迟、leader/follower 状态和磁盘 fsync 时间。很多问题不是 ZooKeeper 进程挂了,而是网络抖动、磁盘慢或 Broker 长 GC 导致会话过期。排查时要把 Kafka 日志、ZooKeeper 日志和节点资源放在同一时间线看。
服务端阅读 05月31日 16:34

Vite 相比 Webpack 快在哪里,又有哪些迁移坑?

Vite 是一个面向现代前端项目的构建工具,开发期基于浏览器原生 ESM 提供按需转换,生产期默认用 Rollup 输出优化后的静态资源。它相比 Webpack 最明显的优势是启动快、HMR 快、默认配置更贴近现代框架。但如果把 Vite 理解成“Webpack 的全面替代品”,迁移时很容易踩坑,因为两者的开发模型、插件生态和打包边界并不完全一样。Vite 快在哪里Webpack 开发服务器启动时,通常要从入口开始构建完整依赖图。项目越大,首次启动越慢。Vite 则把源码模块交给浏览器按需请求,开发服务器只在请求到某个文件时转换它。依赖部分由 esbuild 预构建,速度很快,也能把 CommonJS 依赖整理成浏览器更容易消费的 ESM。HMR 也是同样思路。Webpack 的热更新仍然围绕打包图生成补丁;Vite 只需要定位变化模块,通过 WebSocket 通知浏览器重新 import 对应模块。对于组件很多的大型后台项目,这个差异会非常明显,尤其是只改一个页面组件时。Vite 不只是快Vite 的另一个价值是默认配置少。TypeScript、CSS Modules、PostCSS、静态资源导入、环境变量、框架插件等能力开箱就能用。新项目不需要先写一大段 loader 和 plugin 配置,团队成员更容易统一开发方式。npm create vite@latest my-app -- --template react-tscd my-appnpm installnpm run dev生产构建上,Vite 使用 Rollup,因此 tree-shaking、代码分割、动态导入和库模式都有成熟支持。对常规 SPA、组件库、文档站、管理后台来说,Vite 的默认体验已经够稳。Webpack 仍然有优势的地方Webpack 的优势是可塑性和历史生态。大量老项目依赖自定义 loader、复杂 alias、Module Federation、非标准资源处理和企业内部插件,这些不是换个命令就能迁到 Vite。Webpack 对各种“历史包袱”的兼容经验更多,遇到特殊构建链时反而更省心。所以选择不是简单的“Vite 新,Webpack 旧”。如果是新项目、现代浏览器、React/Vue/Svelte 等主流框架,Vite 通常更合适。如果是多年老项目、强依赖 Webpack 插件、微前端运行时共享复杂,迁移前要先做依赖盘点。// vite.config.tsimport { defineConfig } from 'vite'import path from 'node:path'export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) }})迁移时常见坑是把 Webpack 的 process.env.X 直接搬过来。Vite 客户端环境变量默认需要 VITE_ 前缀,通过 import.meta.env 读取;如果硬要兼容旧写法,就需要显式 define。迁移时要先验证什么第一看依赖格式,尤其是 CommonJS、动态 require 和只面向 Node 的包。第二看资源导入方式,例如 svg loader、less 全局变量、mdx 或自定义文件类型。第三看构建产物,包括 chunk 大小、路由懒加载是否正常、静态资源路径是否适配 CDN。Vite 的迁移收益很高,但最好分阶段做。先让 dev server 跑起来,再处理测试、构建、预览和 CI。一次性重写全部构建链,问题会混在一起,很难判断是业务代码、依赖格式还是 Vite 配置导致的。追问Vite 一定比 Webpack 快吗?开发期大多数现代项目会更快,尤其是冷启动和局部 HMR。生产构建不一定总是数量级领先,因为 Vite 默认还是走 Rollup,复杂项目的插件和压缩也会花时间。速度还取决于依赖数量、插件质量和文件系统性能。判断时应该用自己项目的启动、保存更新、build 三组数据,而不是只看 benchmark。老 Webpack 项目适合直接迁 Vite 吗?不建议直接大爆炸迁移。老项目通常藏着 loader、全局变量、资源规则和环境变量约定,一次性替换很容易让问题堆在一起。更稳的方式是先做一个最小页面或独立包验证,再逐步迁移入口。取舍点在于:如果构建链已经严重拖慢开发,迁移值得做;如果依赖大量私有插件,维护 Webpack 可能更便宜。Vite 和 Webpack 的插件能通用吗?不能直接通用。Vite 插件接口接近 Rollup,并额外提供开发服务器相关钩子;Webpack 插件和 loader 是另一套机制。部分 Rollup 插件能在 Vite 生产构建中使用,但开发期是否可用要看插件有没有处理 ESM 请求模型。迁移时不要只找同名插件,要验证 dev 和 build 两个阶段都正常。为什么 Vite 生产环境还会打包?开发环境追求反馈速度,可以让浏览器按需加载很多模块。生产环境要控制请求数、缓存、压缩、兼容性和资源 hash,所以仍然需要打包优化。直接把源码 ESM 发布出去在小 demo 里可行,在真实业务里通常会带来首屏请求过多和缓存不可控。Vite 的取舍是开发期少打包,生产期认真打包。迁移后线上白屏一般怎么查?先看构建产物的资源路径,尤其是部署在子路径时 base 是否正确。再看动态导入 chunk 是否 404,很多白屏其实是懒加载文件路径被 CDN 或网关改坏了。然后检查环境变量和浏览器兼容目标,开发环境能跑不代表旧浏览器能执行生产代码。最后用 vite preview 复现构建产物,而不是在 dev server 里反复刷新。
服务端阅读 05月31日 16:34

Vite HMR 是如何做到修改代码后几乎秒更新的?

Vite HMR 的核心不是“重新打一个更小的包”,而是利用浏览器原生 ESM 和开发服务器的模块图,只更新真正变化的模块。文件改动后,Vite 服务器通过 WebSocket 通知浏览器,浏览器再按新的模块 URL 拉取更新内容。如果某个模块声明自己能接受更新,页面就不刷新;如果更新无法安全接住,Vite 才会退回整页刷新。HMR 的基本链路开发服务器启动后,Vite 会维护一张模块图,记录 URL、文件路径、导入关系和被哪些模块引用。浏览器加载页面时,每个源码模块都以 ESM 方式请求,比如 /src/App.tsx、/src/main.tsx。当你保存文件,文件监听器捕获变化,Vite 找到对应模块,再判断这次更新能不能沿依赖链被某个 HMR 边界接住。浏览器和服务端之间有一条 WebSocket 连接。服务端发送的不是完整应用代码,而是类似“某个模块更新了,请重新 import 它”的消息。浏览器端 HMR runtime 收到消息后,会给模块 URL 加上时间戳,绕过浏览器缓存并重新加载。if (import.meta.hot) { import.meta.hot.accept((newModule) => { console.log('module updated', newModule) }) import.meta.hot.dispose(() => { console.log('clean side effects') })}accept 表示当前模块愿意处理自己的更新,dispose 用来清理定时器、订阅、WebSocket 连接等副作用。很多业务项目不用手写这段,是因为 React、Vue 插件已经替你处理组件级刷新。为什么 Vite 的 HMR 通常更快Webpack 这类传统打包工具在开发期也能 HMR,但它的更新建立在打包产物和依赖图补丁之上。项目大了以后,重新编译和生成补丁的成本会变高。Vite 开发期不需要先把源码合成一个大 bundle,改哪个文件通常只转换哪个文件,速度不会随着项目模块数线性变慢。这并不代表 Vite HMR 永远是几十毫秒。如果改动的是被大量模块共同依赖的基础文件,例如全局样式、路由入口、状态管理单例,更新仍然可能扩大影响范围。快的前提是模块边界清晰,框架插件能正确识别组件更新。HMR 边界和整页刷新的取舍HMR 最难的地方不是重新加载代码,而是保证状态不乱。比如 React 组件改了样式或 JSX,保留组件状态通常是好事;但如果改了 Hook 调用顺序、模块顶层副作用或全局单例,强行热替换可能比刷新更危险。Vite 的策略是尽量精确更新,但在无法确认安全时刷新页面。开发时看到页面刷新,不一定是 Vite 慢,可能是当前模块没有形成可接受的 HMR 边界。常见原因包括:模块既导出组件又导出普通运行时变量、组件外部有不可清理的副作用、插件没有覆盖该文件类型。// vite.config.tsexport default { server: { hmr: { protocol: 'ws', host: 'localhost', port: 5173 } }}在容器、远程开发机或反向代理下,HMR 失败常常不是代码问题,而是 WebSocket 地址不通。页面能打开但保存后没反应时,先看浏览器 Network 里的 WS 连接是否成功。追问HMR 和 Live Reload 有什么区别?Live Reload 通常是文件变化后刷新整个页面,简单可靠,但页面状态会丢。HMR 只替换变化模块,理论上可以保留表单输入、组件状态和当前路由。取舍在于 HMR 需要模块声明更新边界,也需要框架插件配合。遇到状态被错误保留导致调试迷惑时,手动刷新反而更可信。为什么有些修改会触发整页刷新?因为 Vite 没找到安全的 HMR 接收方,或者插件判断这次更新不适合局部替换。比如修改入口文件、环境变量、模块顶层副作用,都会让局部更新风险变高。整页刷新是保守策略,不是失败。真正要排查的是为什么某个应该局部更新的组件没有形成边界。React 或 Vue 项目为什么很少手写 import.meta.hot?框架插件已经把常见组件更新逻辑封装好了。React Fast Refresh 会判断组件签名,Vue 插件会处理 SFC 的 template、script、style 更新。业务代码手写 HMR 主要出现在自定义状态容器、插件开发、WebSocket 客户端或非组件模块里。边界是:插件只能处理它理解的模式,写法太混合时仍可能退回刷新。HMR 失效应该从哪里排查?先看浏览器控制台和 Network,确认 WebSocket 是否连上。再看终端是否捕获到文件变化,容器挂载、网络盘和 WSL 场景都可能让文件监听不稳定。然后检查模块是否有语法错误或循环依赖,因为更新模块加载失败会中断本轮 HMR。不要一开始就改 Vite 配置,先确认链路上的“文件变化、消息发送、浏览器拉取”三步是否成立。HMR 会影响生产环境吗?不会,HMR runtime 只在开发环境注入,生产构建不会依赖它。需要注意的是,HMR 让开发反馈很快,但也可能掩盖初始化流程问题,因为页面没有从零加载。涉及登录态初始化、全局缓存、首屏请求顺序时,偶尔手动刷新页面是必要的。生产问题最终还是要用 vite build 和真实构建产物验证。
服务端阅读 05月31日 16:34

为什么 Vite 开发用 esbuild,生产构建却用 Rollup?

Vite 在开发环境主要依赖原生 ESM、esbuild 和开发服务器,在生产环境默认使用 Rollup 打包。这个设计不是“开发一套、上线另一套”的随意拼接,而是把两个阶段最在意的目标分开处理:开发时要快启动、快更新、少等待;上线时要稳定产物、代码分割、tree-shaking、资源压缩和缓存友好。开发环境为什么不先打包传统打包工具在启动 dev server 前,通常要先分析入口、递归构建依赖图,再把一大包代码交给浏览器。项目越大,启动越慢,改一个文件也可能牵动一片模块。Vite 的思路更直接:源码模块先按浏览器原生 ESM 方式请求,浏览器需要哪个文件,开发服务器就转换哪个文件。这里 esbuild 主要做两件事。第一是依赖预构建,把 CommonJS 或多入口依赖转换成更适合浏览器加载的 ESM。第二是处理 TypeScript、JSX 等语法转换,因为 esbuild 用 Go 写,冷启动和转换速度通常明显快于纯 JavaScript 转译链。// vite.config.tsimport { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], optimizeDeps: { include: ['react', 'react-dom'] }, server: { port: 5173, hmr: true }})这段配置里的 optimizeDeps 就是开发期优化依赖的入口。踩坑点在于:如果某个依赖内部动态引用很复杂,Vite 自动扫描可能漏掉它,表现为开发时偶发加载失败或 HMR 异常,这时手动 include 往往比怀疑业务代码更有效。生产环境为什么还要 Rollup生产环境关注的是最终资源质量。浏览器可以加载 ESM,但真实线上页面不能把几千个源码模块原样丢给用户,否则请求数量、缓存策略、兼容性和压缩效果都会变差。Rollup 更擅长静态分析,它能把模块合并成合理的 chunk,删除未使用导出,提取 CSS,并配合插件处理资源 hash。Vite 使用 Rollup 不是因为 esbuild 不快,而是因为“快”和“产物可控”不是同一个目标。esbuild 也能打包,但 Rollup 的插件生态、chunk 控制和库模式构建更成熟。对业务应用来说,生产构建慢一点可以接受;如果产物拆包不合理、缓存失效频繁或公共依赖重复打入多个 chunk,线上代价会更高。export default defineConfig({ build: { target: 'es2018', sourcemap: false, rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'] } } } }})manualChunks 不是越细越好。拆得太粗,首屏包大;拆得太细,请求和调度成本上升。常见取舍是把稳定的大依赖单独拆出来,让业务代码变更时不影响 vendor 缓存。开发和生产差异会带来什么边界Vite 的双模式很快,但也意味着开发环境不等于生产环境。开发期按需转换,生产期整体打包,所以有些问题只会在 vite build 后暴露,比如动态导入路径无法静态分析、Node API 被错误带入浏览器、CSS 顺序在打包后变化。因此团队里不能只跑 vite 就认为没问题。提交前至少跑一次 vite build,涉及路由懒加载、第三方库升级、浏览器兼容目标变化时,还应该用 vite preview 看构建产物。追问Vite 开发环境完全不用打包吗?严格说,源码部分通常不做传统意义上的全量打包,而是按浏览器请求即时转换。依赖部分会做预构建,因为很多 npm 包并不是天然适合浏览器直接按 ESM 加载。这个取舍让启动速度更快,但也带来依赖扫描边界,遇到动态 require 或条件导出时可能需要手动配置。不要把“不打包”理解成“不处理”,Vite 只是把处理时机拆开了。esbuild 这么快,为什么生产不全用它?esbuild 的优势是速度,尤其适合开发期频繁转换。生产构建还要考虑 chunk 结构、插件兼容、资源命名、CSS 提取和库模式输出,这些是 Rollup 长期积累的强项。用 esbuild 全量生产打包不是不行,但复杂项目里可控性通常不如 Rollup。实际选择时,开发体验优先速度,线上产物优先稳定和可调。开发正常但 build 失败通常查哪里?先看动态导入、环境变量和依赖格式。开发时某些代码路径可能没被浏览器请求到,生产构建会做全局静态分析,所以隐藏问题更容易暴露。常见坑是 import(path) 的 path 无法被 Rollup 推断,或把 process.env、fs 这类 Node 侧能力带进前端包。排查时不要只盯报错文件,顺着依赖链看是谁把不该进浏览器的代码引入了。如何判断要不要配置 manualChunks?小项目通常先别配,Vite 默认策略已经够用。只有当构建产物里某个 chunk 过大、公共依赖重复、或者业务发布导致大依赖缓存频繁失效时,再考虑手动拆分。配置时要看真实 bundle 分析结果,而不是凭感觉把所有库都拆出来。拆包本质是在首屏请求数和缓存命中率之间做取舍。vite preview 能代替线上验证吗?不能完全代替,但它能帮你用本地静态服务器检查生产产物。它适合发现资源路径、懒加载、路由 fallback、hash 文件引用等问题。边界在于它不等同于 CDN、网关和真实浏览器矩阵,也不会模拟线上缓存策略。上线前如果改了 base、反向代理或静态资源域名,仍然需要在接近生产的环境验证。
服务端阅读 05月31日 16:17

Vite 依赖预构建有什么用?什么时候需要手动配置?

Vite 启动快,并不是因为它什么都不做。业务源码交给浏览器按需请求,但 npm 依赖会先做一次依赖预构建。它把 CommonJS/UMD 转成 ESM,也把零散依赖合并成较少请求。为什么需要预构建浏览器原生 ESM 不认识裸模块导入,也不能直接执行 CommonJS。Vite 会扫描入口源码,找到第三方依赖,再用 esbuild 打到缓存目录。import React from 'react'import { debounce } from 'lodash-es'如果让浏览器逐个请求某些 ESM 包内部的几百个文件,开发首屏会很慢。预构建的取舍是冷启动多做一点工作,换来后续加载更稳定。缓存在哪里预构建结果通常在 node_modules/.vite。Vite 会根据 lockfile、package.json、配置文件判断缓存能否复用。依赖没变时,第二次启动通常更快。npx vite --force--force 会强制刷新预构建缓存。升级依赖、切分支或调整别名后,如果开发环境行为很怪,先刷新缓存是有效排查手段。不要把 .vite 提交到仓库,它只是本机开发产物。include 用来补扫描盲区Vite 的扫描很快,但不是全知全能。动态导入、运行时拼接、插件生成入口,都可能让依赖第一次没被发现。页面访问到新依赖后,Vite 会重新优化并刷新页面。import { defineConfig } from 'vite'export default defineConfig({ optimizeDeps: { include: ['lodash-es', 'dayjs/plugin/utc'] }})include 适合提前加入确定会用、但扫描器可能看不到的依赖。不要把所有依赖都塞进去,预构建越多,冷启动和缓存失效时等待越明显。exclude 不是性能开关exclude 用来排除不该预构建的依赖。比如某个包必须保持源码形态给插件处理,或者预构建后模块结构出错,才适合排除。export default defineConfig({ optimizeDeps: { exclude: ['some-vite-plugin-runtime'] }})常见误区是觉得大型依赖排除后一定更快。实际上它可能产生更多浏览器请求,或者导致 CommonJS 无法直接运行。是否排除要看错误信息和模块格式,不要只看包体积。esbuildOptions 只做兼容修正预构建由 esbuild 执行,因此可以通过 optimizeDeps.esbuildOptions 做少量定制,例如 define、loader 或 esbuild 插件。export default defineConfig({ optimizeDeps: { esbuildOptions: { define: { global: 'globalThis' }, loader: { '.js': 'jsx' } } }})边界是它只面对依赖预构建,不等同于整个项目的编译配置。修依赖兼容性可以,用它替代 Babel 或框架插件就不合适。monorepo 更容易踩坑在 monorepo 里,workspace 包可能既像源码,又像依赖。Vite 对链接依赖通常会按源码处理,方便调试,但深层依赖可能没有被预构建。export default defineConfig({ optimizeDeps: { include: ['@acme/shared > lodash-es'] }, server: { fs: { allow: ['..'] } }})本地包的取舍是调试便利和启动性能。复用包最好明确输出 ESM,并写清 exports、module 和 types。追问预构建和生产打包是一回事吗?不是。预构建主要服务开发环境,让依赖能被浏览器快速加载;生产构建由 Rollup 负责,目标是输出可部署产物。踩坑点是以为 optimizeDeps 能控制生产分包,实际生产分包要看 build.rollupOptions。为什么启动后页面会自动刷新一次?通常是首次扫描没发现某个依赖,页面访问到它时才触发重新预构建。预构建完成后模块地址变化,浏览器需要刷新。可以把这个依赖加入 include,用一点冷启动时间换更稳定的开发过程。exclude 大依赖能减少时间吗?有时能,但不是通用优化。排除后浏览器可能请求更多文件,Vite 也可能做更多运行时转换。只有预构建导致兼容问题,或依赖必须保持源码给插件处理时,才适合 exclude。CommonJS 包为什么依赖预构建?浏览器不能直接执行 require 和 module.exports。Vite 用 esbuild 把 CommonJS 转成 ESM,开发环境才能加载。边界是转换不能替代 Node 运行时能力,依赖如果需要 fs、path,前端仍要换库或做 polyfill。
服务端阅读 05月31日 16:17

如何开发一个 Vite 插件?常用钩子怎么选?

Vite 插件就是一个带 name 和若干钩子的对象。它和 Rollup 插件很像,因为生产构建阶段复用了 Rollup;但 Vite 还有开发服务器、HMR、HTML 转换和依赖预构建,所以写插件不能只按构建阶段思考。最小插件怎么写插件通常写成函数,方便接收选项。真正需要哪个钩子就写哪个,不要一开始就把所有钩子列满。import type { Plugin } from 'vite'export function replaceVersion(version: string): Plugin { return { name: 'replace-version', transform(code, id) { if (!id.endsWith('.ts') && !id.endsWith('.tsx')) return null return code.replace(/__APP_VERSION__/g, JSON.stringify(version)) } }}transform 很常用,也最容易拖慢项目。每个模块都可能经过它,所以一定要先过滤文件类型和范围。config 和 configResolved 怎么选config 适合补默认配置、插入别名、修改构建选项。configResolved 在最终配置确定后执行,适合读取配置、保存状态、输出调试信息,不建议再改配置。export function inspectMode(): Plugin { let isBuild = false return { name: 'inspect-mode', configResolved(config) { isBuild = config.command === 'build' }, transform(code, id) { if (isBuild && id.endsWith('.js')) return code return null } }}边界是“改配置”和“读配置”要分开。都塞进 configResolved,后续会遇到插件顺序和覆盖问题。resolveId 和 load 适合虚拟模块如果希望用户写 import data from 'virtual:xxx',就需要 resolveId 和 load。前者声明模块由插件接管,后者返回模块代码。export function virtualBuildTime(): Plugin { const virtualId = 'virtual:build-time' const resolvedId = '\0' + virtualId return { name: 'virtual-build-time', resolveId(id) { if (id === virtualId) return resolvedId }, load(id) { if (id === resolvedId) { return `export const buildTime = ${JSON.stringify(new Date().toISOString())}` } } }}虚拟模块适合构建时间、自动路由、主题变量、Markdown 索引。内部 ID 加 \0 前缀,可以避免被当成真实文件继续解析。configureServer 和 handleHotUpdate 只服务开发configureServer 可以加中间件,适合 mock、调试端点或开发期资源服务。handleHotUpdate 用来控制文件变化后的 HMR 范围。export function mockHealth(): Plugin { return { name: 'mock-health', apply: 'serve', configureServer(server) { server.middlewares.use('/__health', (_req, res) => res.end('ok')) } }}这些能力不会出现在生产构建里。要在文档里写清楚,否则使用者可能误以为插件提供了线上接口能力。插件顺序和阶段要克制enforce: 'pre' | 'post' 控制相对顺序,apply: 'serve' | 'build' 限制运行阶段。只服务开发的插件写 apply: 'serve',只改产物的插件写 apply: 'build'。export function devOnlyPlugin(): Plugin { return { name: 'dev-only', apply: 'serve', enforce: 'pre' }}不要滥用 pre。太早处理文件,可能抢在 Vue、React、Svelte 插件之前,把还没转换的框架文件处理坏。追问Vite 插件和 Rollup 插件能完全通用吗?不能完全通用。生产构建阶段兼容度高,开发阶段则有 dev server、HMR、HTML 入口等 Vite 专属行为。取舍是纯构建类插件尽量按 Rollup 写,开发体验类插件要使用 Vite 钩子。transform 为什么要先过滤 id?因为开发时每个请求模块都可能经过 transform,不过滤会处理大量无关文件。小项目不明显,大项目 HMR 会变慢。边界是不要一刀切排除所有依赖,有些 workspace 源码包确实需要被插件处理。什么时候该用虚拟模块?当数据没有真实源文件,或不想让业务直接读文件系统时,可以用虚拟模块。比如构建时间、路由清单、主题变量都合适。踩坑点是虚拟模块增加调试成本,所以命名和输出要稳定。handleHotUpdate 要不要精细控制?只有理解依赖关系时才精细控制。配置文件影响全局行为时,直接 full reload 往往比错误的局部 HMR 更可靠。取舍是刷新体验和正确性,插件开发里正确性优先。
服务端阅读 05月31日 16:17

Vite 配置文件怎么写?路径别名如何避免踩坑?

Vite 配置文件不复杂,但项目变大后,真正需要管理的东西会变多:开发服务器、代理、路径别名、构建输出、CSS、环境变量、依赖预构建和插件顺序。好的配置不是把所有选项都写满,而是把开发体验和生产产物的边界分清楚。配置文件从哪里来Vite 默认识别 vite.config.js、vite.config.mjs、vite.config.ts、vite.config.cjs。现代 TypeScript 项目通常用 vite.config.ts,配合 defineConfig 能拿到类型提示。import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], server: { port: 5173, open: true }, build: { outDir: 'dist', sourcemap: false }})如果配置要根据命令变化,可以导出函数。command 区分 serve 和 build,mode 对应 development、production 或自定义模式。export default defineConfig(({ command, mode }) => ({ base: mode === 'production' ? '/app/' : '/', build: { sourcemap: command === 'serve' }}))server 只影响开发环境server 常用选项有 host、port、open、proxy、cors、https、watch。本地联调最常见的是代理,把 /api 转到后端服务,避免开发环境跨域。export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'https://api.example.com', changeOrigin: true, rewrite: p => p.replace(/^\/api/, '') } } }})边界要记住:server.proxy 不会进入生产环境。上线后的代理要交给 Nginx、网关、BFF 或后端 CORS。build 决定产物形态build.outDir 控制输出目录,assetsDir 控制资源目录,target 决定语法目标,minify 决定压缩方式。复杂分包和文件命名通常写在 rollupOptions。export default defineConfig({ build: { target: 'es2020', chunkSizeWarningLimit: 800, rollupOptions: { output: { chunkFileNames: 'assets/[name]-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]' } } }})不要一看到 chunk 警告就手写大量 manualChunks。分包能改善缓存,也可能制造更多请求;先分析体积来源,再动配置更稳。路径别名要配两处Vite 的别名写在 resolve.alias,TypeScript 的编辑器和类型解析写在 tsconfig.json 的 paths。只配一边,常见结果是开发能跑但编辑器报错,或编辑器不报错但构建失败。import { fileURLToPath, URL } from 'node:url'import { defineConfig } from 'vite'export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }}){ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}`ESM 配置里不要直接用 __dirname。用 fileURLToPath(new URL(..., import.meta.url)) 更稳。CSS 和 optimizeDeps 怎么放CSS 配置常用于 CSS Modules、预处理器和 PostCSS;optimizeDeps 用来处理依赖预构建的扫描盲区或兼容性问题。export default defineConfig({ css: { modules: { localsConvention: 'camelCaseOnly' }, preprocessorOptions: { scss: { additionalData: '@use "@/styles/vars.scss" as *;' } } }, optimizeDeps: { include: ['lodash-es'] }})additionalData 适合注入变量和 mixin,不适合注入会产出实际 CSS 的规则,否则每个文件都会重复生成。追问为什么配了 @ 别名,编辑器还是报错?通常是只配了 vite.config.ts,没配 tsconfig.json。Vite 管运行和构建,TypeScript 管类型和编辑器提示,它们不是同一个解析器。取舍上两份配置有重复,但显式配置最可控。server.proxy 能当线上代理吗?不能。它只在 vite dev 的开发服务器里生效,构建产物只是静态文件。线上要用 Nginx、CDN 边缘函数、网关或后端 CORS;本地正常、线上 404,多半就是把开发代理当成了生产能力。base 应该怎么配置?部署在域名根路径就用 /,部署在 /app/ 就写 /app/。同时还要同步前端路由的 basename 或 history base。只改 Vite 的 base 不改路由,刷新深层页面仍可能出问题。optimizeDeps.include 什么时候需要?当动态导入、插件生成入口或依赖扫描盲区导致开发时反复重新优化,可以把依赖加入 include。但它不是越多越好,包含太多会拖慢冷启动。先看控制台提示和缓存行为,再决定是否手动配置。
服务端阅读 05月31日 16:17

Vite 如何加载图片、CSS 和 public 静态资源?

Vite 处理静态资源时,先分清两类文件:一类在 src 里被代码引用,会进入构建图;另一类放在 public,按原路径复制到输出目录。开发环境为了快,Vite 通常让浏览器直接请求资源;生产构建时,Rollup 会接管引用、加 hash、压缩或内联。src 中的图片和字体怎么加载放在 src/assets 的图片、字体、视频,只要被 JS、TS、Vue、React 组件或 CSS 引用,Vite 就能分析到它们。开发时导入结果通常是一个可访问 URL,构建后会变成带 hash 的文件名,方便长期缓存。import logoUrl from './assets/logo.png'const img = new Image()img.src = logoUrldocument.body.appendChild(img)这种方式最稳,因为 base、输出目录和文件 hash 都由 Vite 处理。不要在代码里拼 /src/assets/${name}.png,开发时可能能访问,构建后就容易失效。?url、?raw、?inline 有什么区别默认导入图片、字体等资源时,Vite 返回 URL。?raw 会把文件内容当字符串返回,适合文本模板、shader;?inline 会强制内联成 base64;?url 则显式要求返回资源地址。import fileUrl from './manual.pdf?url'import shader from './shader.glsl?raw'import icon from './icon.svg?inline'内联能少一次请求,但会让 JS 或 CSS 变大,也会让缓存粒度变粗。小 SVG 可以内联,大图片不要为了省请求硬塞进 bundle。CSS 也是模块Vite 允许直接导入 CSS,也支持 CSS Modules 和 Sass、Less 等预处理器。开发时样式会被注入并支持 HMR;生产时会提取、压缩,并根据 chunk 关系输出。import './global.css'import styles from './button.module.css'button.className = styles.primaryCSS Modules 适合组件局部样式,全局 CSS 适合 reset、主题变量和少量基础规则。不要把所有页面样式都塞进入口文件,否则异步页面拆包后仍会被全局样式影响。public 目录适合固定路径文件public 下的文件会原样复制到构建输出根目录,不会被 hash、压缩或依赖分析。站点图标、robots.txt、第三方验证文件、必须固定 URL 的下载文件适合放这里。<link rel="icon" href="/favicon.ico" /><img src="/brand/banner.png" />如果资源需要版本化缓存,就不要放 public;如果外部系统必须按固定路径访问,public 更合适。部署在子路径时要特别注意 base,根路径写死会导致线上资源 404。常用配置怎么写import { defineConfig } from 'vite'export default defineConfig({ base: '/app/', assetsInclude: ['**/*.glb'], build: { assetsInlineLimit: 4096, rollupOptions: { output: { assetFileNames: 'assets/[name]-[hash][extname]' } } }})assetsInclude 适合让 Vite 把自定义后缀当资源处理,比如 3D 模型或特殊二进制文件。如果需要读取内容并转换成代码,就应该写插件,而不是只加资源匹配。追问src/assets 和 public 应该怎么选?被组件或样式引用、希望加 hash 的资源放 src/assets。必须固定路径、给浏览器或第三方平台直接读取的文件放 public。取舍在于是否进入构建图:进入构建图更利于缓存和路径改写,不进入构建图更直接但不受 Vite 保护。assetsInlineLimit 调大是不是一定更快?不一定。调大会减少请求,但会增大 JS 或 CSS 体积,首屏解析和缓存失效成本也会增加。边界是只内联很小、稳定、首屏必用的资源,大图和复用图片通常单独输出更好。为什么生产环境图片路径变了?生产构建会给资源加 hash,并把它们复制到输出目录,这是为了长期缓存。代码只应该使用 import 得到的 URL,不要依赖开发时看到的 /src/... 地址。常见坑是接口返回源码相对路径,构建后前端无法再访问。CSS 为什么没有按源文件拆开?Vite 的 CSS 输出跟 JS chunk 关系有关,不是按目录机械拆分。同步入口样式可能合并,异步页面样式可能独立输出。取舍是请求数量和缓存粒度,排查时应看构建产物和引用链,而不是只看源文件名。
服务端阅读 05月31日 16:17

Vite 和 Webpack 该怎么选?

Vite 和 Webpack 的区别,不是“新工具淘汰旧工具”这么简单。Vite 开发阶段用原生 ESM 按需加载,生产阶段交给 Rollup 打包;Webpack 从一开始就围绕完整依赖图和打包体系设计。一个默认轻快,一个承载复杂工程多年积累,选择时要看项目边界。开发阶段差异最大Webpack dev server 通常要先分析入口、loader、plugin 和依赖图,项目越大,冷启动越慢。Vite 启动时先把服务器跑起来,浏览器请求哪个源码模块,再转换哪个模块;第三方依赖则通过 esbuild 预构建,减少 CommonJS 兼容和请求过多的问题。这也是 Vite HMR 通常更快的原因。它更接近局部更新,而 Webpack 往往要重新编译受影响的模块集合。对页面多、源码多、定制不重的项目,这个差距非常明显。生产构建各有优势Vite 不是全程 esbuild,生产构建默认使用 Rollup。Rollup 在 ESM tree-shaking、库模式和产物结构控制上很成熟。Webpack 的优势则是复杂 loader、Module Federation、历史插件生态和高度定制能力。export default defineConfig({ build: { target: 'es2020', rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'] } } } }})新项目、现代浏览器、Vue 3、React、Svelte、组件库文档和普通中后台,大多可以优先选 Vite。如果是多年老系统,依赖自定义 loader、旧浏览器兼容、私有构建平台或复杂微前端,Webpack 仍然可能是更稳的选择。迁移时别硬翻译配置迁移 Vite 最常见的坑有三类:CommonJS 和动态 require 处理不同;客户端环境变量要从 process.env 改成 import.meta.env;Webpack loader 和 Vite/Rollup 插件不是一一对应。不要把旧配置逐条翻译,很多配置在 Vite 里本来不需要。const api = import.meta.env.VITE_API_URL更稳的迁移方式是从新页面、文档站、组件预览或独立子应用开始。先验证依赖、环境变量、部署和监控链路,再决定是否扩大范围。追问Vite 一定比 Webpack 快吗?开发冷启动和 HMR 上,Vite 通常更快,尤其是现代前端项目。生产构建不一定,取决于压缩器、插件、依赖体积和分包策略。Webpack 如果用了持久化缓存和 SWC/esbuild loader,也可能表现不错。边界是不要拿空模板 benchmark 替真实项目做决定。老项目适合直接迁到 Vite 吗?不一定。老项目常有隐藏构建逻辑,比如自定义 loader、全局变量、polyfill 和特殊别名。直接迁移可能主流程正常,边缘页面或灰度环境才出问题。更稳的取舍是先迁低风险模块,再扩大范围。生产包体积谁更小?没有绝对答案。Vite 借助 Rollup 的 ESM tree-shaking,Webpack 也有 splitChunks、sideEffects 和成熟插件。包体积更多取决于依赖写法、是否按需加载、是否引入大而全的库。常见坑是换工具前不清依赖,让构建工具背锅。微前端场景选哪个?如果重度依赖 Module Federation,Webpack 仍然更成熟。Vite 也有相关插件,但在共享依赖、版本协商和遗留系统接入上要更谨慎。取舍是成熟稳定还是开发速度。核心交易系统不要为了工具升级牺牲可预测性。团队熟悉 Webpack,还值得换吗?如果启动和 HMR 已经明显拖慢效率,值得小范围试点。Vite 配置心智更轻,但团队仍要理解 ESM、Rollup 和环境变量差异。迁移成本还包括 CI、部署、监控和文档更新。不要只让一个人迁完,否则后续没人维护。
服务端阅读 05月31日 16:17

Vite 为什么不默认做 TypeScript 类型检查?

Vite 支持 TypeScript,但默认只转译,不做完整类型检查。很多人迁移后会疑惑:页面能跑,tsc --noEmit 却报错。原因是 Vite 把开发反馈速度放在前面,类型检查需要理解整个项目的类型图,如果塞进每次模块转换,HMR 会明显变慢。Vite 负责转译,不负责兜底.ts、.tsx 文件会被 esbuild 快速转成 JavaScript,类型标注会被擦除,JSX 和新语法也会被处理。但 esbuild 不会像 TypeScript 编译器那样做完整类型分析,所以“Vite 能运行”不等于“类型安全”。type User = { id: number }const user: User = { id: '1' } // 需要 tsc 才能稳定拦住tsconfig 怎么配Vite 项目建议使用 moduleResolution: "bundler",它更贴近现代打包器对 ESM、exports 和条件导出的解析方式。isolatedModules 建议打开,能提前暴露不适合单文件转译的写法。noEmit 让 TypeScript 只检查,不输出文件,避免和 Vite 构建产物混在一起。{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "isolatedModules": true, "noEmit": true, "skipLibCheck": true }, "include": ["src", "vite.config.ts"]}skipLibCheck 是现实取舍。业务项目开启它可以减少依赖声明文件带来的噪音和耗时;基础库、SDK 或类型要求极高的项目要谨慎,因为它可能掩盖第三方类型问题。类型检查放到哪里本地开发可以依赖编辑器提示,也可以接入 vite-plugin-checker。团队协作时,CI 必须执行 tsc --noEmit,否则有人关闭编辑器提示后,类型错误仍可能进入主干。import checker from 'vite-plugin-checker'export default defineConfig({ plugins: [checker({ typescript: true })]})路径别名要同时配置 Vite 和 TypeScript。resolve.alias 影响运行和构建,paths 影响编辑器和类型检查;只配一边,就会出现能运行但编辑器报错,或编辑器正常但构建失败。追问为什么 Vite 不直接检查类型?因为 Vite 的核心链路是按需转译模块,而完整类型检查是项目级静态分析。把它放进每次请求,会拖慢冷启动和 HMR。取舍是先获得运行反馈,再用编辑器、checker 或 CI 保证类型安全。踩坑点是把 Vite build 通过当成类型通过。vite-plugin-checker 和 tsc --noEmit 怎么选?本地想尽快看到错误,可以用 vite-plugin-checker。上线前要稳定兜底,应该跑 tsc --noEmit。插件更贴近开发体验,但大项目里也会占内存和 CPU。边界是合并门禁不要依赖某个开发者的编辑器。isolatedModules 为什么会让一些写法报错?它要求每个文件都能被独立安全转译,这和 esbuild 的工作方式一致。只导出类型却按值导入、部分 namespace 写法,都可能因此暴露问题。限制看起来烦,但能提前发现运行时会消失的类型代码。取舍是少一点 TS 写法自由,换来构建链路一致。路径别名为什么要配两份?Vite 和 TypeScript 是两个解析系统。只配 Vite,编辑器和 tsc 可能找不到模块;只配 TS,构建时可能解析失败。很多人以为是缓存问题,其实是配置源不一致。monorepo 里还要看 workspace 包的 exports 和声明文件。生产构建前还需要 ESLint 吗?需要,ESLint 和 TypeScript 关注点不同。TS 主要检查类型关系,ESLint 更擅长发现 Hook 依赖、未处理 Promise、禁用 API 和潜在逻辑问题。取舍是规则越多 CI 越慢,所以要区分阻断规则和建议规则。不要把 lint 只当格式化工具。
服务端阅读 05月31日 16:17

Vite 构建慢该从哪些地方优化?

Vite 性能优化先别急着堆配置,先判断慢在哪里:冷启动、页面首次加载、HMR,还是生产构建。Vite 开发环境用原生 ESM 按需转换,生产构建走 Rollup,两条链路的瓶颈不一样。把 Webpack 时代的“全量调参”直接搬过来,常见结果是配置变复杂,速度没明显提升。先定位瓶颈开发阶段优先看依赖预构建是否反复执行、插件 transform 是否过重、浏览器请求是否太碎。生产阶段重点看 source map、压缩器、大依赖和 chunk 策略。可以先用调试日志和产物分析确认问题,而不是凭感觉改配置。pnpm vite --debugpnpm build依赖预构建怎么配optimizeDeps 主要影响 dev server。对导入链复杂、CommonJS 包或请求特别碎的依赖,可以显式 include;对本地源码包、需要保留模块结构的包,再考虑 exclude。盲目排除大依赖会让浏览器请求暴增,启动看似快了,页面反而更慢。export default defineConfig({ optimizeDeps: { include: ['lodash-es', 'axios'], exclude: ['@my-org/source-lib'] }})构建优化看分包和压缩manualChunks 不适合一刀切。稳定的大依赖可以单独拆,编辑器、图表、富文本这类低频模块适合动态导入。压缩默认用 esbuild 通常够快;只有确实需要更细压缩规则时,再换 terser。export default defineConfig({ build: { sourcemap: false, minify: 'esbuild', rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom'] } } } }})插件也要按环境启用。图片压缩、可视化分析、Markdown 转换这类插件如果每次开发都跑,很容易拖慢 HMR。静态资源方面,assetsInlineLimit 不是越大越好,大图内联会撑大 JS 或 CSS,影响缓存。追问为什么 Vite 开发快,构建仍然可能慢?开发环境不做全量打包,只按浏览器请求转换模块,所以反馈很快。生产构建仍要完整分析依赖图、tree-shaking、分包和压缩,大项目自然会慢。这里的取舍是开发体验和产物质量不能用同一套机制解决。常见坑是误以为 dev 快就代表 build 一定快。manualChunks 应该怎么拆?优先按访问频率和缓存收益拆,而不是按库名机械拆。React、Vue 这类稳定核心依赖适合单独 chunk,低频页面依赖更适合懒加载。拆太细会增加请求调度成本,拆太粗会拖慢首屏。边界是首屏必须用的代码不要为了分包好看强行拆远。要不要把大依赖放 CDN?CDN 外部化能减小包体,也可能利用公共缓存。代价是运行时依赖外部服务,版本、可用性和内网访问都要兜底。官网或活动页可以考虑,后台系统和离线部署通常不适合。常见坑是本地构建正常,线上 CDN 被拦截导致白屏。source map 关掉是不是一定更好?关闭 source map 能缩短构建并减少产物体积,但线上排错会变难。更合理的是普通生产包关闭,灰度或错误监控单独上传 source map。取舍点是速度、源码安全和问题定位效率。不要把 source map 直接公开到静态目录。HMR 慢时先查什么?先查改动是否牵连全局入口、统一导出文件或巨大的状态模块。再查插件是否每次 transform 都做同步 IO 或全量扫描。Vite 本身 HMR 通常很快,慢多半来自项目结构和插件副作用。monorepo 里还要明确 watch 范围。
服务端阅读 05月31日 16:17

Vite 如何集成 Vue、React 和 Svelte?插件配置怎么选?

Vite 和框架集成的核心,不是把脚手架命令背下来,而是理解插件负责什么。Vite 自己处理开发服务器、依赖预构建、静态资源和 Rollup 构建;Vue、React、Svelte 这些框架特有的语法转换、热更新和编译选项,则交给对应插件。这样拆开看,配置就不容易乱。新项目优先用官方模板创建项目时最稳妥的方式是使用官方模板。模板会装好框架插件、入口文件和基础 TypeScript 配置,适合从零开始。npm create vite@latest my-vue-app -- --template vue-tsnpm create vite@latest my-react-app -- --template react-tsnpm create vite@latest my-svelte-app -- --template svelte-ts如果是老项目迁移,不建议一次性照搬模板。更安全的做法是先把构建入口跑通,再逐步迁移别名、环境变量、CSS 预处理器和测试配置。取舍点在于速度和稳定性:新项目追求开箱即用,迁移项目更需要可回滚。Vue 怎么接入Vue 项目主要使用 @vitejs/plugin-vue,它负责处理单文件组件、模板编译和 HMR。如果项目使用 JSX 或 TSX,再额外加 @vitejs/plugin-vue-jsx。import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import vueJsx from '@vitejs/plugin-vue-jsx'export default defineConfig({ plugins: [vue(), vueJsx()], resolve: { alias: { '@': '/src' } }})Vue 的边界是插件只负责编译,不替你决定状态管理、路由和组件库。比如 Element Plus、Pinia、Vue Router 都要按各自方式接入。踩坑点是路径别名只配 Vite 不够,TypeScript 还要在 tsconfig.json 里配 paths,否则编辑器会报找不到模块。React 怎么接入React 官方插件通常选 @vitejs/plugin-react。它支持 Fast Refresh,也会处理 JSX 转换和 Babel 能力。如果团队追求更快的编译速度,可以评估 @vitejs/plugin-react-swc,但 Babel 插件生态依赖重的项目要谨慎。import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], server: { port: 5173 }, build: { sourcemap: true }})这里的取舍很实际:Babel 版兼容性更稳,SWC 版速度更好。大多数业务项目先用 Babel 版足够,只有在冷启动或 HMR 明显慢时再切换。React 17 以前的项目还要确认 JSX runtime 设置,避免升级 Vite 后出现 JSX 编译行为不一致。Svelte 和 Solid 怎么接入Svelte 使用 @sveltejs/vite-plugin-svelte,Solid 使用 vite-plugin-solid。这类框架的编译器参与度更高,所以不要随便混用插件顺序。一般把框架插件放在 plugins 数组前面,再放检查、压缩、分析等辅助插件。import { defineConfig } from 'vite'import { svelte } from '@sveltejs/vite-plugin-svelte'export default defineConfig({ plugins: [svelte()], css: { preprocessorOptions: { scss: { additionalData: '@use "src/styles/vars" as *;' } } }})通用配置别忘了配两处路径别名、环境变量、CSS 预处理器是所有框架都会遇到的配置。Vite 负责运行时和构建解析,TypeScript 负责编辑器和类型检查,两边经常都要配。{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}多框架仓库怎么处理如果一个 monorepo 里同时有 Vue、React 和组件库,建议每个应用保留自己的 vite.config.ts,公共配置抽成函数复用。不要为了“统一”写一个巨大配置文件,然后用一堆条件判断区分框架。那样短期看少了重复,长期会让插件顺序、别名和构建输出互相牵连。公共层可以放端口约定、alias 生成、环境变量校验和构建分析插件,框架插件仍然留在各应用里。边界清楚以后,某个 React 应用切到 SWC,不会影响 Vue 应用的编译行为。测试配置也要一起考虑。Vitest、Playwright 或 Jest 不一定自动读取 Vite 的 alias 和 env,迁移时经常出现应用能跑、测试失败的情况。稳妥做法是把路径解析和环境变量校验抽成小工具,应用、测试和构建共用同一份约定。追问Vite 集成框架时,插件顺序重要吗?重要,但不用过度紧张。框架插件通常应该靠前,因为它们要先处理 Vue SFC、JSX 或 Svelte 文件。后面的插件再做检查、分析、压缩或自定义转换。踩坑点是多个插件都改同一种文件时,顺序会影响最终代码,遇到奇怪编译问题先简化 plugins 数组。Vue 和 React 项目的 Vite 配置差异大吗?基础能力差不多,差异主要在框架插件。Vue 插件处理模板和 SFC,React 插件处理 JSX、Fast Refresh 和 Babel/SWC 转换。取舍不在 Vite,而在框架生态:Vue 更强调模板编译约定,React 更依赖 JSX 和函数组件习惯。通用配置如 alias、proxy、env、build 基本可以复用。老 Webpack 项目迁到 Vite 要注意什么?不要一开始就追求配置完全等价。Webpack loader、DefinePlugin、动态 require、别名和静态资源规则,都可能需要重写成 Vite/Rollup 的方式。边界是能用浏览器原生 ESM 的代码迁移最顺,依赖 CommonJS 魔法写法的项目会更麻烦。建议先迁入口和开发服务器,再处理构建产物差异。React 插件选 Babel 还是 SWC?Babel 版生态兼容性更好,适合依赖 Babel 插件、宏或老项目的团队。SWC 版速度更快,适合配置简单、项目较大的场景。取舍是性能换生态,不能只看 benchmark。切换前最好跑完整测试和构建,因为一些非标准语法转换在两边表现可能不同。为什么 alias 配了 Vite,编辑器还是报错?因为 Vite 的 resolve.alias 只影响开发服务器和构建。TypeScript、ESLint、测试框架都有自己的解析逻辑,编辑器通常看的是 tsconfig.json。边界是运行能成功不代表类型系统能识别。解决方式是同步配置 compilerOptions.paths,测试工具也要按需补 alias 映射。
服务端阅读 05月31日 16:17

Vite 环境变量怎么用?为什么只有 VITE_ 前缀能进客户端?

Vite 的环境变量分两类:一类给构建工具和服务端配置用,一类会被注入到浏览器代码里。很多线上事故都出在这里:以为 .env 里的变量只是本地配置,结果把密钥用 VITE_ 开头写进了前端包。记住一句话,凡是能通过 import.meta.env 在客户端读到的值,都应该被当成公开信息。.env 文件怎么加载Vite 会按当前 mode 读取环境变量。常见文件有 .env、.env.local、.env.development、.env.production、.env.[mode].local。本地覆盖文件通常不要提交,因为它经常放个人端口、测试地址或临时开关。# .envVITE_APP_TITLE=Admin ConsoleVITE_API_BASE=/api# .env.stagingVITE_API_BASE=https://staging-api.example.com# .env.local,不提交LOCAL_PROXY_TARGET=http://127.0.0.1:7001客户端代码只能访问以 VITE_ 开头的变量,这是 Vite 的安全边界。这个边界不是为了加密,而是为了避免你无意把 DB_PASSWORD、JWT_SECRET 这类服务端变量打进 JS。变量会在构建时被静态替换,所以生产包里的值不会随着服务器环境自动变化。const apiBase = import.meta.env.VITE_API_BASEconst isDev = import.meta.env.DEVconst mode = import.meta.env.MODE内置变量有哪些import.meta.env.MODE 表示当前模式,默认开发是 development,构建是 production。DEV 和 PROD 是布尔值,适合控制调试面板、mock 逻辑和埋点开关。BASE_URL 来自 base 配置,部署到子目录时很有用。SSR 表示代码是否运行在服务端渲染环境,写同构逻辑时要特别注意。vite.config 里怎么读取在配置文件里不能直接依赖客户端的 import.meta.env。需要用 loadEnv(mode, root, prefix) 主动加载,第三个参数决定读取哪些前缀。这里有个取舍:传空字符串可以读到所有变量,方便配置代理;但也更容易误用敏感信息,所以只在配置层使用,不要再塞回客户端。import { defineConfig, loadEnv } from 'vite'export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { proxy: { '/api': { target: env.LOCAL_PROXY_TARGET, changeOrigin: true } } } }})TypeScript 项目还应该补类型,否则变量名写错只能到运行时才发现。/// <reference types="vite/client" />interface ImportMetaEnv { readonly VITE_APP_TITLE: string readonly VITE_API_BASE: string}interface ImportMeta { readonly env: ImportMetaEnv}常见配置边界环境变量还经常和代理、部署路径、CDN 地址混在一起。接口代理通常只属于开发服务器配置,应该写在 server.proxy,不要让业务代码感知本地代理目标。部署子路径则应该优先配置 base,再通过 import.meta.env.BASE_URL 拼静态资源路径。这样区分以后,开发、预发、生产不会因为一个变量名承担太多含义而互相影响。还有一个容易忽略的点:.env 文件变更后通常要重启 Vite dev server。因为配置和环境变量是在启动阶段加载的,不是每次模块热更新都重新读取。如果确实需要运行时切换配置,可以让后端在 HTML 里注入 window.__APP_CONFIG__,或者让前端启动后先请求一个公开配置接口。这样会多一次维护成本,但能避免每个环境都重新打包。边界仍然一样,接口返回的内容也是公开的,不能放密钥。追问为什么生产环境改了服务器变量,前端页面没变化?因为 Vite 的客户端环境变量是在构建时替换的,不是浏览器运行时再去服务器读。你改了容器或机器上的环境变量,但没有重新 build,打出来的 JS 还是旧值。边界是:构建期配置用 Vite env,运行期配置要走接口、HTML 注入或独立配置文件。多租户系统尤其要注意,不能把租户域名这类运行时信息硬编码进包里。VITE_ 前缀是不是安全机制?它更像防误伤机制,不是安全机制。加了 VITE_ 的值会进入客户端包,任何人都能在源码或网络资源里看到。取舍是公开配置可以用它,比如页面标题、接口基础路径、Sentry DSN;真正的密钥必须放服务端。踩坑最多的是把第三方 secret 写成 VITE_XXX_SECRET,这等于主动泄露。mode 和 NODE_ENV 是一回事吗?不是。mode 决定 Vite 加载哪组 .env.[mode] 文件,NODE_ENV 更多影响依赖库的生产/开发分支。你可以用 vite build --mode staging 生成预发包,但它仍然是生产构建。边界是不要用 MODE !== 'production' 判断是否压缩或是否启用调试,应该优先看 import.meta.env.DEV 和 PROD。环境变量应该怎么做类型和默认值?类型文件只能告诉 TypeScript “这个变量应该存在”,不能保证运行时真的有值。关键变量最好在启动或构建阶段做校验,缺了就抛错。取舍是简单项目可以直接读,企业项目建议封装一层 env.ts,集中处理默认值、布尔转换和错误提示。布尔值也要小心,.env 读出来都是字符串,"false" 在 JS 里仍然是真值。框架项目里环境变量放在哪里更合适?纯前端 Vite 应用可以放在项目根目录的 .env 系列文件里。SSR 框架或 BFF 项目要区分客户端变量和服务端变量,不能为了省事全部加 VITE_。边界是:浏览器需要知道的才进 VITE_,服务器专用的只在服务端读取。CI/CD 里通常提交 .env.example,真实值由流水线或部署平台注入。
前端阅读 05月31日 16:17

MobX 的核心概念是什么?它是怎么自动更新视图的?

MobX 解决的是一个很具体的问题:状态变了以后,哪些地方应该跟着变,不需要你手动列清单。它把应用里的数据看成一张依赖图,组件、计算值和副作用只要在运行时读过某个状态,就会被记录为这个状态的消费者。之后状态被修改,MobX 沿着这张图通知真正受影响的部分,所以它看起来像“自动更新”,实际靠的是运行时依赖追踪。MobX 的几个核心概念observable 是可观察状态,通常是对象属性、数组或 Map。它的关键不是“存数据”,而是让读写行为能被 MobX 捕获。computed 是从状态派生出来的值,比如 fullName、过滤后的列表、购物车总价。它默认惰性计算,只有被读取时才执行,并且依赖没变时直接复用缓存。这里的取舍很明显:computed 适合纯计算,不适合发请求、写日志这类副作用。action 是修改状态的边界。MobX 6 推荐把状态修改放在 action 里,因为 action 会批量提交变更,避免中间状态触发多次 reaction。团队项目里最好开启 enforceActions: "always",否则代码越写越散,很难追踪是谁改了状态。reaction / autorun / when 负责处理副作用。autorun 会立即执行并自动追踪用到的状态,reaction 可以精确指定观察的数据,when 在条件满足后执行一次就销毁。import { makeAutoObservable, configure } from 'mobx'configure({ enforceActions: 'always' })class TodoStore { todos: { id: number; text: string; done: boolean }[] = [] filter: 'all' | 'done' | 'active' = 'all' constructor() { makeAutoObservable(this) } get visibleTodos() { if (this.filter === 'done') return this.todos.filter(t => t.done) if (this.filter === 'active') return this.todos.filter(t => !t.done) return this.todos } addTodo(text: string) { this.todos.push({ id: Date.now(), text, done: false }) }}它为什么能自动更新视图在 React 里,observer 会包住组件渲染过程。组件渲染时读取了 store.visibleTodos,MobX 就知道这个组件依赖了对应的 computed;computed 又依赖 todos 和 filter。当 addTodo 或 filter 变化时,依赖链被标记为过期,组件才重新渲染。import { observer } from 'mobx-react-lite'export const TodoList = observer(({ store }: { store: TodoStore }) => ( <ul>{store.visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>))边界也要清楚:MobX 只能追踪“运行时实际读取”的 observable。如果你提前把值解构成普通变量,再在组件外传来传去,依赖关系可能丢失。异步代码里也要注意,await 之后再修改状态,仍然需要在 action 或 runInAction 里完成。项目里怎么落地实际项目不建议把所有状态塞进一个全局 store。更稳的做法是按业务边界拆分,比如用户、权限、编辑器、购物车各自维护自己的状态,再在页面层组合使用。这样做的好处是更新范围小,测试也更容易写。边界是跨模块共享的数据不要随意互相 import,否则 store 之间会形成隐式依赖,后面重构很难拆。调试时可以配合 spy 或 MobX DevTools 观察 action 和 reaction,但不要把调试工具当成架构。真正能降低维护成本的,还是明确哪些方法能改状态、哪些 getter 只能派生数据。追问MobX 和 Redux 应该怎么选?MobX 更适合对象关系复杂、局部更新频繁的业务,比如表单编辑器、低代码画布、后台配置台。Redux 的优势是约束强,状态变更路径清楚,适合需要审计、回放和严格团队规范的项目。取舍点不在谁更先进,而在团队是否愿意用约束换可预测性。小团队快速迭代时 MobX 很舒服,但多人长期维护时要补上 action 规范和目录约定。computed 和普通函数有什么区别?普通函数每次调用都会重新执行,computed 会根据依赖做缓存。只有依赖变化并且有人读取它时,computed 才重新计算,这对列表过滤、聚合统计很有用。边界是 computed 必须保持纯净,不要在里面改状态或发请求。踩坑点是 computed 没有消费者时不会主动运行,所以不要指望它替你触发业务流程。autorun 和 reaction 有什么区别?autorun 适合“用到什么就追踪什么”的简单副作用,比如调试日志。reaction 更适合生产代码,因为它把数据选择和副作用分开,只在选择结果变化时触发。取舍是 autorun 写起来快,但依赖容易变得隐式;reaction 啰嗦一点,却更可控。项目里如果副作用会发请求或写本地缓存,优先用 reaction。使用 MobX 最容易踩什么坑?最常见的是把 observable 过早解构,导致 observer 组件没有在渲染阶段读取状态。另一个坑是异步请求回来后直接赋值,在严格 action 模式下会报错。边界处理方式是:组件里读 store,异步结果用 runInAction 合并回状态。还有一点,reaction 创建后要保留 disposer,在组件卸载或模块销毁时释放,否则会有隐性内存泄漏。MobX 适合所有状态吗?不适合。服务端缓存、分页请求、重试状态这类数据,用 TanStack Query 一类工具通常更合适。MobX 更适合客户端本地状态,尤其是用户正在编辑、拖拽、筛选、组合的状态。取舍上可以把远端数据交给请求缓存库,把前端交互状态交给 MobX,两者不要硬塞进同一个 store。
前端阅读 05月31日 15:55

MobX 和 Redux 到底该怎么选?适合哪些场景?

MobX 和 Redux 的区别不只是 API 写法不同,而是状态管理哲学不同。Redux 强调显式数据流:组件 dispatch action,reducer 生成新 state,状态变化可以被记录和回放。MobX 强调响应式模型:你修改 observable,系统自动知道哪些 computed、reaction 或 observer 组件需要更新。如果用一句话选型:需要强约束、审计和统一协作时偏 Redux;需要快速建模复杂业务对象、减少样板代码时偏 MobX。现在 Redux Toolkit 已经大幅减少模板代码,所以不能再简单说“Redux 一定啰嗦”。但 MobX 在深层对象、表单状态和局部复杂交互里仍然很顺手。// Redux Toolkitconst slice = createSlice({ name: "counter", initialState: { value: 0 }, reducers: { inc: state => { state.value += 1; } }});// MobXclass CounterStore { value = 0; constructor() { makeAutoObservable(this); } inc() { this.value += 1; }}Redux Toolkit 里看起来也能“直接改 state”,但那是 Immer 帮你生成不可变结果。MobX 的直接修改则是它本身的响应式模型,依赖追踪发生在读取和写入之间。两者都能写得很现代,真正影响选择的是团队调试方式、业务复杂度和长期维护成本。还有一个现实因素是招聘和交接成本。Redux 的资料、范式和候选人经验更多,新人即使没接触过项目,也容易顺着 action、slice、selector 找到入口。MobX 项目如果 store 设计得好,上手同样很快;如果设计得随意,新人需要先理解一套隐式依赖网络。选型时把团队未来一年的人数变化也算进去,往往比单纯比较代码量更实际。追问Redux 的优势现在还明显吗?明显,尤其是在多人协作和复杂状态审计场景里。Redux 的 action 日志、DevTools、时间旅行调试仍然很强,线上问题复盘时能看到状态如何一步步变化。取舍是你要接受更明确的流程和更多约束,哪怕 Redux Toolkit 已经减少了不少样板。金融、交易、权限流转这类系统,显式数据流带来的可追溯性通常比少写几行代码更重要。MobX 更适合哪些业务?MobX 适合状态结构像业务对象一样自然变化的场景,比如复杂表单、编辑器、看板、低代码配置器和局部交互很多的后台页面。它允许你用 class 表达领域模型,用 computed 表达派生值,用 observer 自动连接 UI。边界是自由度越高,团队规范越重要。若大家随手在任意位置改 observable,又不给 action 命名,后期排查会比 Redux 更痛苦。性能上 MobX 一定比 Redux 更好吗?不一定,但 MobX 的默认更新粒度通常更细。它追踪组件实际读取的 observable 字段,所以某个字段变化只影响真正用到它的组件。Redux 依赖 selector 和引用比较,写得好同样很快,写得差则容易因为新对象、新数组导致重复渲染。取舍在于 MobX 把优化自动化,Redux 把优化显式化;前者省心,后者更可控。TypeScript 项目选哪一个更舒服?MobX 的 class 模型和 TypeScript 搭配很自然,字段、方法、getter 的类型就是业务模型本身。Redux Toolkit 的类型体验也已经比旧 Redux 好很多,createSlice 能推断 action 和 state,但异步 thunk、RootState、Dispatch 仍然需要一些模板。取舍是 MobX 写业务模型更顺,Redux 写团队规范更统一。大型团队里,类型舒服不一定是唯一目标,统一的数据流和工具链也很值钱。能不能在一个项目里同时用 MobX 和 Redux?可以,但要非常克制。比如全局登录态、权限、审计相关状态放 Redux,某个复杂编辑器内部用 MobX 管局部模型,这是有边界的混用。踩坑点是没有划清职责,导致同一份数据在两个 store 里各存一份,最终同步逻辑比状态管理本身还复杂。除非收益明确,否则更建议选一个主方案,再用局部 React state 或轻量库补足边角。如果项目并不复杂,却又觉得 Redux 和 MobX 都偏重,也可以把 Zustand、Jotai、Valtio 这类轻量方案纳入比较。Zustand API 简单,适合轻量全局状态;Jotai 更偏原子化组合;Valtio 则接近可变对象代理的体验。这里的取舍是生态、团队熟悉度和调试能力,不要只看示例代码短不短。状态管理选型最怕为了“新”而换,最后业务复杂度没降,团队学习成本反而升了。落地时可以先画出状态的生命周期:哪些状态跨页面共享,哪些只服务某个复杂组件,哪些需要被审计或回放。跨团队、跨流程的状态更适合 Redux 这种强约束方案;局部领域模型、频繁编辑和深层对象更适合 MobX。这个判断比“哪个库更流行”靠谱,因为状态管理的问题通常不是 API 不够漂亮,而是边界没有定义清楚。所以 MobX 和 Redux 没有绝对胜负。Redux 像一套清晰的交通规则,MobX 像更灵活的自动导航;项目越重协作和审计,越需要规则,项目越重局部复杂交互,越能体现 MobX 的效率。
服务端阅读 05月31日 15:55

MobX 中 observable、computed 和 action 该怎么分工?

observable、computed 和 action 是 MobX 里最容易混在一起的三个词,但分工其实很清楚:observable 放状态,computed 放由状态推导出的值,action 放修改状态的过程。一个常见判断是,如果它需要被 UI 观察,就用 observable;如果它能由已有状态算出来,就别再存一份;如果它会改变状态,就让它进入 action 边界。import { makeAutoObservable, runInAction } from "mobx";class TodoStore { todos = [] as { title: string; done: boolean }[]; filter: "all" | "done" = "all"; constructor() { makeAutoObservable(this); } get visibleTodos() { return this.filter === "done" ? this.todos.filter(t => t.done) : this.todos; } add(title: string) { this.todos.push({ title, done: false }); }}上面 todos 和 filter 是 observable,visibleTodos 是 computed,add 是 action。使用 makeAutoObservable 时 MobX 会自动推断:字段变成 observable,getter 变成 computed,方法变成 action。它很省事,但不是没有边界;复杂 store 里仍然可以显式配置某些字段不追踪,避免把临时对象、第三方实例也变成 observable。这三个概念放到一起看,还能避免一个常见误区:不要为了“方便更新”把所有派生值都存成 observable。比如 totalPrice、selectedCount、isValid 这类值大多能从已有状态计算出来,存两份反而会带来同步问题。computed 的价值就在这里,它让数据源保持单一,同时又避免每次渲染都重复计算。makeAutoObservable 虽然好用,但在大型 store 里不要完全无脑依赖推断。某些字段可能只是缓存第三方实例,某些方法可能不应该自动绑定,某些深层对象也可能需要 observable.ref 这类更浅的追踪方式。选择默认推断还是显式 annotation,本质是开发效率和可控性的取舍。初期可以先自动推断,等 store 稳定后再把关键边界写清楚。测试时也能看出三者分工是否合理。observable 负责准备初始状态,action 负责触发业务行为,computed 负责断言结果是否正确。如果测试必须手动改很多中间值,往往说明 store 把派生值也当状态保存了,或者 action 颗粒度切得太碎。还有一个判断技巧:看这段代码是在回答“现在是什么”,还是“接下来做什么”。回答“现在是什么”的通常是 observable 或 computed,回答“接下来做什么”的通常是 action。比如 isSubmitDisabled 是当前表单状态的派生结论,应放 computed;submitForm 会校验、请求并写入结果,应放 action。边界清楚后,组件就不需要知道太多状态更新细节,只负责展示和触发行为。追问observable 是不是越多越好?不是,observable 应该只包会影响界面的业务状态。把所有对象都做成 observable 会增加理解成本,也可能让外部库实例被代理后行为变怪。取舍在于便利和边界:页面状态、表单值、接口数据适合观察,DOM 节点、WebSocket 实例、不可变配置通常不适合。踩坑点是把服务类、路由对象也塞进 store,后面调试时很难分清哪些变化真的应该触发 UI。computed 和普通函数有什么区别?computed 有缓存,并且会被 MobX 追踪依赖;普通函数每次调用都会重新执行。对于列表过滤、金额汇总、权限派生这类由 observable 算出的值,computed 通常更合适。边界是 computed 不适合接收经常变化的临时参数,也不应该包含副作用。实际项目里如果一个 getter 里顺手改了 loading 或发了请求,后续很容易出现循环触发。action 只是为了规范代码吗?action 不只是风格约束,它还提供状态修改的事务边界。一个 action 里连续改多个 observable,MobX 会等 action 结束后再通知 reaction,避免 UI 看到中间状态。取舍是你需要把“读”和“写”分清楚,不能在任何地方随手改 store。开启严格模式后,没放进 action 的修改会报错,这在团队协作时反而是好事。异步 action 应该怎么写才安全?async 方法可以作为 action 入口,但 await 之后的代码已经进入新的异步片段。为了让后续赋值仍然处在 action 边界内,可以用 runInAction 包住结果写入。这个写法比直接赋值多一点样板,但能避免严格模式问题,也能让状态变更集中。踩坑点是接口失败时只改了 error,忘记把 loading 改回 false,页面就会一直转圈。async loadTodos() { this.loading = true; try { const todos = await api.listTodos(); runInAction(() => { this.todos = todos; }); } finally { runInAction(() => { this.loading = false; }); }}三者在 React 组件里怎么配合?组件里应该尽量读取 observable 和 computed,而不是把派生逻辑散落在 JSX 中。按钮点击、表单提交这类事件则调用 action,让修改路径可追踪。取舍是 store 会稍微“厚”一点,但组件会更薄,测试和复用更容易。边界是不要把所有 UI 细节都塞进全局 store,弹窗开关、输入框临时草稿可以留在局部组件状态里。掌握这三者后,MobX 代码会变得很直观:状态放一处,派生值只计算不保存,修改都走明确的方法。真正要避免的不是 API 用错,而是把可观察状态、计算逻辑和副作用混在同一段代码里。
前端阅读 05月31日 15:55

MobX 依赖追踪到底是怎么知道该更新谁的?

MobX 依赖追踪的核心可以用一句话概括:谁在运行时读了 observable,谁就会被登记为它的依赖;以后这个 observable 变了,只通知登记过的人。这里的“谁”可能是 autorun、reaction、computed,也可能是被 observer 包装的 React 组件。MobX 不靠你手写依赖数组,而是靠运行时读取行为建立依赖图。一个最小例子如下。autorun 第一次执行时会读取 store.count,MobX 会把当前 reaction 和 count 这个可观察字段连起来。之后 count 改变,autorun 会重新执行;但如果改的是 name,这个 reaction 不会受影响。import { autorun, makeAutoObservable } from "mobx";class Store { count = 0; name = "MobX"; constructor() { makeAutoObservable(this); }}const store = new Store();const dispose = autorun(() => { console.log(store.count);});store.count++; // 触发 autorunstore.name = "Redux"; // 不触发上面的 autorundispose();从内部看,observable 字段像一个最小的发布者,reaction 像订阅者。运行 reaction 时,MobX 会设置一个“当前正在追踪的上下文”,字段 getter 被访问后就把这个上下文记录下来。执行结束后,旧依赖会被对比和清理,所以条件分支切换时,MobX 不会永远订阅已经不再读取的字段。依赖追踪还有一个经常被低估的边界:MobX 只追踪同步执行期间的读取。你在 reaction 里读到的 observable 会被记录,但 setTimeout、Promise 回调或事件监听器里后来才读到的字段,不会自动算进同一次 reaction 的依赖。这个规则解释了很多“明明在函数里用了状态,为什么没更新”的问题。数组、Map 和对象属性也有类似细节。读取 todos.length 追踪的是长度变化,读取 todos[0].title 追踪的是第一个元素及其 title,遍历整个数组则会建立更宽的依赖。依赖越宽,更新越容易触发;依赖越窄,性能越好但也更依赖你把读取位置写对。真实项目里,列表筛选建议放 computed,组件只读取最终结果,这样依赖和缓存都更清楚。最后要记住,自动追踪不是自动设计架构。MobX 能帮你找到“谁读了谁”,但不能替你决定 store 怎么拆、异步流程怎么收口、哪些状态应该全局共享。依赖图一旦跨模块乱连,短期很方便,长期会变成难以拆解的网。追问MobX 和 React 的依赖数组有什么区别?React 的 useEffect 依赖数组是你手动声明的,写漏了会闭包过期,写多了又可能重复执行。MobX 的依赖来自运行时读取,读了什么就追踪什么,不需要人工维护列表。取舍是 MobX 更省心,但依赖关系不总是一眼能从代码声明处看出来。团队里如果大量使用隐式读取,就要配合 trace、命名 reaction 和清晰的 store 边界。computed 为什么能缓存,什么时候会重新算?computed 本质上是一个有缓存的派生值,它会记住自己上次计算时读取了哪些 observable。只要这些依赖没有变化,再次读取 computed 会直接返回缓存结果。边界是 computed 必须是纯计算,不能在里面发请求、改状态或写日志埋点这类副作用。踩坑最多的是把带参数的筛选逻辑硬塞进 computed,参数变化不属于 observable 时,缓存行为就不符合预期。条件分支里的依赖会不会追踪错?不会静态追踪所有分支,只追踪当前这次运行实际访问到的字段。比如 showAge ? user.age : user.name,当 showAge 为 false 时,age 不会成为当前依赖。取舍是这让更新更精准,但也要求你理解“依赖是动态变化的”。如果条件切换后旧依赖没有清理,通常说明 reaction 没有重新执行,或者读取发生在了追踪上下文之外。为什么不建议在 reaction 里做太多事情?reaction 适合连接“状态变化”和“副作用”,但不适合承载大段业务流程。它会随依赖变化自动执行,逻辑过重时很容易出现重复请求、状态互相触发甚至循环更新。项目里的边界可以这样划:状态修改放 action,派生值放 computed,真正需要同步到外部系统时才用 reaction。踩坑点是忘记保存 dispose,页面卸载后 reaction 还活着,就会造成内存泄漏或幽灵请求。如何调试一个组件为什么重渲染?可以先在组件或 computed 中调用 trace(),观察 MobX 认为它依赖了哪些 observable。再看这些 observable 是不是在不该修改的时候被 action 改了,尤其是表单初始化、接口回填和路由切换。这个排查有个取舍:MobX 的自动追踪减少了手写优化,但问题出现时要从“读取链路”而不是“dispatch 链路”入手。实战中给 store action 起清楚的名字,比事后猜哪个字段触发更新要省很多时间。autorun(() => { trace(); console.log(store.visibleUserName);});MobX 的依赖追踪并不神秘,它只是把“读”和“写”都接管了。理解这一点后,很多问题都能归结为两个检查:状态有没有被观察,读取有没有发生在 reaction 执行期间。
服务端阅读 05月31日 15:55

React 里 MobX observer 为什么能自动更新组件?

MobX 的 observer 不是简单地给组件加一个订阅开关,它会在组件渲染时记录“这次 render 到底读了哪些 observable”。之后只有这些被读过的状态变化,组件才会重新渲染。也就是说,observer 的关键不是“组件用了 store”,而是“组件在渲染期间访问了 store 的哪个字段”。在 React 项目里,函数组件通常使用 mobx-react-lite,类组件才会用到 mobx-react。MobX 6 以后更推荐 makeAutoObservable,少写装饰器,也更适合 TypeScript 和现代构建环境。import { makeAutoObservable } from "mobx";import { observer } from "mobx-react-lite";class CounterStore { count = 0; name = "MobX"; constructor() { makeAutoObservable(this); } inc() { this.count += 1; }}const store = new CounterStore();export const Counter = observer(() => { return <button onClick={() => store.inc()}>{store.count}</button>;});上面组件只读取了 store.count,所以 store.name 改变不会让它重渲染。这个粒度比“整个 store 变化就刷新”要细很多,也是 MobX 在复杂表单、局部状态很多的页面里比较省心的原因。实际接入时,还要想清楚 store 从哪里来。小 demo 里直接 const store = new CounterStore() 没问题,但真实应用通常会用 React Context 注入 store,避免测试、SSR 或多实例页面互相污染。尤其是 Next.js 这类服务端渲染场景,全局单例可能把 A 用户的状态带到 B 用户请求里,这是很隐蔽的边界问题。另一个容易忽略的点是 React 18 的 StrictMode。开发环境下某些渲染和 effect 会被重复调用,用来暴露副作用问题,很多人会误判为 observer 重复更新。判断时要区分“React 开发模式故意重复执行”和“MobX 依赖真的变化”。如果 action 里混入请求、埋点或一次性初始化逻辑,最好把这些副作用放到明确的生命周期或事件里,不要依赖 render 触发。还有一个实用经验:不要过早把 store 解构成一堆局部变量再传来传去。const { count } = store 在某些位置只是拿到了当前值,后续组件读取的就不是 observable getter。保留 store.count 的读取路径,或者在 observer 子组件内再读取,通常更符合 MobX 的追踪模型。如果使用 Context 注入 store,也不要在 Provider 的 render 里反复 new Store()。每次创建新实例都会让依赖关系重建,旧组件里的 reaction 也可能来不及按预期清理。通常可以用 useState(() => new Store()) 或模块级工厂保证实例生命周期稳定。这个细节不显眼,但在多标签页、弹窗复用和测试隔离时很容易变成偶发 bug。追问observer 应该包在父组件还是子组件上?更推荐把 observer 放在真正读取 observable 的叶子组件上,而不是一股脑包住最外层页面。这样依赖会更小,某个字段变化时只刷新用到它的那块 UI。取舍是组件数量会多一些,但性能边界更清晰,排查“为什么这里更新了”也更容易。踩坑点是父组件先把 observable 解构成普通值再传下去,子组件即使包了 observer,也可能失去追踪效果。为什么有时数据变了,observer 组件却不更新?最常见原因是渲染期间没有直接读取 observable,例如在组件外提前把值存成普通变量。MobX 只能追踪 reaction 执行时发生的读取,追踪不到已经脱离 observable 的快照。另一个边界是对象本身没有被 makeAutoObservable、observable 或对应 annotation 处理,普通对象当然不会触发更新。实际项目里还要检查状态修改是否发生在 action 中,开启 enforceActions 后,违规修改会直接暴露问题。observer 和 React.memo 需要一起用吗?多数情况下不需要,observer 本身已经对 observable 依赖做了细粒度判断,也会处理一部分 props 变化带来的重复渲染。React.memo 更适合纯展示组件,用普通 props 驱动且没有读取 MobX 状态。两者强行叠加不一定出错,但容易让团队误以为“性能优化越多越好”。真正需要权衡的是组件边界:把读取状态的组件拆小,通常比到处加 memo 更稳定。在 render 里创建新对象会影响 observer 吗?会影响,但影响点通常不是 MobX 追踪,而是 React 子组件的 props 比较。比如每次 render 都创建 { color: 'red' } 或新的回调函数,传给普通子组件时可能导致子组件跟着刷新。这个坑在 MobX 页面里更隐蔽,因为你会以为是 observable 更新太频繁,其实是 React 引用变化。固定样式对象、用 computed 产出派生数据,或者把子组件也拆成 observer,都是可选方案。异步请求里修改状态,observer 会怎样更新?异步本身不会破坏 observer,关键是每次修改 observable 时是否在 action 边界内。async 函数里 await 之后已经离开原来的同步 action,因此后续赋值最好放进 runInAction 或拆成单独 action。这样做的取舍是代码多几行,但状态变化会更可追踪,也能避免严格模式报错。多个字段一起更新时,action 还能合并通知,减少组件中间态闪烁。async load() { this.loading = true; const data = await api.getList(); runInAction(() => { this.items = data; this.loading = false; });}observer 用得好,核心不是“所有组件都包一下”,而是让组件在正确的位置读取正确的 observable。状态读取越靠近展示位置,MobX 的自动追踪越准确,页面也越不容易出现莫名其妙的刷新。
服务端阅读 05月31日 15:55

MobX 异步操作为什么要用 runInAction 或 flow?

MobX 处理异步的核心问题只有一个:await 之后,代码已经离开了原来那个 action 的同步执行栈。如果项目开启了 enforceActions,这时直接改 observable 可能报错,调试也会变乱。所以 MobX 异步写法通常有两条路:普通 async/await 搭配 runInAction,或者使用 flow 让 generator 的每一步自动包进 action。async/await 的常见写法简单请求用 runInAction 最直观。请求前设置 loading;请求回来之后再改 data、error、loading,就放到 runInAction 里。这样状态变更集中,组件只看到清晰的开始、成功或失败、结束。class UserStore { user = null; loading = false; error = null; constructor() { makeAutoObservable(this, {}, { autoBind: true }); } async fetchUser(id) { this.loading = true; this.error = null; try { const user = await api.getUser(id); runInAction(() => { this.user = user; }); } catch (e) { runInAction(() => { this.error = e.message; }); } finally { runInAction(() => { this.loading = false; }); } }}flow 适合更长的流程flow 用 generator 写异步,yield 后面的状态修改仍由 MobX 管理。它适合串行步骤多、需要取消任务、错误分支复杂的场景。代价是语法不如 async/await 普及,TypeScript 类型也要多处理一点,团队不熟时会增加理解成本。fetchUser = flow(function* (id) { this.loading = true; try { this.user = yield api.getUser(id); } catch (e) { this.error = e.message; } finally { this.loading = false; }});并行请求和重试都要考虑“最后一次结果才有效”。否则慢请求可能覆盖快请求,页面显示旧数据。常用做法是记录 requestId,或使用 AbortController 取消旧请求。追问为什么 @action async 函数里 await 后还要 runInAction?@action 包住的是函数开始执行的同步部分,不会自动把所有未来的 Promise 回调都包住。await 后恢复执行时,MobX 已经不能保证这段修改仍在 action 中。runInAction 的取舍是多写一点样板代码,换来严格模式下可预测的状态修改。坑在于本地没开 enforceActions 时看不出问题,线上团队规范一收紧就开始报错。runInAction 和 flow 应该选哪个?短流程、团队熟悉 async/await 时,用 runInAction 更自然。流程很长、每一步都要改状态,或者希望使用 MobX 自带的取消能力时,flow 更顺手。边界不是哪个更高级,而是代码是否还能一眼看出状态变化发生在哪里。大型团队里最好统一风格,否则同一个项目两套写法会增加维护成本。loading 状态为什么容易写错?最常见的坑是并发请求共用一个 boolean。第一个请求还没结束,第二个请求开始;第二个先结束把 loading 设 false,页面就误以为全部结束了。可以用 requestId 保证只处理最后一次请求,或用 pendingCount 表示还有几个请求未完成。取舍是代码稍复杂,但能避免旧请求覆盖新结果和 loading 闪烁。const id = ++this.requestId;const data = await api.search(keyword);runInAction(() => { if (id === this.requestId) this.results = data;});异步错误应该放在 store 里还是组件里?业务错误通常放 store 里,因为多个组件可能都要根据它展示状态或禁用按钮。只影响某个弹窗的一次性错误,可以留在组件本地,没必要污染全局 store。边界是错误是否属于业务状态:比如“用户未登录”是业务状态,“当前弹窗输入为空”更像 UI 状态。踩坑点是把所有 error 都塞进全局数组,最后用户看到的提示和真实操作对不上。组件卸载时异步任务要不要取消?如果请求结果只会写入全局 store,组件卸载不一定必须取消,但要防止过期结果覆盖新状态。若结果只服务当前组件,卸载时取消更稳,尤其是搜索框、详情页切换和轮询。flow 返回的任务可以 cancel,fetch 可以用 AbortController。边界在于:取消不是为了让 MobX 安全,而是为了避免无意义请求和过期数据写回。