前言
最近半年,我在公司内部的两个中后台项目里先后把构建工具从 Webpack 迁到了 Vite,一个是从 Vue CLI 老项目迁移,一个是 React 新项目直接上手。迁移过程中冷启动从一分多钟降到两秒出头的体验,说实话有点”回不去”的感觉。这篇文章就把 Vite 为什么快这件事掰开揉碎讲一遍,再附上我自己的实战配置和踩坑记录,希望能帮到还在 Webpack 冷启动里干等着的同学。
1. Webpack 的痛点:一切皆模块的代价
1.1 冷启动为什么慢
Webpack 的核心哲学是”一切皆模块”,为了让你能随意使用各种模块语法和资源类型,它在启动时会做一件很重的事情:从入口文件出发,递归解析所有 import/require,把整个项目的模块依赖图(Module Graph)完整地构建出来,中间还要经过 loader 转译、plugin 处理,最后打包成一个或多个 bundle。
这个流程在开发环境下也一点不少。哪怕你只是要改一行首页文案,Webpack 也要先把几百上千个模块全部编译打包完,才能启动 dev server 响应你的第一次请求。项目越大,这个”开机时间”越长,我见过最夸张的老项目冷启动要一分半,摸鱼都摸完一轮了页面还没出来。
1.2 HMR 的瓶颈
热更新(HMR)慢的根源和冷启动一脉相承:某个模块改了,Webpack 需要重新做一次增量打包,以该模块为起点的受影响子图全部要重新编译、重新拼接进 bundle。如果这个模块被太多地方引用,或者触发了整个 chunk 的重打包,HMR 就会从”毫秒级”退化成”秒级”,甚至直接卡死等编译。
问题的本质在于:Webpack 必须先打包,才能服务。而浏览器明明早就支持了原生 ES Modules,为什么开发环境还要把几百个模块合并成几个大文件再喂给浏览器呢?Vite 就是冲着这个问题来的。
2. Vite 的核心思路:基于 ESM 的按需编译
2.1 dev 与 build 的差异
Vite 在开发环境和生产环境用的是两套完全不同的策略,理解了这一点就理解了 Vite 的一半:
- 开发环境:Vite 根本不打包。它启动一个 dev server,利用浏览器原生的 ES Module 加载能力,浏览器请求哪个模块,Vite 才按需编译哪个模块,然后以单个模块的粒度返回给浏览器。真正的”按需加载、用到才编译”。
- 生产环境:Vite 使用 Rollup 做打包,因为把几百个零散请求直接扔到线上会造成网络瀑布,打包后的代码分包、tree-shaking、压缩都更有利于线上加载。
用文字画个示意图,对比一下两种模式下浏览器拿到页面的过程:
1 | 【Webpack dev 模式】 |
也就是说,Webpack 的启动时间和项目体积成正比,而 Vite dev 模式的启动时间基本和项目体积无关——因为启动时它只处理依赖,不处理你的业务源码。
2.2 请求拦截与模块改写
Vite dev server 本质上是一个带”模块改写中间件”的静态服务器。浏览器发出的每一个模块请求,都会被 Vite 拦截并实时转译成浏览器能直接执行的 JavaScript:
.vue单文件组件请求会被拆解,模板部分当场编译成 render 函数;.ts文件被 esbuild 当场转译成 JS(只做转译,不做类型检查,类型检查交给编辑器和 tsc);import语句里的裸模块名(bare import,比如import { createApp } from 'vue')会被改写成预构建产物的路径,比如/node_modules/.vite/vue.js?v=xxx;- LESS/SCSS 请求当场编译成 CSS 并以 JS 模块形式注入。
编译发生在”请求时”而不是”启动时”,所以改一个文件后 HMR 只需要让浏览器重新请求这一个模块,替换掉旧模块的执行结果即可,耗时和项目多大几乎没有关系,稳定在毫秒级。
3. esbuild 预构建:Go 与 JS 的速度差

