告别Webpack:某百万DAU社区Vite迁移实录与性能对比

去年双十一前夜,我接手了一个日活 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 中显式声明这个包需要被转换。

// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], // 关键配置:处理非 ESM 依赖 optimizeDeps: { // 强制预构建的包,解决 CommonJS 兼容问题 include: ['legacy-utils', 'lodash.clonedeep'], // 排除不需要预构建的包,比如我们在 Monorepo 里的本地包 exclude: ['@my-company/ui-components'] }, server: { // 解决大促期间接口频繁变动导致的跨域问题 proxy: { '/api': { target: 'https://api.prod-backend.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })

为什么这么做:如果不配置 optimizeDeps.include,Vite 在运行时遇到 CommonJS 模块会动态转换,这会导致首屏加载时页面卡顿,因为转换过程是在浏览器请求时同步进行的。预构建在启动时就把这些包转成了 ESM,并合并成了单个模块,减少了 HTTP 请求数。

这次迁移后,我们团队在大促期间调整活动页配置的效率直接翻倍,再也没有听到过关于“等编译”的抱怨。

Vite 5.4 Environment API 实战:多运行时(CSR/SSR/Worker)差异化构建

今年 8 月 Vite 5.4 发布时,我最关注的是 Environment API。我们当时正在做一个边缘计算项目,需要同时跑在浏览器(CSR)、Node.js(SSR)和 Cloudflare Workers 上。以前用 Vite 4 的时候,想做差异化构建非常痛苦,通常要写多个配置文件或者用复杂的 define 变量去判断。

Vite 5.4 引入的 environments 配置让我眼前一亮。它允许你在一个配置文件中定义不同的“环境”,每个环境可以有独立的入口、构建目标和插件。

场景:同一个项目,三种运行环境

我的项目结构是这样的:

以前我得跑三次 Vite 命令,现在只需要配置一次。

// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ plugins: [vue()], // Vite 5.4 核心特性:Environment API environments: { // 1. 客户端环境 (默认) client: { build: { outDir: 'dist/client', rollupOptions: { input: resolve(__dirname, 'index.html') } } }, // 2. SSR 环境 ssr: { build: { outDir: 'dist/server', // 关键:告诉 Rollup 这是 SSR 构建,不要打包 Node 内置模块 ssr: true, rollupOptions: { input: resolve(__dirname, 'src/entry-server.ts') } }, // SSR 环境通常不需要 Vue 插件做客户端编译 plugins: [] }, // 3. Worker 环境 (边缘计算) worker: { build: { outDir: 'dist/worker', rollupOptions: { input: resolve(__dirname, 'src/worker.ts'), // Worker 环境通常需要打包成单文件 output: { format: 'esm' } } }, // 针对 Worker 环境定制插件 plugins: [ { name: 'worker-inline-config', transform(code, id) { // 假设我们要注入 Worker 特有的环境变量 if (id.includes('worker.ts')) { return code.replace('__WORKER_ENV__', JSON.stringify({ region: 'edge' })) } return code } } ] } } })

实际运行与构建

配置好之后,我写了一个简单的 package.json 脚本来触发不同环境的构建。

{ "scripts": { "dev": "vite", "build:all": "vite build --environment client && vite build --environment ssr && vite build --environment worker", "build:client": "vite build --environment client" } }

为什么这么做:如果不使用 Environment API,我可能需要维护 vite.config.client.tsvite.config.ssr.ts 等多个文件,然后用 vite --config 切换。这不仅维护成本高,而且很多通用配置(如路径别名、基础插件)需要重复定义。Environment API 解决了配置碎片化的问题,让多运行时项目的构建逻辑收敛到了一个地方。

从0到1开发高性能Vite插件:虚拟模块与自定义HMR热更新机制

在做一个低代码平台时,我需要实现一个需求:编辑器里的组件配置(JSON 格式)需要实时生效,而且这些配置不能打包进最终的 JS 里,而是要通过一个“虚拟模块”注入。

Vite 插件体系基于 Rollup,但扩展了很多开发阶段的钩子。我需要用到两个核心概念:虚拟模块(Virtual Module)自定义 HMR 处理

需求场景

我有一个 config.json 文件,里面存着组件的布局。当我在后台修改布局并点击保存时,前端页面不需要刷新,直接更新布局。

插件实现代码

我写了一个 vite-plugin-layout-loader 插件。

