双十一前的那段日子,我们那个负责商品详情页和结算流程的核心SPA项目,还在死守着 Webpack 4.46.0。那时候每次大促前上线,最让人提心吊胆的不是接口挂了,而是构建机跑不动了。我记得有一次,为了赶在凌晨两点前发一个紧急的价格展示Bug修复,本地 npm run build 居然跑了将近 4分半钟,构建服务器的 16G 内存直接飙到 95%,风扇狂转。当时我就想,这要是再大改点东西,大促预案都得泡汤。
正好那段时间 Webpack 5 已经迭代到了 5.90.0(2024年1月发布的稳定版),社区里关于持久化缓存和模块联邦的讨论已经非常成熟了。我找了个需求空档期,拉着组里的一个小伙子开始动手迁移。
升级过程其实没想象中那么痛苦,但有几个点必须得注意。我们当时最大的痛点是 node-polyfill 的移除。Webpack 5 不再自动注入 crypto、path 这些 Node 核心模块的 Polyfill。我们的项目里有个历史遗留的加密模块,是用 Node 的 crypto 写的,升级后直接报错 Module not found: Error: Can't resolve 'crypto'。
为什么这么做? 因为 Webpack 5 认为浏览器环境不需要这些,强行打包进去只会增加体积。我们当时的解决办法是,如果那个模块只在服务端用,就把它从打包列表里剔除;如果是前端确实需要用的加密逻辑,我们就换成了 crypto-js 这个纯 JS 库,或者配置 fallback。
除了 Polyfill,资源模块(Asset Modules)的替换也是个大头。以前我们用 file-loader 和 url-loader 处理图片,配置又臭又长。Webpack 5 原生支持了 asset/resource 和 asset/inline,我直接把那些 Loader 干掉了。
不这么做会怎样? 其实不换也能跑,但看着那一堆过时的 Loader 配置,心里总觉得不舒服。而且 Webpack 5 的资源模块在处理 Base64 转换时,性能确实比 url-loader 要好一些,配置也更简洁。
最让我惊喜的是构建速度。我们项目大概有 1200 多个模块,升级前冷构建平均是 210秒 左右,热构建(改一行代码)大概 45秒。升级到 5.90.0 并开启持久化缓存后,冷构建第一次还是 210秒,但第二次冷构建(没有任何代码改动,模拟 CI 拉取新代码后的构建)直接降到了 68秒。而日常开发时的热构建,基本稳定在 12秒 左右。这省下来的半分钟,在大促那种高压环境下,就是救命的时间。
另外,Webpack 5 的 sideEffects 优化也帮我们省了不少事。以前我们引入 lodash 总是担心全量引入,现在只要 package.json 里标记了 "sideEffects": false,配合 Tree Shaking,打包体积直接小了大概 15%。
构建速度是上去了,但新的麻烦也跟着来了。本地跑得好好的,一推到 GitLab CI 上,构建时间还是老样子,甚至有时候比 Webpack 4 还慢。我盯着 CI 的日志看了半天,发现每次都在重新编译那些根本没动过的 node_modules 里的依赖。
Webpack 5 默认开启了 cache: { type: 'filesystem' },这本来是个神器。它会在 node_modules/.cache/webpack 目录下生成缓存文件。但在 CI 环境里,每次构建都是一个全新的 Docker 容器,这个缓存目录根本不会被保留,或者因为 Git 没有追踪 node_modules 的变化而导致缓存失效。
为什么这么做? 因为 Webpack 的持久化缓存是基于文件时间戳和内容的哈希值来判断是否失效的。如果 CI 每次都重新 npm install,或者缓存目录被清理了,Webpack 就会认为所有文件都变了,从而重新构建。
我们当时的 CI 用的是 GitLab Runner,我尝试了几种方案。最开始想直接把缓存目录打包上传到对象存储,下次构建再拉下来,但这太重了,而且网络传输耗时可能比构建还久。后来我仔细看了 Webpack 的文档,发现可以通过 cache.buildDependencies 和 cache.version 来精细控制。
但这还没完。GitLab CI 其实有自带的缓存机制。我最后采用的方案是:利用 GitLab CI 的 cache 关键字,把 .webpack-cache 这个目录缓存起来。
排查过程: 有一次线上构建突然变慢,我上去看 CI 日志,发现 Restore cache 那一步成功了,但 Webpack 还是全量编译。后来排查发现,有个同事在本地跑构建时,不小心把 .webpack-cache 目录里的文件权限改了,导致 CI 拉取下来的缓存文件权限不对,Webpack 读取失败,直接忽略了缓存。最后我在 CI 脚本里加了一句 chmod -R 777 .webpack-cache 才解决。
还有一个坑是 thread-loader。为了提速,我给 Babel 加了 thread-loader,但在 CI 环境里,如果构建机的 CPU 核数很少(比如只有 2 核),开启多进程反而会因为进程间通信开销导致变慢。所以我现在一般会根据环境变量动态决定是否开启:
去年我们接手了一个企业级 SaaS 平台的重构。这个平台以前是单体应用,所有的 HR、财务、CRM 模块都揉在一起,打包出来光 JS 就有 12MB,启动一次 npm run dev 要等 3 分钟。老板要求各个业务线能独立开发、独立部署,还要能像搭积木一样组合在一起。
这时候 Webpack 5 的 模块联邦(Module Federation) 就派上用场了。它允许我们在运行时动态加载其他独立构建的应用,而不是把所有代码打包在一起。
为什么这么做? 因为传统的微前端方案(比如 single-spa)通常需要把所有子应用的代码都打包进主应用,或者通过 SystemJS 加载,配置复杂且对构建工具侵入性强。模块联邦是 Webpack 原生支持的,配置相对简单,而且能实现真正的“远程模块”共享。
我们当时的架构是:一个 Shell 作为主应用(Host),负责布局、登录态管理和路由分发;HR、Finance、CRM 作为远程应用(Remote)。
这是 Finance 应用的 Webpack 配置,它把自己暴露出去:
这是 Shell 主应用的配置,它去消费 Finance 暴露的模块:
在 Shell 的代码里,我就可以像引入本地模块一样引入远程模块了:
遇到的实际问题:类型安全。
这是模块联邦目前最大的痛点。因为模块是在运行时加载的,TypeScript 在编译时根本不知道 finance/Dashboard 是什么东西,直接报错。
为了解决这个问题,我们搞了一个叫 @company/mf-types 的私有包。每个远程应用在构建完后,会通过 tsc --declaration 生成 .d.ts 类型文件,然后把这些文件上传到这个包里。
比如 Finance 应用构建后,会生成 types/finance/Dashboard.d.ts。我们在 Shell 里安装这个类型包,然后在 src 目录下建一个 remote.d.ts 文件:
不这么做会怎样? 如果不做类型安全,开发的时候完全靠猜。有一次 Finance 团队把 Dashboard 组件的 props 从 { userId: String } 改成了 { user: Object },没通知我们,也没更新类型包。结果 Shell 那边传参还是旧的,页面直接白屏,控制台报了一堆 Vue 的警告,排查了半天才发现是接口对不上了。有了类型定义,这种错误在 npm run dev 的时候就能直接报出来,根本轮不到上线。
另外,模块联邦的版本控制也很头疼。如果 Shell 和 Finance 用的 Vue 版本不一致,虽然 shared 里配置了 singleton: true,Webpack 会尝试加载高版本,但如果 API 不兼容,还是会崩。所以我们现在强制要求所有子应用和主应用的核心依赖版本必须严格一致,通过 package-lock.json 的 overrides 字段来强制锁定。
去年双十一大促前,我接手了一个运行了三年的老牌电商后台系统。这个系统基于 React 16 开发,打包后的 vendor.js 体积高达 2.1MB。在测试环境,使用公司内网打开首屏需要 3.8 秒,一旦切换到员工家里的弱网环境,加载时间直接飙到 12 秒以上。老板直接把绩效压力给到了我,要求我把首屏加载压到 1.5 秒以内。
我排查了一下,发现主要问题有两个:一是很多组件库里未被使用的代码(比如我们没用过的 DatePicker 和 Modal)依然被打进了包里;二是项目里大量的 SVG 图标和 PNG 图片还是通过 url-loader 转成了 Base64,导致 JS 文件体积虚胖。
针对这两个问题,我在 Webpack 5.90.0 的环境下做了针对性优化。
Webpack 5 对 Tree Shaking 做了增强,支持嵌套模块的剔除。但在实际项目中,仅仅开启 production 模式是不够的。我发现很多第三方库虽然写了 ES Module 语法,但在 package.json 里没有声明 sideEffects,导致 Webpack 不敢删。
我首先修改了 webpack.config.js,确保 optimization.usedExports 是开启的(production 模式下默认开启),然后重点处理了 sideEffects。
为什么这么做? 我检查了 package.json,发现之前的项目里 sideEffects 字段是缺失的。Webpack 为了安全,会假设所有文件都有副作用(比如直接执行了 console.log 或者修改了全局变量),从而不敢删除未引用的代码。我在项目的 package.json 中显式声明了无副作用:
如果某些文件确实有副作用(比如全局引入的 reset.css),我会在数组里单独列出:"sideEffects": ["*.css"]。
做完这一步,打包体积从 2.1MB 降到了 1.7MB。但这还不够,我继续排查,发现 lodash 虽然用了按需引入 import { debounce } from 'lodash',但因为 lodash 的 CommonJS 导出方式,Tree Shaking 效果不理想。我强制团队改用 lodash-es,体积瞬间又减少了 150KB。
那个老项目里,url-loader 的配置是把 10KB 以下的图片都转成 Base64。这在 HTTP/1.1 时代是为了减少请求数,但在 HTTP/2 多路复用的情况下,过多的 Base64 反而让 JS 解析变慢,而且无法被浏览器缓存。
Webpack 5 内置了 Asset Modules,我直接移除了 file-loader 和 url-loader 的依赖,改成了如下配置:
实际效果: 我把 SVG 图标全部设置为 asset/resource,并且配合 svgo 进行了压缩。之前 300 多个 SVG 被打包进 JS 里,导致 JS 体积巨大。分离出来后,虽然增加了几百个请求,但在 HTTP/2 下并发处理毫无压力,且首屏不需要加载所有图标,JS 文件体积直接减少了 400KB。
最终,配合代码分割(SplitChunks),这个订单系统的首屏 JS 体积降到了 680KB,通过 CDN 分发,首屏加载时间从 3.8 秒优化到了 1.2 秒。这就是 Webpack 5 的 Tree Shaking 和 Asset Modules 在真实业务中的威力。
上个月,公司技术委员会让我评估一个新孵化的 ToC 营销项目和一个运行了 5 年的核心交易系统的构建工具选型。这让我不得不重新审视 Webpack 5 在 2024 年的位置。
现在的局面是:Webpack 5.90.0 依然是生态最稳的基石;Vite 凭借 ESM 原生加载在开发体验上吊打传统打包器;而 Rspack 作为 Rust 编写的后来者,正虎视眈眈地盯着 Webpack 的性能痛点。
对于那个新的营销项目,我毫不犹豫选了 Vite。原因很现实:营销页生命周期短,组件复用率低,且不需要复杂的微前端集成。Vite 的冷启动速度是秒级的,开发时改一个按钮样式,页面刷新几乎是瞬间的。
但我也遇到了 Vite 的短板。营销页里有一个需要动态加载旧系统遗留的 UMD 格式 SDK 的需求。Vite 在开发环境下用 ESM 加载,遇到这个 UMD 包直接报错,因为浏览器不支持 require 语法。我不得不写了一个 vite-plugin 去预处理这个文件,或者把它扔进 public 目录用 script 标签引入,这破坏了模块化的美感。
对于那个运行了 5 年的交易系统,情况完全不同。这个项目有 2000+ 个模块,依赖了公司内部封装的 30 多个私有 npm 包,且深度集成了 Webpack 的 NormalModuleReplacementPlugin 来做多环境配置替换。
我做过一个实验,尝试用 Rspack 去编译这个遗留项目。Rspack 确实快,冷构建只用了 15 秒,而 Webpack 5 需要 45 秒。但问题来了:Rspack 虽然兼容大部分 Webpack API,但那个交易系统里有一个自定义的 Loader,用来解析一种特殊的 .tpl 文件(公司内部模板引擎),这个 Loader 是用纯 JS 写的,且依赖了 Node 的 fs 模块。Rspack 目前的 Rust 内核无法运行这个 JS Loader,我必须重写它,或者等待社区支持。
为什么不盲目迁移?
迁移成本不仅仅是改配置文件。我算了一笔账:
require.context 和动态 import 路径处理,在 Rspack 下行为略有差异,需要逐个排查。webpack-bundle-analyzer 和 speed-measure-webpack-plugin。Rspack 有自己的分析工具,但集成进 CI/CD 流水线需要改脚本。我最终给这两个项目定的策略是:
swc-loader 替代 babel-loader,构建速度提升了 40%;二是开启了持久化缓存(cache: { type: 'filesystem' }),让二次构建从 45 秒降到了 6 秒。在 2024 年,Webpack 5 依然是企业级复杂应用最稳妥的选择。Vite 和 Rspack 是未来的趋势,但在涉及大量私有插件、复杂构建逻辑的遗留项目中,Webpack 5 的容错率和生态成熟度目前还没有对手。
很多开发者用了好几年 Webpack,只知道配置 entry 和 output,一旦遇到打包报错就束手无策。我之前带过一个新人,他在做代码分割时,发现每次改一行代码,所有文件的 hash 都变了,导致 CDN 缓存全部失效。这其实就是没理解 Webpack 的 Module IDs 机制。
Webpack 的构建流程本质上是一个流水线:初始化 -> 编译(Compilation) -> 输出(Emit)。在这个流程里,Loader 和 Plugin 分别作用于不同的阶段,解决不同的问题。
Loader 的本质是一个函数,它接收源文件内容,返回转换后的内容。它只关心文件层面的转换。
我在一个项目里遇到过这样一个需求:我们需要在前端直接引入 .yaml 格式的配置文件。Webpack 默认不认识 YAML,这时候就需要 Loader。
实际问题排查:
有一次线上环境配置没生效,我排查下来发现是 yaml-loader 的解析顺序问题。配置如下:
为什么这么做? Webpack 的 Loader 执行顺序是从右到左(或者说从下到上)。yaml-loader 负责把 YAML 文本转成 JS 对象,json-loader 负责把 JS 对象转成 JSON 字符串,这样最终导出的就是一个可以直接 JSON.parse 的字符串。如果不理解这个顺序,就会拿到一个 [Object object] 的字符串,导致解析失败。
如果说 Loader 是翻译官,Plugin 就是监工。它可以在 Webpack 生命周期的每一个钩子(Hook)上挂载任务。比如 HtmlWebpackPlugin 是在 emit 阶段,把打包好的 JS 注入到 HTML 模板里。
我在做微前端改造时,需要自定义一个 Plugin 来修改输出目录的结构。Webpack 5 的插件系统基于 Tapable,理解这个才能写出高级配置。
回到开头那个新人遇到的问题。Webpack 在打包时,会给每个模块分配一个 ID 用于引用。在 Webpack 5 之前,默认使用的是自增 ID(0, 1, 2, 3...)。
为什么不这么做?
假设我有 3 个文件:index.js (id: 0), a.js (id: 1), b.js (id: 2)。
如果我在 index.js 里新增了一个引入 c.js,那么 c.js 可能会被分配 id: 1,而原来的 a.js 变成 id: 2,b.js 变成 id: 3。
结果: 虽然 a.js 和 b.js 的代码一行没改,但因为 ID 变了,它们对应的 Chunk Hash 也变了。浏览器会认为它们是新文件,缓存全部失效。
Webpack 5 默认开启了 确定性短哈希(Deterministic Module IDs)。它不再使用自增数字,而是根据模块的相对路径生成一个固定的短字符串(如 a1b2)。
我的实战经验:
在 Webpack 4 时代,我必须手动引入 HashedModuleIdsPlugin 来做这件事。升级到 Webpack 5.90.0 后,这个优化是内置的。这带来的直接收益是:我们那个订单系统,修改一个按钮文案,重新打包后,只有包含该按钮的 JS 文件 hash 变了,其他 99% 的 vendor 文件 hash 保持不变。配合 CDN 的长缓存策略(Cache-Control: max-age=31536000),用户二次访问的加载速度提升了 70% 以上。
理解这些底层机制,能让你在面对复杂的构建需求时,知道该去哪里找解决方案,而不是盲目地复制网上的配置。
去年双十一前,我负责的一个电商后台系统从 Webpack4 升级到 5.90.0。本来本地测试构建速度从 90 秒降到了 40 秒,大家都挺开心。结果上线到 CI/CD 环境后,构建时间反而飙升到了 4 分钟。
我当时第一反应是缓存没生效。查了半天,发现是我们在 Docker 构建时,每次都会重新安装 node_modules,导致 持久化缓存 的目录被清空了。我当时的解决方案很直接:把缓存目录挂载到宿主机,而不是容器内部。同时调整了 cache.buildDependencies 配置,把 package.json 和 config 文件纳入监听。
改完之后,CI 环境的构建时间稳定在 45 秒左右。这次经历让我明白,Webpack5 的缓存虽好,但在容器化部署时,路径和依赖管理必须得配合好,否则就是负优化。
关于 Module Federation(模块联邦),我现在的态度比较保守:
* 适合的场景:像我这种有多个子系统的 SaaS 平台,或者需要独立部署的微前端项目。它能让你不用发版就能更新公共组件,确实爽。
* 不适合的场景:小团队、单应用项目。如果你为了用而用,光是处理 类型安全 和 版本对齐 就能把你累死。我见过有人在一个简单的官网项目里硬上 MF,结果维护成本比收益高得多。
至于 Tree Shaking,别太迷信它。如果你的代码里到处是 require 和 import *,或者用了太多副作用严重的库,开了也是白开。
别把 Webpack 当成黑盒。很多人配置都是直接抄网上的,出了问题就懵。我建议你花点时间看看 Module IDs 的生成逻辑,理解为什么开发环境要用 named,生产环境要用 deterministic。当你真正理解了构建流程,配置优化其实就是顺水推舟的事。别怕慢,多试几次,构建日志里的每一个警告都值得你停下来看一眼。