3.1 为什么需要预构建
既然是按需编译,为什么启动时还要做”依赖预构建”(Dep Pre-Bundling)?主要是两个原因:
- CommonJS 兼容。很多 npm 包(比如老的
lodash、react相关包)发布的是 CJS 格式,浏览器原生 ESM 不认识module.exports。预构建会把它们统一转成 ESM 格式。 - 减少请求瀑布。像
lodash-es这种包,内部有几百个小模块文件,如果浏览器按原生 ESM 去加载,会发起成百上千个请求形成请求瀑布。预构建会把一个包的所有模块合并成单个文件,浏览器一次请求搞定。
3.2 Go 与 JS 的速度差
预构建的执行者是 esbuild——一个用 Go 写的打包器。同样是”解析 JS → 转译 → 打包”这件事,Go 和 JavaScript 的差距是数量级的:
- Go 是编译型语言,编译产物是本地机器码,多核并行是天然能力,goroutine 一开就是满核运转;
- Node.js 下的 Webpack 这类工具,解析、AST 构建、闭包处理全在单线程 JS 里跑,还要受 GC 抖动影响;
- esbuild 从算法层面也做了极致优化:一切皆字节数组、AST 只分配一次内存、并行调度做得非常激进。
我的直观感受是:同一个项目的依赖预构建,Rollup 方案要十几秒,esbuild 一般一两秒就完成了,第一次跑 vite 命令时的等待感几乎可以忽略。而且预构建结果会缓存在 node_modules/.vite 里,之后只要锁文件不变就不再重新预构建,冷启动更快。
4. vite.config.ts 实战配置
下面是我们项目里真实在用的 vite.config.ts,覆盖了最常用的三块:路径别名、开发代理和 CSS 预处理器。
1 | import { defineConfig } from 'vite'; |
配置里有三处容易被问到的点:
- **alias 的
__dirname**:如果配置文件被当作 ESM 处理(package.json里有"type": "module"),__dirname不存在,改用fileURLToPath(new URL('./src', import.meta.url))。别直接取.pathname,Windows 下会得到/E:/code/...这种路径,alias 直接失效。 - proxy 的 rewrite:从 webpack 的
pathRewrite迁移过来时注意写法变化,webpack 里是pathRewrite: { '^/api': '' },Vite 里是个函数。 - scss additionalData:老的 webpack 项目如果是在每个文件里手动
@import变量文件的,迁移过来直接用additionalData注入,代码还能顺便瘦一圈。
5. 老项目迁移踩坑
从 Vue CLI(Webpack)迁移到 Vite,我总结下来大部分时间都花在两类问题上:CommonJS 依赖和环境变量。
5.1 CommonJS 依赖
浏览器 ESM 无法直接执行 CJS,而 Vite 的预构建只处理 node_modules 里的依赖。所以第一类坑是”依赖没问题,但写法有问题”:
1 | // webpack 里可以,Vite dev 模式下不行(运行时报错) |
业务源码里的 require 要全部改成 import。如果数量太多,可以写个 codemod 批量替换。真正麻烦的是少数只发布了 CJS 版本的包,典型症状是浏览器控制台报 Uncaught SyntaxError: Unexpected token 'export' 或者 require is not defined。解决办法:
1 | // vite.config.ts |
还有一个隐蔽的坑:optimizeDeps.include 要把动态 import 的包也写进去,否则第一次动态加载到它时会触发”新依赖发现”,页面会整页刷新一次,体验很突兀。
5.2 环境变量 import.meta.env
这是迁移时必改的一批代码。Webpack 体系下我们习惯了 process.env.XXX,Vite 走的是浏览器标准的 import.meta.env:
1 | // webpack 写法(Vite 下 process 未定义,直接白屏) |
配套的几个差异点要一起改:
- 前缀约定变了:webpack 的
VUE_APP_前缀换成 Vite 的VITE_,所有.env文件里的变量名都要改,否则读不到(Vite 出于安全考虑只暴露带前缀的变量给客户端)。 - 内置变量改名:
process.env.NODE_ENV换成import.meta.env.MODE或import.meta.env.PROD / DEV。 - 类型提示:在
src下建一个env.d.ts,给import.meta.env补上类型声明,团队里才不会写出拼错名字的环境变量。
1 | // src/env.d.ts |
5.3 其他小坑
- 装饰器/旧语法:Vue CLI 里靠 babel 转译的老语法,Vite 默认的 esbuild target 可能不支持,需要显式配置
build.target和 esbuild 插件; - 全局注入:webpack 的
ProvidePlugin(比如自动注入$)在 Vite 里要用esbuild的注入或直接改显式 import; - 多页应用:
pages配置换成build.rollupOptions.input的多入口写法。
6. 性能数据对比
贴一下我们那个 Vue CLI 老项目迁移前后在同一台机器上的实测数据(业务代码约 500+ 模块):
| 指标 | Webpack (Vue CLI 4) | Vite 2 |
|---|---|---|
| dev 冷启动(无缓存) | 约 78 秒 | 约 2.3 秒 |
| dev 冷启动(有缓存) | 约 60 秒 | 约 1.1 秒 |
| 首次页面可交互 | 约 8 秒 | 约 2 秒 |
| HMR 平均耗时 | 600ms~3s | 80~150ms |
| 生产构建 | 约 4 分 20 秒 | 约 1 分 40 秒 |
生产构建的提升主要来自两点:esbuild 做转译和压缩(替代 terser),以及 Rollup 本身打包效率高于 Webpack 4。当然要说明的是,生产构建的提升幅度因项目而异,dev 体验才是 Vite 的降维打击。
写在最后
Vite 快的秘诀说穿了并不神秘:把”先打包再服务”换成”服务时按需编译”,把”JS 写的构建工具”换成”Go 写的构建工具”,再把该合并的依赖提前合并好缓存起来。三件事叠加,就有了上面表格里那 30 倍的冷启动差距。
2022 年这个时间点,Vite 2 的生态已经相当成熟,Vue 3 项目可以放心全量上 Vite,React 项目也有完整的官方插件支持。如果你的项目还在被 Webpack 的冷启动折磨,建议先在新项目里尝鲜,再找时间把老项目迁过去——大概率花一两天,换回来团队今后几年的开发效率。下一篇我打算聊聊 Vite 插件机制和如何自己写一个插件,有兴趣的同学可以关注下。