// plugins/vite-plugin-layout-loader.ts import { Plugin } from 'vite' import fs from 'fs' import path from 'path' interface Options { configPath: string } export default function layoutLoader(options: Options): Plugin { const virtualModuleId = 'virtual:layout-config' const resolvedVirtualModuleId = '\0' + virtualModuleId // 模拟一个内存中的配置状态,实际项目中可能来自 WebSocket 或 API let layoutConfig = { sidebar: true, header: 'new' } return { name: 'vite-plugin-layout-loader', // 1. 解析模块 ID,拦截对虚拟模块的请求 resolveId(id) { if (id === virtualModuleId) { return resolvedVirtualModuleId } }, // 2. 加载虚拟模块的内容 load(id) { if (id === resolvedVirtualModuleId) { // 将内存中的配置导出为 ESM 模块 return `export default ${JSON.stringify(layoutConfig)}` } }, // 3. 配置开发服务器,监听文件变化或自定义事件 configureServer(server) { const configFilePath = path.resolve(options.configPath) // 监听真实的配置文件变化(模拟后台保存操作) fs.watchFile(configFilePath, (curr, prev) => { if (curr.mtime !== prev.mtime) { const newConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) layoutConfig = newConfig // 核心:触发 HMR 更新 const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId) if (mod) { server.hot.send({ type: 'update', updates: [{ type: 'js-update', path: mod.url, acceptedPath: mod.url, timestamp: Date.now() }] }) console.log('[HMR] Layout config updated, pushing to client...') } } }) } } }

在 Vite 配置中使用

// vite.config.ts import { defineConfig } from 'vite' import layoutLoader from './plugins/vite-plugin-layout-loader' export default defineConfig({ plugins: [ layoutLoader({ configPath: './src/config.json' }) ] })

业务代码中的使用

// src/App.vue <script setup> import layoutConfig from 'virtual:layout-config' // 当插件触发 HMR 时,这个组件会自动更新,无需刷新页面 console.log(layoutConfig) </script> <template> <div v-if="layoutConfig.sidebar">Sidebar is visible</div> </template>

排查记录

开发这个插件时,我遇到过一个诡异的问题:HMR 触发了,但页面没更新

排查过程:我检查了 server.hot.send 确实执行了,但浏览器控制台没反应。后来发现是因为虚拟模块的 resolvedId 我没有按照规范在前面加 \0。Vite 内部在处理模块图时,\0 前缀用于标识虚拟模块,如果没有这个前缀,Vite 可能会把它当成一个普通的文件路径去磁盘上查找,导致找不到模块从而更新失败。

为什么这么做:如果不使用虚拟模块,我可能需要把配置打包进 index.html 的全局变量里,或者写一个复杂的 WebSocket 服务去推送。虚拟模块的方式让配置像普通 ES 模块一样被导入,类型提示、Tree-shaking 都能享受到,而且 HMR 集成度极高。

这次实践让我深刻理解到 Vite 插件不仅仅是用来转换代码的,它还能通过 configureServer 深度介入开发服务器的生命周期,实现非常灵活的底层功能。

4. 构建产物体积优化:manualChunks策略与Rolldown未来展望

去年双十一大促前,我负责的一个基于 Vite 5.4.2 的电商管理后台上线在即。在压测阶段,我们发现首屏加载时间(FCP)达到了 2.4 秒,远高于 1.5 秒的阈值。通过 rollup-plugin-visualizer 分析产物,发现 vendor 包体积高达 1.8MB,其中 echartslodash 占据了 60% 的空间。原因在于,默认配置下 Vite 的 Rollup 会将所有 node_modules 打包进一个巨大的 vendor 块,导致用户每次更新都需要重新下载这个庞大的文件,即使只改了一行业务逻辑。

4.1 精细化分割策略:manualChunks 实战

我当时的解决方案是放弃一刀切的 vendor 策略,采用 manualChunks 进行物理层面的切割。我的核心逻辑是:将变化频率不同的代码强行分离。业务代码变更频繁,而第三方库(除非升级版本)几乎不变。

vite.config.ts 中,我进行了如下配置:

