1. 为什么 Webpack 开发时那么慢:bundler 编译模型的瓶颈
我之前接手过一个中后台项目,React + TypeScript,页面模块大概 300 多个,node_modules里的依赖也不少。刚开始用 Webpack 4 开发,一次npm run dev冷启动要 28 秒左右,中途改了某个公共组件,热更新等 3 到 5 秒是常事。后来项目膨胀到 600 多个模块,冷启动直接逼近 40 秒,热更新有时候要 8 秒甚至直接白屏等刷新。这不是个案,几乎所有 Webpack 项目到后期都会遇到同样的问题。
问题出在 Webpack 的编译模型上。Webpack 是一个典型的 bundler,它必须从入口文件开始,递归地解析所有import/require,构建出一整张模块依赖图(module graph),然后逐个模块交给 loader 转换代码,再把这些模块打包成一个或几个 bundle 文件。也就是说,哪怕你只想改一个按钮的颜色,Webpack 也得先把整个项目的模块都读一遍、转换一遍、打包一遍,浏览器才能拿到更新后的代码。项目越大,这张依赖图越大,耗时自然线性上升。
开发模式下 Webpack 还会给每个模块包一层运行时注入代码,用来实现module.hot.accept这类热更新能力。这个包装逻辑本身也有开销,而且模块数量越多,bundle 里附带的管理代码就越多,解析和执行效率就越差。有人可能会说,那 Webpack 也有持久化缓存、多线程构建啊,为什么还是慢?因为这些优化本质上是在"缓解"全量打包这个行为,而不是"消除"它。cache-loader能把 loader 转换结果存到磁盘,但依赖图还是要重新遍历;thread-loader能把压缩或 babel 转换拆到子进程跑,但任务本身还是得做。DLLPlugin 是一个更极端的方案,把不常变的第三方依赖预编译成单独的文件,但它带来的心智负担非常高,配置复杂、容易踩版本坑,而且 Webpack 5 官方已经在文档里明确不推荐继续使用了。
所以你会发现一个很尴尬的事实:Webpack 的优化手段越来越多,项目的构建复杂度也越来越高,但开发体验的提升却非常有限。这不是配置技巧的问题,而是"打包"这个模型在开发场景下天然就低效。真正的突破口,是换一种完全不同的运行机制——Vite 就是基于这个思路出现的。
2. Vite 快在哪里:ESM 原生与依赖预构建的正确理解
Vite 的核心思路其实不复杂:开发阶段,不打包。
浏览器已经原生支持 ES Module,通过<script type="module">可以直接加载远程 JS 模块,不需要像 Webpack 那样把代码全部转换为非模块化的 bundle。Vite 利用这一点,在开发服务器收到页面的 HTML 请求后,只做两件事:把入口模块的源码转译一下返回给浏览器,然后在浏览器真正import某个模块时才去按需转译那个模块。它没有"构建依赖图"这一步,也没有"生成 bundle"这一步,冷启动自然就是秒级。
Vite 开发模式下的两种核心机制值得好好讲清楚,它们决定了为什么它能比 Webpack 快出数量级。
2.1 依赖预构建:把 CJS 模块变成浏览器能认的 ESM
node_modules里有大量第三方库是基于 CommonJS 或 UMD 格式写的,浏览器根本不认识require。如果 Vite 不做任何处理,浏览器在加载这些依赖时直接就会报错。所以 Vite 在首次启动时会对node_modules做一次"依赖预构建":用 esbuild 把这些依赖统一转换成 ESM 格式,并把多次import同一个包的内部引用做重写合并,同时把结果缓存到node_modules/.vite目录。
这个预构建过程非常快,因为 esbuild 是用 Go 写的,打包 JS 代码的速度比传统 JS 写的打包器快几十倍甚至上百倍,官方说法是在 Terser 等工具的对比测试中能拉开好几十倍差距。实际体感也很明显:第一次启动 Vite,预构建一个包含 100 多个第三方依赖的项目,通常一两秒内就结束了。而 Webpack 首次构建光跑 babel 转译就得十几秒起步。
还有一点很多人没注意到:esbuild 预构建完成后,Vite 会把依赖的缓存关系记在optimizeDeps的哈希里。如果你没有改package.json,后续启动直接用缓存,连预构建这步都省了,启动速度还能再快一截。
2.2 源码按需转译:只有被请求的模块才被处理
预构建处理完依赖后,Vite 的 dev server 会返回一个入口 HTML,里面只有一个<script type="module" src="/src/main.ts">。浏览器加载这个入口后,会顺着源码里的import语句一个个发请求给 Vite dev server。Vite 收到请求后,对当前这个文件单独做转译——比如把.ts转成.js、把.vue拆成 JS 和 CSS、把 SCSS 编译成普通 CSS——然后立刻返回。
这个模型的关键在于:转译是惰性的。编辑某个模块,Vite 只需要重新转译那一个文件,然后通过 HMR 连接把更新的模块推给浏览器。浏览器端只需要重新请求那一个模块,不需要重新加载整个应用。所以修改样式或者改一个工具函数,浏览器里的反馈几乎就是即时的,不像 Webpack 那样要等整个依赖图重新走一遍。我之前在项目里实测过,Vite 的热更新响应时间基本稳定在 50ms 左右,Webpack 则是 1.5 秒起步,快的时候也得 800ms。
2.3 对比实测:同样项目切换前后的体感差异
我拿那个 600 模块的 React 项目做了一次完整迁移,切到 Vite 之后用hyperfine做了冷启动对比,数据比较直观:
| 指标 | Webpack 4(优化后) | Vite 4 |
|---|---|---|
| 冷启动 dev server | 约 38 秒 | 约 1.2 秒 |
| 首次页面加载完成 | 约 42 秒 | 约 2 秒 |
| 修改一个组件的热更新 | 2~8 秒 | 约 80ms |
| 全量构建 | 约 120 秒 | 约 60 秒 |
开发体验完全是两个时代的产物。生产构建虽然也快了一倍,但真正的质变还是开发阶段的这几十倍差距,这也是很多人切换后最大的体感来源。不过话说回来,开发模式快是一回事,生产构建要稳定、产物要小是另一回事,这正是下一章要展开的内容。
3. 迁移实操:webpack.config.js 到 vite.config.ts 的配置对照
很多人一听"迁移"就头大,其实 Vite 的设计很讨巧,它很懂 Webpack 用户的习惯,大量配置项的命名和结构都沿用了社区熟悉的概念。把项目从 Webpack 切到 Vite,多数情况下不需要重写业务代码,主要工作量集中在配置文件和环境差异的处理上。
下面是几个最常见的配置项对照,基本上照着改就能跑通。
3.1 入口、出口与基础路径
Webpack 里入口和出口是最核心的配置,Vite 的入口是隐式的——默认读取项目根目录下的index.html,然后从里面的<script type="module" src="/src/main.ts">找到入口 JS。如果你不需要多页面,基本不用显式写入口配置。出口就更不用管了,Vite 会把最终产物输出到dist目录。
基础路径这个要特别注意。Webpack 对应的是output.publicPath,Vite 对应的是base配置项:
// webpack.config.js module.exports = { output: { publicPath: process.env.NODE_ENV === 'production' ? '/admin/' : '/' } };// vite.config.ts import { defineConfig } from 'vite'; export default defineConfig({ base: process.env.NODE_ENV === 'production' ? '/admin/' : '/' });如果你把应用部署在 CDN 或子路径下,这个配置错了页面会直接白屏,因为 JS 和 CSS 的引用地址全都会错。这是迁移时最容易出问题的地方之一,我见过不止一个项目栽在这里。
3.2 别名(alias)
resolve.alias几乎是每个项目都有的配置,Vite 的写法和 Webpack 几乎一模一样,只是要注意用 Node 的path来解析路径,否则相对路径会算错:
// webpack.config.js const path = require('path'); module.exports = { resolve: { alias: { '@': path.resolve(__dirname, 'src') } } };// vite.config.ts import path from 'path'; import { defineConfig } from 'vite'; export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src') } } });有一点和 Webpack 不同的是,Vite 对alias的匹配默认是"前缀匹配",'@'会匹配到'@/components'也可以匹配到'@vue'这种包名。如果你用的自定义别名和某个 npm 包名冲突了,可能会出现意料之外的解析结果。稳妥做法是给别名加上精确边界,Vite 内部推荐写成'@': path.resolve(__dirname, 'src'),并且配合find和replacement对象写法来控制匹配精度,具体可以查一下文档里的resolve.alias用法。
3.3 代理与 Mock
Webpack 的devServer.proxy在 Vite 里对应server.proxy,配置结构几乎完全一致:
// webpack.config.js devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }// vite.config.ts server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }Vite 底层用的是http-proxy,所以changeOrigin、rewrite这些选项都是兼容的。唯一需要留意的是,Webpack 的devServer.before这类自定义中间件能力,Vite 里需要用 Vite 插件的configureServer钩子来实现,写法更接近中间件模式,但思路是通的。
3.4 CSS 与 PostCSS 处理
Webpack 里处理 CSS 通常要配style-loader/css-loader/postcss-loader一长串 loader。Vite 内置了 CSS 处理能力,默认支持 CSS 文件、.scss、.less、.stylus,只需要安装对应的预编译器,比如sass,然后直接@import就能用。PostCSS 也内置了,项目根目录如果有postcss.config.js,Vite 会自动读取并使用。
这个设计大大降低了配置成本,但也带来一个很实在的问题:Vite 默认不开启 CSS 的url()路径重写方式。Webpack 中你会习惯用~前缀去引用node_modules里的样式文件,比如@import '~bootstrap/dist/css/bootstrap.min.css'。在 Vite 中不需要这个~前缀,直接写@import 'bootstrap/dist/css/bootstrap.min.css'就能解析到 node_modules,如果你保留老的写法,反而会报错找不到模块。
3.5 环境变量
Webpack 常用dotenv-webpack或自定义DefinePlugin来注入环境变量。Vite 内置了环境变量机制,规则是只有以VITE_开头的变量才会被注入到客户端代码中,从import.meta.env上读取:
// 直接在代码里用 const apiBase = import.meta.env.VITE_API_BASE;// webpack 时代也是可以用的 const apiBase = process.env.VITE_API_BASE;如果你有一批老代码里写的是process.env.VITE_API_BASE,可以统一在 Vite 的define里做一个全局替换:
export default defineConfig({ define: { 'process.env': { VITE_API_BASE: JSON.stringify(process.env.VITE_API_BASE) } } });注意要JSON.stringify包一层,否则替换进去的是一个裸字符串,代码里会变成const apiBase = http://xxx,直接语法报错。
3.6 plugins 机制差异
Webpack 的插件体系基于 tapable 钩子,Vite 的插件则是标准的 Rollup 插件接口外加 Vite 特有的一系列钩子(config、configureServer、transformIndexHtml等)。业务上最常见的需求是 Vue 项目加@vitejs/plugin-vue、React 项目加@vitejs/plugin-react,这些官方插件装好就行,不需要额外做 Babel 配置。
如果你原来在 Webpack 里写的是一个复杂自定义插件,迁移到 Vite 基本都要重写。所以迁移前评估一下自己项目里自定义插件的复杂程度,是判断迁移成本最直接的办法。通用能力类的插件(压缩、图片优化、CDN 外链)大多都有 Vite 移植版,但非常特定于你团队内部那套构建逻辑的插件,是最费时间的部分。
4. 生产构建的另一套逻辑:Rollup 打包与产物优化
Vite 开发模式快,靠的是"不打包";但生产环境是要真正产出静态文件的,总不可能也让用户浏览器一个个请求几千个模块。所以 Vite 在vite build时会把代码交给 Rollup 来做全量打包和压缩。很多人不理解为什么 Vite 不继续用 esbuild 做生产构建,一个很重要的原因是:esbuild 的代码分割(code splitting)能力还不够完善,而 Rollup 的 tree-shaking 和 chunk 分割机制非常成熟,产物体积控制得更好。Vite 官方也表态会在未来逐步引入 Rolldown(基于 Rust 写的 Rollup 替代品),但现阶段 Rollup 仍然是最稳的选择。
生产构建阶段,最值得关注的是三个问题:分包策略、CDN 外链和浏览器兼容性。
4.1 手动分包:避免只改业务代码就拖着依赖重新打包
依赖和业务代码的分离在生产构建里非常重要。如果不做分包,第三方库会跟着整个应用打成一个巨大的 JS 文件,用户每次发布新版本都要重新下载几 MB 的依赖代码,缓存策略形同虚设。
Webpack 里用的是optimization.splitChunks,Vite 里对应的是build.rollupOptions.output.manualChunks。比如把 Vue 全家桶拆成一个 vendor 包:
export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) { return 'vue-vendor'; } if (id.includes('echarts') || id.includes('zrender')) { return 'echarts-vendor'; } return 'vendor'; } } } } } });这里有个常见的坑:manualChunks的函数形式里过滤条件写得过宽,很容易把存在循环依赖的包拆到不同 chunk 里,运行时报"Cannot access before initialization"之类的初始化顺序错误。稳妥做法是只对明确没有交叉依赖的库做强制分包,剩下的让 Rollup 自动处理。
4.2 通过 external 把依赖甩给 CDN
另一个常用优化是external加 CDN,生产环境不打包 jQuery、lodash 这类体积大且更新不频繁的库,而是直接在index.html里引 CDN 地址。Vite 配置如下:
export default defineConfig({ build: { rollupOptions: { external: ['lodash', 'jquery'], output: { globals: { lodash: '_', jquery: '$' } } } } });同时要在index.html里手动加上 CDN 的<script>标签。这里很容易漏一步:代码里import _ from 'lodash'在运行时拿的是全局变量_,如果 CDN 挂了或者加载顺序不对,页面就会白屏报错。所以用 CDN 外链一定要确认第三方服务的可用性,并且做好本地 fallback。
4.3 老浏览器兼容:plugin-legacy 还是自动降级
Vite 5 的默认构建目标基线是 ES2020,如果你要兼容 IE 11 或者低版本移动端浏览器,必须加@vitejs/plugin-legacy。这个插件做的事情是:生成一份现代代码给新浏览器用,同时生成一份降级转译版本给老浏览器用,再通过nomodule属性做差异加载。
我在迁移一个运营管理系统时踩过这个坑:这个系统还在用 IE 11,迁移到 Vite 后我没加 legacy 插件,结果 IE 里打开白屏,控制台报错是语法错误——代码里用了可选链?.,IE 根本不认识。加上 plugin-legacy 之后这个问题就解决了,但产物体积会增加,因为有两份代码。所以要不要加这个插件,取决于你的目标用户群体,别盲目加。
5. 迁移中高频踩坑与排查思路
这一节集中写写迁移过程中最容易卡住的地方,都是我实际踩过的,按出现频率排序。
5.1 动态 import 与字符串拼接路径
Webpack 里写import(./modules/${name}.js)是可以动态解析的,Webpack 会把你给的所有可能的路径都打包进去,运行的时候再根据变量选择。Vite 不行,它不能对运行时才知道的变量路径做静态分析,必须用import.meta.glob显式声明所有可能的匹配路径:
// 老写法:Vite 会直接报错 const module = await import(`./modules/${name}.js`); // Vite 写法 const modules = import.meta.glob('./modules/*.js'); const loader = modules[`./modules/${name}.js`]; // loader 是一个懒加载函数,调用后才真正 import const module = await loader();如果项目里动态导入的路径模式比较复杂,这一步需要花一点时间逐一改写。
5.2 CJS 老依赖的预构建失败
有些老旧依赖因为导出方式特殊,第一次启动 Vite 时预构建会报错,常见的错误特征是在浏览器控制台看到"does not provide an export"或者请求node_modules下某个包时返回 500。这种问题可以调整optimizeDeps配置来绕过:
export default defineConfig({ optimizeDeps: { include: ['lib-a', 'lib-b'], exclude: ['lib-c'] } });include强制把某些包纳入预构建,exclude则是把有问题的包排除,让 Vite 在源码引用时再单独处理。排除之后可能需要降级策略,比如改用其他包或者让这个依赖不接受 HMR。多数的预构建问题其实是包版本过老导致的,升级依赖往往更省心。
5.3 Node 内置模块的 polyfill 消失
Webpack 4 会自动给浏览器端的crypto、path、process等 Node 内置模块提供 polyfill,很多老代码因此可以在浏览器里直接用path.join这类 API。Vite 不做这件事,默认会直接报错,提示某个模块在浏览器环境不可用。这不是 Vite 的缺陷,而是浏览器本来就不该有这些 API。遇到这种代码,正确的处理方式是改成浏览器原生的 Web API,或者用rollup-plugin-node-polyfills这类插件按需注入。最坑的是那种依赖 Node 模块做复杂运算的库,比如某些加密算法库,在浏览器端跑通需要做不少适配工作。
5.4 图片与静态资源的引用路径
Vite 处理图片资源的逻辑是:小于build.assetsInlineLimit(默认 4096 字节)的图片会转成 base64 直接内联进 JS,大于这个阈值的会输出到dist/assets并按需生成带哈希的文件名。这个行为本身和 Webpack 的limit配置差不多,但有两个差异值得注意。
第一,代码里通过/src/assets/logo.png引用的资源在构建时会自动处理路径,但如果资源放在public目录,就直接拷贝到dist根目录,不会被内联。很多迁移项目的问题是:之前的 Webpack 项目里用publicPath + 文件名引用的资源,切到 Vite 后路径对不上,需要统一改成相对路径或import.meta.env.BASE_URL + 路径。
第二,动态拼出来的 URL,比如:src="'/img/' + name + '.png'",Vite 不会替你处理,因为它无法预知这些文件。这种情况下要么把图片放到public目录,要么用import.meta.glob把图片导入映射做好。
5.5 Vite 版本差异带来的配置陷阱
这几年 Vite 的版本更新很快,4.x 到 5.x 再到 6.x,最明显的变化有两处:一是配置项命名调整,比如部分选项从build迁移到optimizeDeps,二是对 Node 版本的要求越来越高。我在迁移时因为项目还在用 Node 14,Vite 5 直接拒绝启动,后来把本机 Node 升级到 18 才解决。建议动手前先确认开发环境和 CI 里的 Node 版本,再决定用哪个大版本的 Vite,不然一篇配置照着文档抄也可能跑不起来。
还有一个细节:vite preview命令预览的是生产构建产物,不是开发服务器,很多人在部署后发现资源路径不对,第一反应是去改 dev 配置,实际上要改的是base。
6. 结合热词补充:Webpack 打包优化配置的本质与新项目的选型建议
搜索热词里除了 Vite 之外,还有不少人在搜"webpack 打包优化配置",包括webpack 配置这类关键词,说明 Webpack 仍然是大量存量项目的底座。如果暂时没有条件切到 Vite,掌握下面这些 Webpack 优化思路也是必要的,至少能缓解开发期的痛苦。
6.1 存量 Webpack 项目的止血优化
Webpack 5 比 Webpack 4 原生就带持久化缓存(cache: { type: 'filesystem' }),启动和构建都会明显提速,这是零成本收益最大的一项。开启后,二次构建的耗时一般能下降 40% 到 60%。在此基础上,再考虑用thread-loader给耗时的 babel-loader 开启多线程,用cache-loader缓存 loader 的中间产物。注意thread-loader并不是所有场景都划算,文件很小的项目启动线程池的开销反而比省下的时间还大,实测下来一般 100 个模块以上的项目才有明显收益。
代码分割当然是必做的。单 bundle 的体积控制是原生体验的关键,优先把react/react-dom或vue这类框架级的依赖拆出来,配合 CDN 外链把首屏加载量降下来,这个对用户体验的改善比构建速度更直接。
还有一招容易忽略:在开发模式下关闭不必要的压缩插件,特别是terser-webpack-plugin。生产构建要压缩,开发环境完全不需要,很多团队的 Webpack 配置里 compression 插件是全局生效的,白白拖慢 dev server 的启动和热更新。
6.2 新项目怎么选型
新项目或者可以完全掌控技术栈的项目,我倾向于直接用 Vite。理由不只是快,而是 Vite 的配置心智负担比 Webpack 轻得多,内置能力已经覆盖了大部分常见需求,不需要像 Webpack 那样把 loader、plugin、optimization 全部搞清楚才能开工。团队里有新手入职,Vite 的项目他能很快上手改配置,Webpack 项目至少需要一周沉淀才能动构建配置,这个团队成本差异很容易被低估。
唯一建议谨慎考虑 Vite 的场景是:项目里有大量需要深度定制构建流程的模块分包策略,或者强依赖 Webpack 特有的 loader 生态(比如某些小众格式文件的解析器),这部分迁移成本会高一些。即便如此,也可以先只把开发环境切到 Vite(官方支持)加@vitejs/plugin-legacy,用 Vite dev server 提升开发体验,生产环境暂时保留 Webpack 构建,分阶段过渡,风险可控。
7. 个人体会与最后的实操建议
这次从 Webpack 迁移到 Vite,最大的收获其实不只是"速度变快了",而是整个团队的开发节奏都变了。之前改个页面组件,热更新要等好几秒,心态上是"改完去倒杯水再回来看效果";现在热更新几乎是敲完保存就生效,更愿意做小步快跑的迭代,这种开发体验的提升很难用数字完全表达。
给准备做迁移的读者几个具体建议:
第一,迁移前先跑通一个最小可行性验证。挑一个模块数较多的业务页面,把它单独拎出来切到 Vite 跑通,确认所有依赖预构建都正常,再做全量迁移。这样能把风险控制在最小范围。
第二,配置改完后一定要测三种模式:dev、build、preview。很多配置在 dev 下正常、build 就出问题,比如图片路径、环境变量、CDN 外链,preview 能帮你提前发现部署后的资源引用错误,别等上了服务器才发现白屏。
第三,公共库和业务代码的分包要提前设计。Vite 默认的产物已经比 Webpack 默认更合理,但如果你有路由级懒加载需求,务必在迁移时同步确认manualChunks的拆分是否符合预期,避免把整个路由页面的公共依赖全打进一个 chunk,导致首屏体积不降反升。
最后想说一句:Vite 和 Webpack 不是非此即彼的关系。Webpack 在复杂分包和生态深度上依然很能打,Vite 在开发体验上是降维打击。现阶段最优解往往是让 Vite 负责日常开发,同时理解 Webpack 的优化逻辑,因为存量代码总有一天需要你来维护。工具会迭代,但"理解构建链路本质"这件事,永远是前端工程化里最值钱的能力。