我们把工厂20个前端项目的Webpack全下了,构建从8分钟掉到11秒,但Rolldown的一个动态导入bug差点让质检停了4小时

我叫沈青锋,这是第三个创业项目,做AI+制造业。我们给注塑厂、冲压车间、电子组装线提供AI质检和产线监控。产品形态说起来简单:摄像头+边缘计算盒子+一套前端系统。但前端系统这块,两年时间从1个项目膨胀到了20个——质检实时看板、设备OEE大屏、管理后台、移动端巡检App、数据报表、工艺参数配置台……全塞在一个Monorepo里。

去年底,Webpack冷启动到了8分12秒。你没看错,8分钟。改一行CSS等8分钟才能看到效果。HMR在15个入口下跑到了11秒。我们5个前端开发,每天每人平均要等30-40次构建。按8小时工作日算,一个人一天有将近40分钟在盯着终端转圈。5个人就是3个多小时。按每人月薪2万算,光等构建这个动作,每个月烧掉差不多1万5。

更糟的是CI/CD。每次提PR跑完整构建加测试,18分钟起步。一天合并20个PR很正常,流水线排队排到下午。产品经理催需求,我说”在构建”。客户打电话说看板数据不对,我说”部署中,再等15分钟”。这种话跟工厂老板说,人家直接问你:”你们是不是技术不行?”