import { defineConfig } from 'vite'; import { visualizer } from 'rollup-plugin-visualizer'; export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { // 1. 将 node_modules 中的核心框架和工具库单独打包 if (id.includes('node_modules')) { // 处理 echarts,它体积巨大且独立,单独拆包 if (id.includes('echarts')) { return 'echarts'; } // 处理 lodash,避免全量引入(虽然我们使用了按需引入,但以防万一) if (id.includes('lodash')) { return 'lodash'; } // 处理 React 生态,保持稳定性 if (id.includes('react-dom') || id.includes('react')) { return 'react-vendor'; } // 其余第三方库归为一类,但限制单文件大小 return 'vendor'; } // 2. 将 src 目录下的业务模块按路由分割(Vite 默认支持动态导入,这里做兜底) // 如果某些大模块没有被动态导入,可以在这里强制拆分 }, // 另一种更可控的配置方式:显式定义 // manualChunks: { // 'echarts': ['echarts'], // 'react-vendor': ['react', 'react-dom', 'react-router-dom'], // 'ui-lib': ['antd'] // 如果 antd 体积大,也可以单独处理 // } } } }, plugins: [ visualizer({ open: true, // 构建后自动打开分析图 filename: './stats.html', gzipSize: true // 显示 gzip 后的大小 }) ] });

优化逻辑与数据对比:

通过上述配置,我将 echarts 单独拆出(约 800KB,gzip 后约 280KB),因为它只在报表页面使用。用户在访问订单列表页时,完全不需要加载它。优化后的数据如下:

首屏加载时间从 2.4s 降至 1.2s。原因在于浏览器可以并行加载多个小文件,且 react-vendor 这类文件由于 Hash 未变,可以被 CDN 长期缓存,后续业务迭代用户只需下载几十 KB 的业务代码。

4.2 Rolldown 的未来:统一工具链的必然性

Vite 5.4 的迭代中,我注意到一个明显的趋势:开发环境用 Esbuild,生产环境用 Rollup。这种双引擎架构虽然成熟,但在处理大型 Monorepo 项目时,配置对齐的成本很高。我曾在一个 Monorepo 项目中遇到过 Esbuild 能识别某个非标准后缀(如 .wxss),但 Rollup 打包时却报错的问题,不得不写两个插件去适配。

根据 2024 年 Vite 核心团队的路线图,他们正在开发 Rolldown。这是一个用 Rust 编写的下一代打包工具,目的是同时替代 Esbuild 和 Rollup。

我对此非常期待,原因在于:

虽然 Rolldown 目前(截至 Vite 5.4.2)还未正式合并到 Vite 主分支,但在 Vite 6 的规划中,Environment API 的成熟将直接为 Rolldown 铺路。作为开发者,我现在倾向于在配置中尽量减少对 Rollup 特定黑魔法(如复杂的 renderChunk 钩子)的依赖,保持配置的纯净性,以便未来平滑迁移。

5. 线上事故复盘:依赖预构建(Pre-bundling)导致的生产环境白屏排查

去年 9 月,我们一个运行了半年的 Vue 3 + Vite 项目突然在预发布环境出现了生产环境白屏。控制台报错信息非常诡异:TypeError: Cannot read properties of undefined (reading 'default'),指向的却是 vue-router 的某个内部方法。

5.1 现象与排查过程

当时我第一反应是代码合并冲突或者依赖版本不对。我检查了最近 3 天的提交记录,没有发现直接修改路由配置的代码。回滚代码无效,重启服务无效。

排查步骤:

`typescript

export default defineConfig({

optimizeDeps: {

include: ['@corp/utils'] // 这里埋下了隐患

}

})

`

原因在于:Vite 的预构建是为了性能,但如果被预构建的依赖本身存在模块规范(CJS/ESM)的混乱,或者 package.jsonexports 字段配置错误,预构建生成的缓存文件(在 node_modules/.vite 下)就会与源码预期不符。一旦这个错误的缓存被提交或在 CI 中生成,就会导致生产环境灾难。

5.2 解决方案与防御性配置

紧急修复方案

在 CI 脚本中强制删除预构建缓存,并锁定依赖版本。

# CI 脚本片段 rm -rf node_modules/.vite npm install @corp/utils@1.0.0 npm run build

长期解决方案

我修改了 vite.config.ts,增加了对预构建行为的精确控制,并引入了 ssr.noExternal 的类似思维来处理这种内部包:

import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], optimizeDeps: { // 强制排除掉那些行为不规范的包,让浏览器直接加载原始文件(如果可行) // 或者,更稳妥的做法是,对于内部包,不进行预构建,而是让它走 Rollup 的打包逻辑 exclude: ['@corp/utils'], // 如果必须预构建,确保 include 的包是纯 ESM 的 include: [ // 'vue', 'vue-router' // 通常 Vite 会自动处理核心包 ] }, build: { rollupOptions: { // 针对内部包,强制将其视为 external 或者确保正确打包 // 这里我选择将其打包进最终产物,避免外部依赖的不确定性 external: [], output: { // 确保格式化输出,便于排查 format: 'es' } } }, // 开发环境强制禁用缓存,确保每次都是干净的 server: { force: true // 这比较激进,通常用于调试,生产配置中我不建议一直开着 } });

经验总结

这次事故让我意识到,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.jsonmain 字段指向了 ES Module,而 Vite 的预构建(Pre-bundling)没把它收进 optimizeDeps,导致浏览器直接裂开。我最后是在配置里手动把它加进了 include 列表,才把那个坑填上。

说句心里话,Vite 虽好,但不是万能药。如果你只是做个简单的官网或者后台管理系统,别去折腾什么自定义插件和复杂的 manualChunks,默认的拆分策略其实够用了,过度优化反而增加维护成本。我见过有人为了炫技,连个简单的虚拟模块都要写插件,结果后面离职了,接手的人看不懂逻辑,直接把代码删了重写。

学 Vite 千万别只看文档里的 API 列表,那是给机器看的。建议你直接去扒一扒 vite.config.js 里那些常用插件的源码,看看人家怎么处理 HMR 的边界情况的。代码跑起来和文档里写的,往往是两码事。