同样是启动开发服务器,Webpack 项目越大越慢,Vite 却永远是秒开。差异不是「优化得好」,而是思路根本不同:Webpack 在 dev 时也要先打包,Vite 干脆不打包。这篇把 Vite 快的三个原因讲透:原生 ESM、依赖预构建、HMR,以及为什么生产构建反而换回 Rollup——dev 和 build 是两套引擎。
一、总览:Vite 的三层加速
Vite 快的秘密可以拆成三层,每层解决一个问题:
| 层 | 手段 | 解决什么 |
|---|---|---|
| 第一层 | dev 不打包,浏览器原生 ESM 加载 | 启动时不用等全量打包 |
| 第二层 | 依赖预构建(esbuild,Go 写的) | 源文件不用管,依赖先转 ESM 并合并 |
| 第三层 | HMR:按模块热更新 | 改一行代码只重载那一个模块 |
二、第一层:dev 不打包,浏览器自己拉模块
Webpack 的 dev 模式也要先打包:分析全部模块依赖、编译、合并成 bundle,浏览器拿到 bundle 才能跑。项目 1000 个模块就要处理 1000 个,启动自然慢。
Vite 的做法:dev 时源码原样通过 <script type="module"> 发给浏览器,浏览器原生支持 ES Module,自己按 import 语句一条条拉取。Vite 只做一个轻量转换(把 .vue、.ts 编译成浏览器能认的 JS),不做打包。
对比之下:
Webpack dev:启动 → 打包全部 1000 个模块 → 浏览器加载 bundle
Vite dev:启动(秒)→ 浏览器按需加载第一个模块 → 用到哪个再拉哪个所以 Vite 的启动时间跟项目规模基本无关——浏览器还没开始拉模块呢,服务器已经就绪了。用到的具体机制见 Vite 快速入门 的「dev 时根本不用打包」。
三、第二层:依赖预构建,esbuild 的活
源文件不打包,但第三方依赖要预构建——Vite 启动时先把 node_modules 里的依赖处理一遍,产物缓存在 node_modules/.vite/deps/。为什么要预构建?两个原因:
原因 1:CommonJS → ESM 转换。很多老包(lodash、react-dom 的老版本)用的是 CommonJS(module.exports),浏览器不认识,必须转成 ESM 才能直接 import。
原因 2:合并请求数。假设你 import { cloneDeep } from 'lodash-es',lodash-es 内部有 600 多个模块文件,浏览器如果逐个拉,一次页面加载就要发 600 个请求。预构建把这 600 个合并成一个文件,浏览器只发一个请求。
实跑证据(Vite 8):dev 起一个
import { cloneDeep } from 'lodash-es'的项目,浏览器收到的源码里 import 已被重写成/node_modules/.vite/deps/lodash-es.js——预构建合并产物;同时 build 时终端打印645 modules transformed——Rollup 逐个处理这些模块做 tree-shaking,只用到两个函数,最终产物仅 15 kB。
预构建工具用 esbuild——用 Go 写的,比 JS 写的打包器快 10~100 倍(官方说法 10-100x)。这也是 Vite 启动快的第二层来源:依赖虽然要处理,但处理得极快。
四、第三层:HMR,改哪刷哪
HMR(Hot Module Replacement,模块热替换):修改一个模块,只重新加载这一个模块,页面状态(输入框内容、滚动位置、已打开弹窗)全部保留。
Vite 维护一张模块依赖图。你改了 table.js,Vite 通过 WebSocket 通知浏览器「table.js 变了」,浏览器只重新请求 table.js、替换它,其他模块原封不动:
改 table.js → 通知浏览器 → 只重新拉取 table.js → 页面其余部分不动对比 Webpack:改动后它要重新编译包含该模块的整个 chunk,chunk 越大刷新越慢。Vite 是按模块粒度的,所以项目越大,两者体验差距越明显。
模块自己也决定「热不热」:组件文件默认支持 HMR(Vue/React 插件接入);普通 JS 模块如果没声明 import.meta.hot.accept(),改了它会触发整页刷新兜底——所以写库代码时经常看到保存后页面闪一下,那就是走了兜底。
五、为什么 build 反而用 Rollup?
这是 Vite 最容易被问到的设计:dev 用 esbuild(快),build 却换回 Rollup(JS 写的,慢一些),为什么不一路 esbuild 到底?
官方理由很实在:
| 能力 | esbuild | Rollup |
|---|---|---|
| 转译/压缩速度 | 极快(Go) | 较慢(JS) |
| tree-shaking | 有,较激进 | 成熟、精准 |
| 代码分割(code splitting) | 支持有限 | 成熟(动态 import 拆 chunk) |
| 插件生态 | 较新 | 十几年积累 |
| CSS 处理 | 简单 | 完整(CSS 提取、代码分割) |
构建产物的目标是「稳」而不是「快」:tree-shaking 摇错一个变量就是线上 bug,代码分割做不好就是首屏加载一堆用不到的代码。Rollup 在这两件事上积累了十几年,Vite 生产构建选择「成熟优先」;esbuild 也没闲着,被用作默认的压缩器(minify),快的部分让它干。
一句话记忆:dev 拼速度(esbuild + 原生 ESM),build 拼质量(Rollup),各自干擅长的。
总结
| 问题 | 答案 |
|---|---|
| 为什么 dev 秒启动 | 不打包,浏览器原生 ESM 按需拉模块 |
| 为什么依赖要预构建 | CJS 转 ESM + 600 个模块合并成 1 个,减少请求 |
| 预构建用什么 | esbuild,Go 写的,比 JS 快 10-100 倍 |
| 为什么 HMR 快 | 按模块热更新,改哪刷哪,状态不丢 |
| 为什么 build 用 Rollup | tree-shaking / 代码分割成熟,构建求稳不求快 |
链式导航:Vite 怎么用(三条命令、别名、代理、环境变量),看 Vite 快速入门;Vite 编译的 .vue 文件本质是模板编译,配合 Vue 响应式原理详解 理解组件更新;构建产物 hash 缓存策略可以对照 Vue 快速入门 的静态资源部分。