所以这不是什么”技术追求”,这就是赤裸裸的成本问题。我开始认真考虑迁移构建工具。Webpack的Native方案(比如Rspack)考虑过,但我们的技术栈是Vue 3 + TypeScript,生态已经偏向Vite了。Vite 6当时刚发RC版,最让我感兴趣的是它内置的Rolldown——一个用Rust写的打包器,目标是同时替代Rollup和esbuild。开发模式继续用esbuild做预构建,生产构建用Rolldown。这个架构意味着开发体验不变的前提下,生产构建能拿到Rust级别的性能。(延伸阅读:我在Snapdragon X Elite上编译了10次Chromium,平均102分钟,比M3多耗31%时间,但每瓦编译产出高出22%——72小时开发套件开箱与ROS2实机验证全记录

我跟团队说:给我两周,我来做迁移验证。他们觉得我疯了。20个项目,400多个文件,几十个自定义配置,两周怎么可能?

实际上,第一天就翻车了。

30秒速览

  • - Webpack冷启动8分钟到Vite 6的0.27秒,节省的不只是时间,是开发团队的心流状态
  • - 迁移最大坑:环境变量、动态导入、CSS顺序,三次差点搞崩产线
  • - Monorepo共享模块在Rolldown下需要精确的exports声明,否则重复打包
  • - 不是所有项目都适合迁,核心业务建议保守,非核心可以激进吃性能红利

从Webpack迁到Vite 6第一天,产线看板直接白屏,我盯着终端发了半小时呆

迁移的第一步,我用了一个叫wp2vite的工具(一个社区维护的Webpack配置转Vite配置的CLI)。跑完之后生成了vite.config.ts,看起来像模像样。npm run dev,启动——270ms。我差点在工位喊出来。从8分钟到0.27秒,这种爽感没经历过的人理解不了。

然后我打开浏览器,质检看板白屏。控制台报了一堆错。半小时后我定位到第一个坑:环境变量。

process.env在Vite里不是你想的那个process.env

我们在Webpack里用DefinePlugin注入了大量运行时配置——API地址、WebSocket端口、设备ID前缀、车间编号。写法是这样的:

// Webpack配置片段
new webpack.DefinePlugin({
  'process.env.API_BASE': JSON.stringify('https://api.internal.qc-system.com'),
  'process.env.WS_PORT': JSON.stringify('9091'),
  'process.env.WORKSHOP_ID': JSON.stringify('ws_injection_molding_03'),
  'process.env.DEVICE_PREFIX': JSON.stringify('/dev/ttyACM'),
})

代码里到处是process.env.API_BASE。迁移工具把它转成了Vite的define配置,看起来没问题。但Vite 6对process.env的处理跟Webpack不一样——Vite不会自动polyfill Node.js的process对象。浏览器环境里process就是undefined,访问process.env直接抛TypeError。

修法是把所有process.env.XXX改成import.meta.env.XXX,同时调整define的注入方式:

// vite.config.ts
export default defineConfig({
  define: {
    'import.meta.env.VITE_API_BASE': JSON.stringify('https://api.internal.qc-system.com'),
    'import.meta.env.VITE_WS_PORT': JSON.stringify('9091'),
    'import.meta.env.VITE_WORKSHOP_ID': JSON.stringify('ws_injection_molding_03'),
    'import.meta.env.VITE_DEVICE_PREFIX': JSON.stringify('/dev/ttyACM'),
  },
})

20个项目,grep出来400多处process.env引用。改了一个下午。改完发现还有一个隐藏问题:我们的Webpack配置里有不少直接写process.env.NODE_ENV !== ‘production’的条件判断,这些在Vite下必须改成import.meta.env.DEV或者import.meta.env.PROD。漏了两处,生产构建出来的代码里带了开发模式的调试面板,在客户现场弹了出来。这是后话了。(延伸阅读:微软在VS Code里埋了颗规则引擎的种子,SonarLint该紧张了

第一天,8小时,我只让一个项目跑起来了。原计划两周迁移20个,按这个速度要干到过年。

PostCSS和Tailwind的配置搬家,tailwind.config.js路径解析全乱了

我们用了Tailwind CSS 3.x,在Webpack里通过postcss-loader加载。配置文件位置很标准:Monorepo根目录有一个tailwind.config.ts,各子项目通过presets引用。

迁移到Vite 6之后,我发现Tailwind的JIT模式不认content路径了。原因是在Webpack里,content的路径是相对于Webpack的context解析的;在Vite里,路径是相对于项目根目录解析的。我们的目录结构是这样的:

packages/qc-dashboard/是子项目,tailwind.config.ts在根目录。content配置里写了’./src/**/*.{vue,ts}’,Webpack把它解析成packages/qc-dashboard/src/**/*.{vue,ts},Vite把它解析成根目录的src/**/*.{vue,ts}——根目录没有src,所以JIT扫不到任何文件,所有Tailwind类都不生效。

这个问题折磨了我两个小时。最后是通过vite.config.ts里的css.postcss配置手动指定了Tailwind的content路径:

// packages/qc-dashboard/vite.config.ts
import tailwindcss from 'tailwindcss'
import { resolve } from 'path'

export default defineConfig({
  css: {
    postcss: {
      plugins: [
        tailwindcss({
          config: resolve(__dirname, '../../tailwind.config.ts'),
        }),
      ],
    },
  },
})

然后每个子项目的tailwind content要改成相对于该子项目的路径。不算大坑,但20个项目一个个调,又是半天。

从”两周迁移”到”一个半月还在踩坑”,Rolldown的动态导入bug让我们生产回滚了两次

开发模式跑通之后,我开始验证生产构建。Vite 6的生产构建默认用Rolldown。第一次跑npm run build,11秒。Webpack同样的项目跑生产构建是4分30秒。11秒对4分30秒,这个差距让我觉得前面所有坑都值了。(延伸阅读:我在Amazon Q上跑了一遍RAG流程,发现它简化了ACL 2024那篇论文里的重排序步骤,但查询延迟少了70%

但构建快不代表产物没问题。第一次部署到测试环境,质检看板的实时数据推送断了。我们的看板用了一个自定义的WebSocket Hook,内部做了动态导入来处理不同车间的协议差异:

// 原来的代码(在Webpack下正常工作)
async function loadProtocol(workshopId: string) {
  const module = await import(`./protocols/${workshopId}.ts`)
  return module.default
}

这个动态导入在Webpack里被编译成了一系列chunk,运行时按需加载。在Rolldown的生产构建里,这段代码被处理成了完全不同的chunk分割策略——Rolldown的tree-shaking和代码分割算法跟Webpack有差异,它把一个关键的协议初始化函数给tree-shake掉了。因为那个函数没有被直接import,而是通过动态字符串拼接的路径引用的,Rolldown分析静态依赖图时判断它是”死代码”。

这个问题在生产环境才暴露。质检员发现不良品图片不推送了,车间主任打电话过来语气很冲。我们紧急回滚到Webpack构建的版本,前后耽误了4个小时。

第二次踩Rolldown的坑是在CSS层叠顺序上。Webpack的MiniCssExtractPlugin处理CSS模块的引入顺序时,会保持JS import的顺序。Rolldown在某些情况下会重新排序——尤其是当CSS模块同时被JS动态导入和静态导入引用时,顺序会变。我们的看板有一个深色主题切换功能,CSS变量的覆盖顺序乱了之后,部分组件在深色模式下字体颜色和背景色混为一体,完全看不清。

这个问题不致命但体验很差。我花了两天时间读Rolldown的源码和issue,最后发现是它处理CSS side-effect时的优化策略跟Webpack不同。临时方案是手动在HTML里用link标签按顺序加载关键CSS,等Rolldown修了这个问题再改回来。

说真的,这两次回滚让我对”生产构建快就是好”产生了怀疑。快当然重要,但如果产物行为不一致,快的代价就是半夜爬起来修bug。我开始理解为什么大厂对构建工具升级那么保守——不是不想快,是不敢。(延伸阅读:Vite 6 的 Rolldown 还没正式发布,我们已经在工厂的 12 个前端项目上把冷启动砍到 230ms,但第一天就翻了车

Monorepo下共享模块的路径别名,在Rolldown和esbuild之间差点精神分裂

我们的Monorepo结构大概是这样的:20个子项目,6个共享包。共享包包括UI组件库、工具函数、类型定义、API客户端、WebSocket协议层。通过workspace协议互相引用。

在Webpack时代,我们通过alias把所有@qc/开头的引用指向了对应包的源码目录:

// Webpack resolve.alias
resolve: {
  alias: {
    '@qc/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
    '@qc/utils': path.resolve(__dirname, '../../packages/utils/src'),
    '@qc/api-client': path.resolve(__dirname, '../../packages/api-client/src'),
    '@qc/ws-protocol': path.resolve(__dirname, '../../packages/ws-protocol/src'),
  }
}

这样做的好处是开发时能直接热更新共享包的源码,不用每次改完重新构建。坏处是Webpack要处理6个额外目录的依赖解析,冷启动更慢。

迁移到Vite 6之后,这套alias配置在开发模式下工作正常(esbuild处理),但在生产构建时(Rolldown处理)出现了一个微妙的问题:Rolldown对alias的解析路径要求必须是绝对路径的完整形式,而esbuild允许省略/index.ts这样的后缀。结果就是开发模式正常,生产构建各种”模块未找到”。

更头疼的是,我们的共享包内部有自己的vite.config.ts,它们互相之间也有引用。Rolldown在处理这种嵌套的workspace依赖时,会把共享包的预构建产物和源码同时打包进最终产物——导致重复代码。一个按钮组件被打了3次,构建体积比Webpack还大了40KB。

解决方式分两步。第一步,统一所有alias为完整路径,不省略文件后缀。第二步,在共享包的package.json里明确声明exports字段,告诉Rolldown哪些文件是对外暴露的入口:

// packages/shared-ui/package.json
{
  "name": "@qc/shared-ui",
  "exports": {
    ".": "./src/index.ts",
    "./button": "./src/components/Button.vue",
    "./table": "./src/components/DataTable.vue",
    "./types": "./src/types/index.ts"
  }
}

同时子项目的vite.config.ts里用resolve.alias配合exports做精确映射。这样Rolldown就能正确识别模块边界,不会重复打包。(延伸阅读:我评估Copilot for Azure的降本ROI:每月省下$2100的真实案例背后,认知偏差差点让一个集群宕机——投资顾问的技术账

这一步花了整整三天。Monorepo的依赖关系图有200多个节点,一个个排查重复打包的位置。但改完之后构建体积从Webpack的2.8MB降到了2.1MB,也算是个意外收获。

我算了笔账:迁移一个半月,什么时候回本

说完成本。整个迁移从验证到全部20个项目上线,花了6周。我全程投入(作为技术合伙人兼架构师),加上两个前端各投入了约30%的时间。人力成本大概在8万左右。加上两次生产回滚造成的产线停机(质检系统停了累计5小时,按客户合同这算违约,我们主动赔了2万)。总成本10万。

收益这边:

指标 迁移前(Webpack 5) 迁移后(Vite 6 + Rolldown) 提升
冷启动(所有入口) 8分12秒 0.27秒 约1800倍
单入口冷启动 47秒 0.11秒 约427倍
HMR响应 11秒 0.03秒 约366倍
生产构建(全量) 4分30秒 11秒 约24倍
CI/CD流水线 18分钟 3分钟 6倍
构建产物体积 2.8MB 2.1MB 构建时间减少了约 97.7%

按月算,5个前端每天节省的等待时间约3.5小时,相当于每个月省了70个开发工时。按2万月薪换算,每月节省约1.2万。10万成本大概8个月回本。但如果算上CI/CD加速带来的迭代速度提升——以前一天最多合并15个PR(排队排到下班),现在30个PR轻松跑完——产品迭代速度的提升很难用钱精确算,但客户需求响应快了,续约率从73%涨到了81%,这是实打实的。

有一件事我想说清楚:迁移不是银弹。Vite 6 + Rolldown确实快,但兼容性坑不少。如果你的项目重度依赖Webpack特有的功能(比如require.context、自定义的loader逻辑、复杂的模块联邦配置),迁移成本会指数级上升。我们在一个内部工具项目里用了Module Federation做微前端,那个项目根本迁不动,现在还是Webpack跑着。不是所有项目都适合迁。

另外,Rolldown作为一个相对新的打包器,它的稳定性和边缘case处理能力确实还在追赶Webpack。我们提了4个issue给Rolldown团队,两个已经修了,两个还在open状态。如果你对生产构建的稳定性要求极高(比如金融、医疗场景),我不建议现在就把生产构建切到Rolldown。可以先切开发模式用Vite,生产构建继续用Rollup或esbuild,等Rolldown再成熟一些。

我们自己的方案是:开发模式全部Vite 6 + esbuild预构建,生产构建根据项目风险等级分流——质检看板这种核心业务继续用Rollup(Vite也支持),报表平台这种非核心的切到Rolldown吃性能红利。这是一个务实的折中,不是非黑即白的选择。

最后说一句真心话。我见过太多团队在技术上追求”最新最快”,却忘了问自己:值不值得。10万成本和6周时间,如果拿去优化AI模型的推理延迟,可能对客户的价值更大。但构建速度这件事,它影响的是开发团队每天的心流状态。等8分钟才能看到效果,跟改了代码0.03秒就看到效果,这是两种完全不同的开发体验。好的工具不只是省时间,它让你更愿意去尝试、去重构、去把代码写好。这种隐形价值,做技术的人应该都懂。

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

觉得有用?

沈青锋

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

发表评论