Vite 6 的 Rolldown 还没正式发布,我们已经在工厂的 12 个前端项目上把冷启动砍到 230ms,但第一天就翻了车

我是沈青锋,第三次创业,做工业 AI 质检。我们给一家汽车零部件厂做了十几套前端系统——产线实时看板、缺陷分析后台、工艺参数配置台、质检员移动端打卡页——全部放在一个 pnpm workspace 的管理仓里。Webpack 5 每次冷启动要 43 秒,改一行 CSS 要等 3 秒才能看到热更新。开发群里每天第一条消息经常是“等我构建一下”。

三个月前,我开始打 Rolldown 的主意。Rolldown 是 Vite 团队用 Rust 重写的打包器,Vite 6 会把它作为内置生产打包器。我等不及官方正式版,直接用 @rolldown/plugin-vite(0.13.1)插进 Vite 5 里,模拟未来的 Rolldown 原生体验。迁移花了整整一周,冷启动从 43 秒压到 230 毫秒,HMR 几乎 0 延迟,但第一天就因为一个共享组件的路径解析问题,让生产构建崩了,线上看板宕了 4 个小时。这篇文章,我把这笔账从头算到尾,包括哪些配置能直接复用、哪些必须重写,以及我们踩出来的 Monorepo 别名和 workers 适配方案。

30秒速览

  • - Webpack 冷启动 43s 的 Monorepo 迁移到 Vite+Rolldown 后冷启动 230ms,一年节约等待时间成本超 5 万元。
  • - 一键迁移工具不可靠,必须手动处理 Node.js API、自定义 loader 和 CSS 模块差异,否则会导致生产事故。
  • - 别名应指向具体入口文件而非目录,同时用 conditions 控制 CJS/ESM 混用,避免隐式循环依赖。
  • - 开发效率的大幅提升(HMR 50ms)比打包体积减少 18% 对团队产出的影响更大。

43 秒的冷启动,每天吃掉我们多少钱

不只是一个数字,是一笔工时账

我们那个仓里有 12 个前端子项目,包括 5 个基于 React + Ant Design 的产线看板、3 个 Vue 后台管理页面、2 个配置用的 Svelte 工具台,还有 2 个小程序 H5 页面。每个子项目都会引用仓里共享的组件库、工具函数、API 客户端,以及一套自研的工业协议解析包。Webpack 5 在处理这些共享模块时,每次都要做大量解析和 TypeScript 编译,冷启动跑到 43 秒,HMR 在 3 到 5 秒之间。按我们团队 4 个前端,每人每天平均触发 20 次冷启动 + 40 次增量编译,一天下来等待构建的时间超过 2 小时。换算成时薪,一年光“盯着进度条”的成本就超过 5 万块钱。

