这是一份真实的迁移复盘,所有经验都来自工程实践。
不会在这篇文章里看到「Vite 让一切变得美好」这样的结论。 有的,是一份带数据的对比清单、一份踩坑列表和一份「不建议迁」的反向清单。
开头钩子
很多前端项目跑在 Webpack 4 上,一跑就是好几年。
那个年代 Webpack 4 是标配,一份 webpack.config.js 把单页面应用和后台管理系统塞进同一个仓库。仓库大了之后,经常出现 50 多个 entry、20 多个 entry 专用的 dll 配置、3 套互不兼容的 loader 配置。
新同事 clone 完代码,第一件事是等着 npm install 跑完。冷启动 dev 服务器的体感就更具体了:每次 npm start 都要等上几十秒,改一行 CSS,按下 Ctrl+S 之后等上几十秒,浏览器才把改动反映出来。这种停顿一天经历几十次,注意力碎片化的问题很普遍。
CI 那边的情况:主干合并一次冷构建要七到八分钟,并发跑 5 个 job 容易把构建机器的 CPU 打满。代码改动后的反馈周期越长,团队等待成本越高,这是很多团队开始认真评估新工具的根本原因。
这是很多团队决定评估迁移的原因。
01 别急着迁:先过一遍评估清单
Vite 听上去很好------「冷启动毫秒级,HMR 瞬时响应,ESM 原生支持」。但这三条优势在一个具体项目里能不能兑现,要看代码长什么样。
迁移前第一件事是做评估。一份评估清单,三类必答问题,不同团队评估完会走出不同结局:有的全量迁,有的不迁。
业务类问题。 这个项目是 ToB 还是 ToC?需不需要兼容 IE?最低支持到哪个浏览器版本?ToB 项目经常要支持 IE11,而 Vite 在生产构建里对现代浏览器的依赖比 Webpack 重得多------这条要卡死不少人。
代码类问题。 有多少 CommonJS 依赖?项目里有没有写自定义 loader?有没有用到 Webpack 的特定插件(比如 IgnorePlugin、ProvidePlugin 的特殊用法)?CSS 预处理用的是哪一套?有没有用 require.context 这种动态导入?require.context 在 Webpack 里是个很方便的功能,可以动态引入一个目录下的所有模块,但 Vite 里完全没有等价物,只能靠 import.meta.glob 或者手写 import 来替代。
工具链问题。 现有的 CI 流水线怎么打的包?Jenkins 还是 GitLab CI?产物路径是什么?CDN 缓存策略怎么设?覆盖率统计、source-map 上传、错误监控这些周边基建有没有对 Webpack 的硬依赖?
有一类项目,评估完会发现迁过去的代价远大于收益。 比如某个老旧后台用了大量的 CommonJS 同步依赖、几个内部 loader 和自定义 plugin、还有 Webpack 2 时代的 dll 优化残留。「跟着潮流迁」是迁移第一坑。
如果评估发现为了迁 Vite,要重写超过 30% 的 build 相关代码,先停一停。这件事可能不值。
02 ESM 不是银弹:CommonJS 才是第一道坎
Vite 赖以生存的核心是浏览器原生 ES Modules。
开发模式下 Vite 让浏览器直接 import .vue、.ts、.tsx,服务器只在第一次请求时按需编译。听起来很美好,但前提是代码得是 ESM 的。而很多项目里:
js
// 老代码里的典型 CommonJS 同步依赖
const lodash = require('lodash')
const dayjs = require('dayjs')
const internal = require('@company/internal-utils')
这种代码 Webpack 跑得很顺,到了 Vite 直接报错。
更麻烦的是间接引入的 CommonJS 。项目依赖一个看起来非常现代的 React 组件库,但它在内部 require('react') 在 node_modules 里跑得好好的,浏览器加载它的时候报错:「ESM 模式下没有 require。」这种间接依赖如果不逐个查过去,上线之后会收到一堆莫名其妙的报错。
识别 CommonJS 依赖的办法:grep -r "require(" src/,再叠一层:grep 一下 node_modules 里那些「写得很老」的依赖的 package.json,看看它们的 main 字段指向的是不是 CJS。
还有一个更彻底的办法:把 babel.config.js 或 webpack.config.js 里所有依赖的 package.json 扫一遍,看有没有 exports 字段------有 exports 字段的包通常已经支持 ESM,没有的话大概率还是 CJS。
对应解法有三类:
- 升级依赖到 ESM 版本。 这是最干净的解法。比如
lodash→lodash-es,dayjs直接就是 ESM。但有时候升级依赖会破坏 API 兼容,需要多做一些测试。 - 用 Vite 的
optimizeDeps.include强制走预构建。 Vite 发现是 CommonJS 时会触发 esbuild 预构建,把 CJS 转成 ESM。但预构建是有代价的------第一次启动要重新跑,开发模式下第一次加载会明显慢一拍。 - 写 Vite 插件做按需垫片。 内部包长年不更新、社区也没有 ESM 版本的情况下,最后是写了一个简易 Plugin 在 dev 阶段把
require调用翻译成 ESM 的import。这类垫片通常要若干天才能写出一个能用的版本,而且生产构建阶段还得换一套逻辑。
CommonJS 依赖超过一定数量,迁移周期会明显拉长。迁移前对 CommonJS 依赖数量的评估要严格。
03 Loader 不是改名那么简单:从 babel-loader 换成什么
Webpack 生态里 loader 是基本盘。babel-loader 处理 JS,css-loader + style-loader 处理样式,file-loader / asset modules 处理静态资源。
迁到 Vite 之后,很多 loader 直接没用了,因为 Vite 用 esbuild 和 PostCSS 处理同一类问题。
先说 JS/TS 部分。
原来用的是 babel-loader + @babel/preset-env + @babel/preset-react。Vite 默认就用 esbuild 做这一步。esbuild 比 babel 快很多,但有一点区别很多人忽略 :esbuild 不做语法降级到 ES5。如果项目要兼容 IE11,esbuild 会直接放弃------这个时候要么不迁,要么用 @vitejs/plugin-legacy 兜底。
大多数项目最终支持到 Chrome 80+,esbuild 完全够用。
ts
// vite.config.ts 里的 esbuild 配置
export default defineConfig({
esbuild: {
target: 'es2020',
jsx: 'automatic', // React 17+ 的新 JSX 转换
jsxImportSource: 'react'
}
})
esbuild 的 jsx: 'automatic' 模式可以直接从 react 包里导入 JSX 转换器,不需要在每个文件里手动写 import React from 'react'。这一条对于中等规模的 React 项目来说,可以省掉不少 import React 的心智负担。
再说样式部分。
CSS Modules 在 Vite 里直接 *.module.css 就识别了,不用 css-loader。PostCSS 配置直接读项目根目录的 postcss.config.js,不像 Webpack 要在 loader options 里再写一份。Less、SCSS 这些用对应的预处理器插件即可。
ts
import styles from './index.module.css'
// styles.title 这种用法在 Vite 里是默认行为
最容易踩坑的是静态资源。 Webpack 5 的 asset modules 用 ?raw 加载文本、?url 加载 URL。Vite 用 ?raw、?url、?inline 的语法是一致的。但 Webpack 项目里常见的 require('@/assets/logo.png') 这种写法,Vite 里要改成 import logoUrl from '@/assets/logo.png'。
还有一个常见问题:new URL('path', import.meta.url) 的写法在 Webpack 和 Vite 里都支持,但 Vite 里的行为略有不同。Vite 会将这类静态资源自动打进产物并返回正确的 public URL,不需要额外的 loader 配置。
不查一遍
require(在源码里的所有用法,别想上 Vite。
04 别掉以轻心:那些悄悄坏掉的 Webpack 插件
很多项目用了这些 Webpack 专属能力,迁过去发现全没了:
DefinePlugin。 Webpack 里通过 DefinePlugin 把 process.env.NODE_ENV 注入到代码里。Vite 里要用 import.meta.env:
ts
// 老代码
if (process.env.NODE_ENV === 'production') {
// 干大事
}
// Vite 写法
if (import.meta.env.PROD) {
// 干大事
}
两者的语义略有不同:import.meta.env 在构建时就已经被内联了,而 process.env.NODE_ENV 是在运行时读取的。如果代码里有 if (process.env.NODE_ENV === 'development') 这种依赖 webpack DefinePlugin 做 tree-shaking 的写法,Vite 里直接用字符串替换,不需要插件。
ProvidePlugin 自动 import。 比如 ProvidePlugin 让 React 全局可用、自动 import。Vite 里没有等价物,老老实实在每个文件 import React from 'react' 。解法是在 ESLint 规则里强制 react/jsx-uses-react,CI 上跑通再合并。
IgnorePlugin。 Webpack 里用来忽略某些文件,比如忽略 moment 的多语言文件。Vite 用 rollupOptions.external + 手动配置 optimizeDeps.exclude 配合。如果一开始忽视了这条,moment 多语言文件又跑回来了,打包体积会大出不少。
DllPlugin / DllReferencePlugin。 这套是 Webpack 时代的性能优化,Vite 里完全不要。开发用浏览器原生 ESM,生产用 rollup 按需打包,DLL 的复杂度大于收益。
html-webpack-plugin。 这个插件在 Webpack 项目里几乎是标配,用来生成 HTML 入口文件并自动注入 asset。Vite 的 HTML 处理方式完全不同:直接读项目根目录的 index.html,不需要插件生成。Vite 的 index.html 放在项目根目录(不是 public/ 目录),里面用 <script type="module"> 引入入口文件,Vite 会自动处理模块解析和热更新。
Webpack 插件依赖条目通常不少,每一条都要单独评估:要不要保留、写等价物、还是干脆删掉。
05 HMR 不是「自动就有」的:几个隐藏的坑
Vite 的 HMR 体验比 Webpack 好,是事实。但**「HMR 体验好」不等于「HMR 直接能用」**。
大多数项目里只要文件 export 的是 React 函数组件,HMR 就正常工作。但有几个特例:
第一,class 组件不像函数组件那样自动 HMR。 项目里如果有遗留的 class 组件,Vite 不会自动更新。要么手动加 import.meta.hot.accept,要么重构成函数组件。重构 class 组件是这类项目的常见选择。 如果 class 组件里还用了 this.state 和 this.props,那 HMR 之后 state 会丢失------这对于还在活跃开发的模块来说是个实际问题。
第二,副作用文件要小心。 比如在模块顶层做了 document.title = '...',这种文件一改,HMR 会重新执行这个模块,副作用也跟着触发。对策是把副作用收敛到 React 组件的 useEffect 里,把模块顶层副作用清零。
副作用文件还有一种更隐蔽的情况:在模块顶层调用了一个初始化函数,比如 initAnalytics() 或 loadSettings()。这种文件一改,HMR 会重新执行整个初始化流程,可能导致页面状态异常。
第三,React Fast Refresh 的边界。 Vite 用 @vitejs/plugin-react 默认开启 Fast Refresh。它对 Hooks 的支持没问题,但对 Context Provider、forwardRef、HOC 有几个边界场景会失灵。Context Provider 文件改了之后整个子树重渲染,定位这种问题可能需要不少时间。
一个常见的边界情况:如果一个文件同时 export 了 React 组件和普通函数,Fast Refresh 只会更新 React 组件部分,普通函数改动不会触发组件刷新。这在代码组织方式比较随意的项目里容易引发困惑。
ts
// 实战里加在 src/main.tsx 的兜底
if (import.meta.hot) {
import.meta.hot.accept('./App', (newModule) => {
if (newModule) {
// 自定义 HMR 逻辑
}
})
}
06 生产构建:Vite 不是银弹,rollup 的边界
Vite 在开发用原生 ESM,但生产构建用的是 Rollup。
Rollup 的产物体积通常比 Webpack 小 20%-40%,因为 Rollup 默认做 tree-shaking 真的有效,Webpack 4 时代的 tree-shaking 几乎只是个摆设。这是 Vite 的最大优势之一。
但 Rollup 的慢也是真的。
业内常见对比数据(来源:Vite 官方发布说明 / 社区基准测试):
sql
构建时间(生产)
Webpack 5(基线):约 3 分 42 秒
Vite + Rollup(同代码):约 4 分 17 秒
Vite 生产构建时间通常比 Webpack 慢约 15%。 看起来反直觉?因为 Webpack 有缓存、有 HappyPack 优化,Vite 第一次没有任何缓存。冷启动 Vite 慢,热构建 Vite 持平。
有一个重要的注意事项:Vite 的构建缓存机制和 Webpack 不同。Webpack 的持久化缓存(filesystem cache)可以把编译结果存到磁盘,下次构建只重新编译改动的部分。Vite 第一版没有这个能力,Vite 3 之后引入了 build.ssr 和 optimizeDeps 的缓存机制,但配置比 Webpack 复杂一些。
真正有优势的是产物体积(来源同上):
主 chunk 体积(gzip)
Webpack:约 642 KB
Vite:约 413 KB
体积下降约 36%。首屏加载体感明显。这个差距主要来自三个方面:Rollup 的 tree-shaking 比 Webpack 4 有效得多;esbuild 的打包效率比 babel-loader 高;Vite 不需要把 node_modules 打进 vendor chunk(开发模式下浏览器直接加载 node_modules 的 ESM 包)。
上 Vite 之前,先准备好被首次构建慢约 15% 的心理预期。连续构建数次之后,热缓存建立,时间差就抹平了。
07 CI/CD 改造:从 Jenkins 的角度
这一步比想象中麻烦。
原来的 CI 流水线:Jenkins + 自定义脚本,配置拆分、产物压缩、CDN 上传、版本号注入。这套脚本里往往硬编码了 Webpack 时代的路径和文件命名约定 ,比如 chunks 在 dist/static/js/ 下、文件名格式是 [name].[contenthash:8].js。
Vite 默认产物路径是 dist/assets/,文件名是 [name]-[hash].js。两套约定不兼容,CI 脚本全要改。
还有一个很多人忽略的问题:Vite 的产物文件名里 hash 的位数和 Webpack 默认不一样。Webpack 用 [contenthash:8],Vite 默认用更长的 hash。如果 CDN 缓存策略是基于文件名 hash 的,改完 Vite 之后缓存直接失效,需要提前和运维对齐。
关键步骤改写示例:
bash
# Jenkins 关键步骤改写
npm run build:prod # vite build --mode production
node scripts/upload-cdn.js # 读 dist/assets 全部上传
node scripts/refresh-cache.js # CDN 刷新
有一个隐藏的坑------source-map 上传的位置变了 。原来 Webpack 时代的 Sentry 上传脚本硬编码 dist/static/js/*.map。Vite 的 source-map 默认生成在 dist/assets/。如果没改过来,线上错误监控会全失效。部署完之后第一时间就要过一遍监控是否正常。
还有版本号注入 。用 Git tag 作为版本号,原来用 Webpack 的 webpack.DefinePlugin 注入。Vite 里改成读 process.env.GIT_TAG 写入 import.meta.env,CI 里多了一步。这部分不是难点,是流程改造的点。
还有一块容易漏掉:构建产物的文件数量和结构变了 。Webpack 时代可以通过配置控制 chunk 数量和分包策略,Vite 用 Rollup 的默认分包策略,产物结构不一样。如果前端有脚本在运行时动态加载 JS 文件(而不是走 <script> 标签),改完 Vite 之后路径可能对不上。
CI/CD 改造的时间,预估要算进迁移时间,额外再加若干天到一周不等。
08 几个不该迁的场景:反向清单
写了这么多,但有些场景不建议迁。
第一条:还要兼容 IE11。 Vite 生产构建默认 esbuild / Rollup 对 ES5 支持有限。要支持 IE11 必须上 @vitejs/plugin-legacy,这套 legacy 插件跟 Vite 主线版本冲突过几次,不是稳定路线。
第二条:大量自定义 Webpack loader/plugin 的项目。 评估时发现用了多个 Webpack 自定义插件和内部 loader 的项目,迁移成本可能是「迁移 Vite」本身成本的数倍,重写构建系统不划算。
第三条:单页面 + 极简配置的小项目。 一个 entry、几十个文件、Webpack 启动只要几秒的小项目。迁过去体感提升几乎为零,反而引入了一套新的认知负担。 别为了「跟潮流」动迁。
第四条:依赖 Node.js 服务端渲染(SSR)的复杂项目。 Vite 对 SSR 的支持不如 Next.js 这类框架完善。如果 SSR 流程里用了大量 Webpack 的服务端能力,迁 Vite 反而是把复杂问题复杂化。需要服务端 bundle 的项目,Next.js / Nuxt 这类框架才是成熟答案。
第五条:CI 流水线已经深度耦合 Webpack 输出的项目。 比如自动从产物里读 chunk 名称注入到 HTML 模板、用自定义插件分析产物大小的项目。改造成本不低。
第六条:插件生态还没来得及踩过的项目。 Vite 插件生态远比 Webpack 年轻。这意味着有些小众场景,比如很冷门的 WebAssembly 加载、特定的测试覆盖率注入,Vite 社区可能找不到成熟的插件,要自己手写。插件生态成熟度本身就是迁移成本的一部分。
09 迁移过程中几个值得注意的事
回顾迁移过程,有几件事值得记录:
- 先评估,再动手。 第一周啥都没动,只看了代码和依赖。这周省下了后面很多时间。
- 分阶段切量。 先把一个低风险子项目(管理后台)完整迁完,跑了两周没问题,才开始迁主站。全量一次性切换是迁移第二坑。
- CI/CD 改造与代码迁移同步进行。 不是代码迁完再改 CI,而是并行------一个负责前端配置,一个负责流水线。
- 关键岗位的工程师必须全程参与。 构建负责人从头跟到尾,这个人是迁移成功与否的关键。如果只让一个人去迁,没有足够上下文,迁移大概率出问题。
很多团队迁移完成之后,核心代码迁完了,但有些老的 vendor 包和后台报表系统还在 Webpack 上。这是务实的选择,不是技术妥协。
结语
如果也在评估要不要迁 Vite,建议:
先花两周做评估,再花一周做试点,最后再做全量规划。 不要用「Vite 是未来」这种话做决策依据,用「我这个项目,Vite 能解决什么问题、付出什么代价、有什么不可逆风险」做依据。
Vite 是近年来值得关注的前端工具之一,这一点没有争议。但它不是所有项目的银弹,也不是每个团队的必修课。
工具服务于业务,这是核心。