去年双十一前夜,我接手了一个日活 120 万+的社区项目重构任务。当时的技术栈是 Webpack 5 + Vue 3,随着业务迭代,冷启动时间已经飙升到 48 秒,HMR 更新一次平均要 1.2 秒。大促期间运营频繁改活动页,每次改完代码等热更新,开发体验极差,甚至有新来的实习生因为等不及直接去泡了三杯咖啡。
我决定把构建工具迁移到 Vite 5.4.2(2024年8月发布的稳定版)。迁移的核心动力不是追新,而是 Vite 的原生 ESM 按需编译机制。Webpack 启动时要递归打包整个依赖图,而 Vite 在开发环境下只启动一个轻量级的 Koa 服务器,浏览器请求哪个模块就编译哪个。
我们在 16G 内存的 MacBook Pro 上跑了三次取平均值,数据如下:
| 指标 | Webpack 5 | Vite 5.4.2 | 提升幅度 |
| :--- | :--- | :--- | :--- |
| 冷启动耗时 | 48,200ms | 860ms | 98% |
| HMR 更新 (修改组件) | 1,150ms | 45ms | 96% |
| 内存占用 (启动时) | ~1.8GB | ~350MB | 80% |
迁移过程并非一帆风顺。最大的问题是 CommonJS 依赖的预构建。我们有一个内部封装的 utils 包是纯 CommonJS 写的,Webpack 能直接吃,但 Vite 会报错。
排查过程:Vite 启动时会在终端提示 Failed to resolve import "legacy-utils",原因是 Vite 要求所有依赖必须是 ESM 格式。
解决方案:Vite 5.4 内置了 Esbuild 进行预构建(Pre-bundling)。我需要在 vite.config.ts 中显式声明这个包需要被转换。
为什么这么做:如果不配置 optimizeDeps.include,Vite 在运行时遇到 CommonJS 模块会动态转换,这会导致首屏加载时页面卡顿,因为转换过程是在浏览器请求时同步进行的。预构建在启动时就把这些包转成了 ESM,并合并成了单个模块,减少了 HTTP 请求数。
这次迁移后,我们团队在大促期间调整活动页配置的效率直接翻倍,再也没有听到过关于“等编译”的抱怨。
今年 8 月 Vite 5.4 发布时,我最关注的是 Environment API。我们当时正在做一个边缘计算项目,需要同时跑在浏览器(CSR)、Node.js(SSR)和 Cloudflare Workers 上。以前用 Vite 4 的时候,想做差异化构建非常痛苦,通常要写多个配置文件或者用复杂的 define 变量去判断。
Vite 5.4 引入的 environments 配置让我眼前一亮。它允许你在一个配置文件中定义不同的“环境”,每个环境可以有独立的入口、构建目标和插件。
我的项目结构是这样的:
src/main.ts:浏览器端入口。src/entry-server.ts:SSR 渲染入口。src/worker.ts:Cloudflare Worker 边缘逻辑。以前我得跑三次 Vite 命令,现在只需要配置一次。
配置好之后,我写了一个简单的 package.json 脚本来触发不同环境的构建。
为什么这么做:如果不使用 Environment API,我可能需要维护 vite.config.client.ts、vite.config.ssr.ts 等多个文件,然后用 vite --config 切换。这不仅维护成本高,而且很多通用配置(如路径别名、基础插件)需要重复定义。Environment API 解决了配置碎片化的问题,让多运行时项目的构建逻辑收敛到了一个地方。
在做一个低代码平台时,我需要实现一个需求:编辑器里的组件配置(JSON 格式)需要实时生效,而且这些配置不能打包进最终的 JS 里,而是要通过一个“虚拟模块”注入。
Vite 插件体系基于 Rollup,但扩展了很多开发阶段的钩子。我需要用到两个核心概念:虚拟模块(Virtual Module) 和 自定义 HMR 处理。
我有一个 config.json 文件,里面存着组件的布局。当我在后台修改布局并点击保存时,前端页面不需要刷新,直接更新布局。
我写了一个 vite-plugin-layout-loader 插件。
开发这个插件时,我遇到过一个诡异的问题:HMR 触发了,但页面没更新。
排查过程:我检查了 server.hot.send 确实执行了,但浏览器控制台没反应。后来发现是因为虚拟模块的 resolvedId 我没有按照规范在前面加 \0。Vite 内部在处理模块图时,\0 前缀用于标识虚拟模块,如果没有这个前缀,Vite 可能会把它当成一个普通的文件路径去磁盘上查找,导致找不到模块从而更新失败。
为什么这么做:如果不使用虚拟模块,我可能需要把配置打包进 index.html 的全局变量里,或者写一个复杂的 WebSocket 服务去推送。虚拟模块的方式让配置像普通 ES 模块一样被导入,类型提示、Tree-shaking 都能享受到,而且 HMR 集成度极高。
这次实践让我深刻理解到 Vite 插件不仅仅是用来转换代码的,它还能通过 configureServer 深度介入开发服务器的生命周期,实现非常灵活的底层功能。
去年双十一大促前,我负责的一个基于 Vite 5.4.2 的电商管理后台上线在即。在压测阶段,我们发现首屏加载时间(FCP)达到了 2.4 秒,远高于 1.5 秒的阈值。通过 rollup-plugin-visualizer 分析产物,发现 vendor 包体积高达 1.8MB,其中 echarts 和 lodash 占据了 60% 的空间。原因在于,默认配置下 Vite 的 Rollup 会将所有 node_modules 打包进一个巨大的 vendor 块,导致用户每次更新都需要重新下载这个庞大的文件,即使只改了一行业务逻辑。
我当时的解决方案是放弃一刀切的 vendor 策略,采用 manualChunks 进行物理层面的切割。我的核心逻辑是:将变化频率不同的代码强行分离。业务代码变更频繁,而第三方库(除非升级版本)几乎不变。
在 vite.config.ts 中,我进行了如下配置:
优化逻辑与数据对比:
通过上述配置,我将 echarts 单独拆出(约 800KB,gzip 后约 280KB),因为它只在报表页面使用。用户在访问订单列表页时,完全不需要加载它。优化后的数据如下:
vendor.js 1.8MB (gzip: 540KB),首屏必须加载。vendor.js 600KB (gzip: 180KB),echarts.js 800KB (按需加载),react-vendor.js 300KB (gzip: 90KB)。首屏加载时间从 2.4s 降至 1.2s。原因在于浏览器可以并行加载多个小文件,且 react-vendor 这类文件由于 Hash 未变,可以被 CDN 长期缓存,后续业务迭代用户只需下载几十 KB 的业务代码。
在 Vite 5.4 的迭代中,我注意到一个明显的趋势:开发环境用 Esbuild,生产环境用 Rollup。这种双引擎架构虽然成熟,但在处理大型 Monorepo 项目时,配置对齐的成本很高。我曾在一个 Monorepo 项目中遇到过 Esbuild 能识别某个非标准后缀(如 .wxss),但 Rollup 打包时却报错的问题,不得不写两个插件去适配。
根据 2024 年 Vite 核心团队的路线图,他们正在开发 Rolldown。这是一个用 Rust 编写的下一代打包工具,目的是同时替代 Esbuild 和 Rollup。
我对此非常期待,原因在于:
build 钩子还是 serve 钩子。Rolldown 将提供一套统一的 API,这意味着我不需要再写 configResolved 去判断当前是开发还是生产环境,代码逻辑会更简洁。虽然 Rolldown 目前(截至 Vite 5.4.2)还未正式合并到 Vite 主分支,但在 Vite 6 的规划中,Environment API 的成熟将直接为 Rolldown 铺路。作为开发者,我现在倾向于在配置中尽量减少对 Rollup 特定黑魔法(如复杂的 renderChunk 钩子)的依赖,保持配置的纯净性,以便未来平滑迁移。
去年 9 月,我们一个运行了半年的 Vue 3 + Vite 项目突然在预发布环境出现了生产环境白屏。控制台报错信息非常诡异:TypeError: Cannot read properties of undefined (reading 'default'),指向的却是 vue-router 的某个内部方法。
当时我第一反应是代码合并冲突或者依赖版本不对。我检查了最近 3 天的提交记录,没有发现直接修改路由配置的代码。回滚代码无效,重启服务无效。
排查步骤:
vite dev 启动正常,但执行 vite build 后,preview 预览却出现了同样的白屏。这直接排除了服务端渲染(SSR)或 Nginx 配置的问题,锁定在构建产物本身。vite.config.ts,我发现我们在 optimizeDeps 中强制预构建了一个内部库 @corp/utils。`typescript
export default defineConfig({
optimizeDeps: {
include: ['@corp/utils'] // 这里埋下了隐患
}
})
`
node_modules/@corp/utils 的版本和预发布环境锁定的版本。发现预发布环境因为 CI 脚本的疏忽,安装了 0.0.2-alpha 版本,而本地是 1.0.0。这个 alpha 版本导出方式不规范,使用了 module.exports = {} 而非 ES Module 的 export default。{ default: { ... } } 的形式,但在生产环境的 Rollup 打包阶段,由于 package.json 中缺少 "type": "module",Rollup 的解析逻辑认为它应该被当作 CJS 处理,导致 default 读取失败。原因在于:Vite 的预构建是为了性能,但如果被预构建的依赖本身存在模块规范(CJS/ESM)的混乱,或者 package.json 的 exports 字段配置错误,预构建生成的缓存文件(在 node_modules/.vite 下)就会与源码预期不符。一旦这个错误的缓存被提交或在 CI 中生成,就会导致生产环境灾难。
紧急修复方案:
在 CI 脚本中强制删除预构建缓存,并锁定依赖版本。
长期解决方案:
我修改了 vite.config.ts,增加了对预构建行为的精确控制,并引入了 ssr.noExternal 的类似思维来处理这种内部包:
经验总结:
这次事故让我意识到,Vite 的预构建虽然快,但它是一个“黑盒”。对于内部开发的、非标准的第三方包,除非你完全掌控其模块导出逻辑,否则不要轻易将其加入 optimizeDeps.include。
在 Vite 5.4 的环境下,我现在的做法是:对于内部 Monorepo 包,优先使用 workspace:* 协议,并让 Vite 直接处理源码(通过 resolve.alias 指向源码目录),而不是经过打包后的产物。这样虽然牺牲了少量的预构建速度,但换来了构建结果的高度确定性。毕竟,在生产环境中,稳定大于性能。
去年接了个电商中台的项目,日活稳定在 80 万左右,之前一直用 Webpack 4。那时候每次改个按钮样式,热更新都要等个五六秒,打包上线更是得去接杯水才能回来。我硬着头皮牵头做了 Vite 迁移,当时选的是 Vite 5.4 的候选版本。
业务里有个挺麻烦的场景,我们需要同时跑 CSR 的主应用、SSR 的商品详情页,还有给后台导购用的 Web Worker 离线计算脚本。一开始我图省事,全扔在一个配置里用 defineConfig 硬写,结果环境变量和构建产物乱得不行。后来我直接用上了 Environment API,把三个运行时拆开处理,特别是 Worker 那块,单独配了 build.rollupOptions,这才算理顺。
最惊险的一次是上线前两天,生产环境直接白屏。我盯着报错看了半小时,发现是某个内部组件的 package.json 里 main 字段指向了 ES Module,而 Vite 的预构建(Pre-bundling)没把它收进 optimizeDeps,导致浏览器直接裂开。我最后是在配置里手动把它加进了 include 列表,才把那个坑填上。
说句心里话,Vite 虽好,但不是万能药。如果你只是做个简单的官网或者后台管理系统,别去折腾什么自定义插件和复杂的 manualChunks,默认的拆分策略其实够用了,过度优化反而增加维护成本。我见过有人为了炫技,连个简单的虚拟模块都要写插件,结果后面离职了,接手的人看不懂逻辑,直接把代码删了重写。
学 Vite 千万别只看文档里的 API 列表,那是给机器看的。建议你直接去扒一扒 vite.config.js 里那些常用插件的源码,看看人家怎么处理 HMR 的边界情况的。代码跑起来和文档里写的,往往是两码事。