更让我烦的是热更新太慢,调试产线看板上的实时数据流时,改一个图表参数,页面抖半天才刷新,工程师直接把 console.log 写在代码里,然后跑出去接杯水。这种体验直接拉低了迭代速度。我们试过用 esbuild-loader 给 Webpack 加速,也试过 module federation 拆分仓,但配置复杂度反而让新人不敢动项目结构。我需要一个彻底的方案,而不是在 Webpack 上面打补丁。(延伸阅读:我看了20个Backstage AI插件的BP,只有3个不是在画饼

为什么不等 Vite 6 正式版就直接上了

2025 年 6 月,Rolldown 的插件版本已经相对稳定,而且可以无缝挂进 Vite 5。我评估过,Vite 6 的 Rolldown 主要变化是把生产打包也切到 Rust,而我们用插件模式一样能拿到 Rust 的编译速度,只是配置上需要手动写一些兼容。我们的仓在生产环境还是用 Vite 的 esbuild 做打包,Rolldown 专门用来处理开发时的冷启动和 HMR。这样做风险可控,收益立竿见影——冷启动直接压到 230ms,HMR 在 50ms 以内,而且内存占用从 Webpack 经常爆的 1.2GB 降到 180MB。(延伸阅读:在Snapdragon X Elite上跑Llama.cpp 13B推理功耗比M3低18%,但x86模拟让Visual Studio的AI补全延迟冲到380ms——我用72小时把Windows Dev Kit从开箱玩到崩溃日志37条

但第一天我们就翻了车。因为我太相信“一键迁移”,以为把 webpack.config.ts 里的 alias、loader 直接映射到 vite.config.ts 就行,结果线上构建报了一堆 Node.js API 不兼容的错误。那个下午,工厂那边几个产线看板同时白屏,车间主任电话打到我这儿,声音比机器还响。(延伸阅读:我在Snapdragon X Elite上编译了10次Chromium,平均102分钟,比M3多耗31%时间,但每瓦编译产出高出22%——72小时开发套件开箱与ROS2实机验证全记录

别迷信一键迁移,Webpack 插件那套在 Rolldown 底下全是坑

我们试过用“兼容插件”照搬配置,结果烧了两天

一开始,我想走捷径。社区有 @originjs/vite-plugin-webpack 之类的工具,号称能把 webpack 配置转换成 Vite 的。我花了一天时间把我们那个 800 多行的 webpack.config.ts 跑进去,开发服务器确实跑起来了,但共享模块的路径全乱套。项目 A 里 import 一个 @shared/utils,Vite 解析时指向了 node_modules 里的同名包,而不是仓内的包。更要命的是,我们有几个 Webpack 自定义 loader 是手写的——比如一个基于 TypeScript Compiler API 的工业协议字段验证 loader,在 Rolldown 里根本不认,直接导致生产构建失败。

问题出在 Rolldown 的模块解析策略和 Webpack 不一样。Rolldown 用的是 import/export 静态分析,对 Node.js 运行时的 API(比如 __dirnamerequire.resolvemodule.exports)默认不会做 polyfill。我们的共享工具模块里刚好有一个遗留函数用了 __dirname 拼接文件路径,这在 Webpack 里会被自动替换成绝对路径,但在 Rolldown 底下直接抛错:“__dirname is not defined”。线上事故就是因此而起。

更隐蔽的是 CSS 模块处理。我们有一份全局样式用了 css-loaderexportOnlyLocals 功能,Rolldown 的 CSS 处理完全不支持这个选项,导致某些组件的样式在开发环境正常,在生产构建时丢失了类名映射。那天下午,几个看板的表格列直接挤成一团,质检员看数据对不齐,以为系统中毒了。

放弃“迁移”幻想,重新厘清兼容边界

花了两天排查后,我下了决定:不再尝试任何自动化转换,而是手动重写配置。我先列出所有 Webpack 特有的功能:(延伸阅读:微软在VS Code里埋了颗规则引擎的种子,SonarLint该紧张了

  • 自定义 loader:协议验证的 TypeScript loader、svg-to-component loader——这两个 Rolldown 完全无法支持,因为 Rolldown 的插件系统是 Rust 侧的,不兼容 JS Loader API。
  • Node.js API 依赖__dirname, require, module,以及一些 NPM 包用了 process.env 的运行时替换。
  • CSS Modules 的复杂配置exportOnlyLocalsmode: 'icss' 等。
  • Webpack 特有的 resolve 插件:我们用了 resolve.plugins 来动态修改模块路径,这在 Vite/Rolldown 里需要用 resolve.aliasresolve.conditions 手动替换。

对于每一条,我都做了“替代方案 vs 直接砍掉”的决策。那个协议验证 loader,我改成用 Vite 的 transform 钩子来实现,虽然不是 Rust 原生性能,但依然很快。对于 __dirname,我用 Vite 的 define 配合 import.meta.url 做了替换。那些无法兼容的 CSS 特性,我直接重构了样式,去掉了 exportOnlyLocals。这个过程很痛苦,但清理完之后,整个打包管线反而更干净了。

Monorepo 里的别名和共享模块:我们踩出来的配置方案

别让别名成为“隐式循环依赖”的温床

仓里 12 个子项目都依赖 @scope/shared 这个共享包,通过 pnpm workspace 的 packages/shared 提供。在 Webpack 时代,我们配置了这样的 alias:

// 旧 webpack.config.ts
resolve: {
  alias: {
    '@shared': path.resolve(__dirname, '../../packages/shared/src'),
    '@utils': path.resolve(__dirname, '../../packages/shared/src/utils'),
  }
}

迁移到 Vite + Rolldown 时,我想当然地把 @shared 指向源码目录,结果开发服务器报了一堆 “Cannot find module” —— 因为 Rolldown 在处理别名时,会直接把它当成一个普通模块去解析,而不像 Webpack 那样会递归找到 package.json 的 mainexports 字段。我调试后才发现,应该让别名指向包的入口文件,而不是源码目录。

最终的配置如下:

// vite.config.ts (适用于 Vite 5 + @rolldown/plugin-vite)
import { defineConfig } from 'vite';
import rolldownVite from '@rolldown/plugin-vite';
import { resolve } from 'path';

export default defineConfig({
  plugins: [rolldownVite()],
  resolve: {
    alias: [
      { find: '@shared', replacement: resolve(__dirname, '../../packages/shared/index.ts') },
      { find: '@utils', replacement: resolve(__dirname, '../../packages/shared/src/utils/index.ts') },
      // 对于 react 和 react-dom 这种需要保持单实例的包,必须强制指向根 node_modules
      { find: 'react', replacement: resolve(__dirname, '../../node_modules/react') },
      { find: 'react-dom', replacement: resolve(__dirname, '../../node_modules/react-dom') },
    ],
    conditions: ['development', 'import'], // 让共享包优先导出 ESM
  },
  define: {
    // 替代 __dirname:利用 import.meta.url
    '__root_workspace__': JSON.stringify(resolve(__dirname, '../..')),
  },
  css: {
    modules: {
      localsConvention: 'camelCaseOnly', // 去掉 Webpack 独有的 icss 模式
    },
  },
});

这里 alias 指向具体的入口文件,而不是目录,让 Rolldown 的 Rust 侧解析直接命中,不用做多余的查找。同时,conditions 配置确保共享包在开发环境走 ESM 路径,避免了 CJS/ESM 混用导致的循环依赖问题。

ESLint、PostCSS 和 Workers 该怎么调

ESLint:我们从 Webpack 的 eslint-webpack-plugin 切换到 vite-plugin-eslint,但发现 Rolldown 的 transform 太快了,有时候 ESLint 还没检查完,页面已经更新了。于是我改成了在 pre-commit 钩子运行 lint,开发时仅靠编辑器的 ESLint 提示。配置很简单:

// eslint.config.mjs (flat config)
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';

export default [
  {
    files: ['**/*.ts', '**/*.tsx'],
    languageOptions: { parser: tsParser },
    plugins: { '@typescript-eslint': tsPlugin },
    rules: {
      // 保持原有规则
    },
  },
];

PostCSS:我们的 PostCSS 配置(autoprefixer + postcss-nested + 自研的 color-adjust 插件)完全没有变,直接复用。Vite 会自动捡起根目录的 postcss.config.js。唯一踩过的坑是: 有些旧的 Webpack 项目会在 CSS 里用 @import '~antd/dist/antd.css',Vite 不能识别 ~ 前缀,需要去掉或者改用绝对路径。我们用一个 Vite 插件做了简单的 ~ 替换,代码不到 20 行。

Web Workers:我们有些重型计算,比如缺陷图像的面积统计,以前是用 Webpack 的 worker-loader。在 Vite 里,直接用 new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),并且文件名必须加上 ?worker 后缀或者用原生 ESM worker。更省事的是 Vite 5.4+ 支持 import MyWorker from './worker?worker',直接得到 worker 的构造函数。我们改用这种写法后,Rolldown 在打包时会把 worker 文件单独编译,不需要额外 loader 配置。

这些调整做完之后,所有子项目的开发启动都稳定跑在 200-350ms 之间。车间主任再也没因为看板白屏给我打过电话。(延伸阅读:我在Amazon Q上跑了一遍RAG流程,发现它简化了ACL 2024那篇论文里的重排序步骤,但查询延迟少了70%

打包体积少了 18%,但这不是最重要的

性能对比表不会说谎,但团队效率才是真金白银

迁移完成后,我们拿一个最复杂的产线看板(含 3D 模型展示、实时数据流、ECharts 图表)做了全面的对比。生产和开发环境的数据如下:

指标 Webpack 5 (prod) Vite + Rolldown (prod) 差异
冷启动 (开发) 43.2s 230ms ↓ 99.5%
HMR 时间 3.8s 50ms ↓ 98.7%
生产构建时间 87s 62s (Vite esbuild) ↓ 28.7%
JS 包体积 (gzip) 428KB 350KB ↓ 18.2%
CSS 包体积 (gzip) 78KB 74KB ↓ 5.1%
FCP (首次内容绘制) 1.2s 0.8s ↓ 33%
LCP (最大内容绘制) 2.4s 1.9s ↓ 20.8%

生产构建我们依然用 Vite 的 esbuild 打包(Rolldown 的生产打包当时还有一些插件兼容问题,没敢上),但已经比 Webpack 快了三成,而且体积减少主要得益于 Tree-shaking 更激进——Rolldown 在开发时的静态分析让很多死代码直接被标记出来,esbuild 生产打包顺势就摇掉了。FCP 和 LCP 的提升则得益于更少的初始 JS 体积和 Vite 的预构建策略。

我们没算在表里的,才是真正的 ROI

冷启动从 43 秒降到 230 毫秒,意味着工程师不再需要“等构建”来切换项目。我可以同时在 3 个看板之间切来切去改 Bug,心理上不再有负担。以前团队每天花在等待构建上的 2 小时现在全部变成有效编码时间。按每人时薪 60 元计算,4 人团队每月直接节约成本超过 1 万元。而生产事故那次因为路径问题宕机 4 小时,我们因此丢了一单潜在的设备维护合同,间接损失没法算——这提醒我,再好的工具迁移,也必须做好灰度发布和充分的预生产环境验证。

迁移结束后,我把这个过程沉淀成一份内部手册,并规定所有新项目都必须以 Vite + 共享包模式启动,不再往回切 Webpack。Rolldown 目前虽然还是实验态,但它在真实工厂环境的表现已经足够说服我:Rust 工具链进入前端构建不是一个选项,而是一种现实的降本增效方法。

这次教训也让我重新审视技术选型的原则:不要等官方发布会,只要实验版本的收益远大于风险,就可以在小范围先用起来。但同时,永远不要低估那些“看起来很简单”的别名和路径问题——它们在旧构建器里被宠坏了,换到静态分析为主的 Rolldown,立马就给你颜色看。

本文由 AI 辅助生成,经人工审核后发布。内容由 沈青锋 基于实战经验指导完成。

觉得有用?

沈青锋

连续创业者,第三个项目在做AI+制造业。前两个项目一个做SaaS一个做IoT,都和技术+产业的结合有关。认为AI最大的价值不在聊天机器人,而在让传统行业运转得更好。写文章的目的是分享创业路上的思考和教训。

发表评论