前端知库

webpack 构建慢、产物大,应该怎样优化?

先用 speed-measure 之类的工具和 bundle 分析找到慢在哪、大在哪。提速主要靠持久化缓存(cache.type: 'filesystem')、缩小 loader 处理范围、用 esbuild 或 SWC 替代 babel-loader、多进程处理;减小体积靠 Tree shaking、代码分割、压缩,以及把大依赖换成更小的或按需引入。

参考答案

简答

项目越写越大,npm run build 从几十秒变成好几分钟,打出来的主包好几 MB,这时要分两件事看:构建速度和产物体积。两者的优化手段不一样,但第一步都是先测量,不要凭感觉改配置。

构建速度上,收益最大的通常是缓存。webpack 5 设置 cache: { type: 'filesystem' } 后,会把编译结果缓存到磁盘,第二次构建只处理变化的模块。其次是让 loader 少干活:用 include 只处理 src 目录,不去转换 node_modules;把 babel-loader 换成 esbuild-loader 或 swc-loader,它们用 Go 和 Rust 实现,转换快很多。再往后可以用 thread-loader 把耗时的 loader 放到 worker 池里并行,但官方文档提醒,每个 worker 启动有开销,主进程和 worker 之间传数据也有成本,所以 worker 不要开太多,只给耗时很长的 loader 用。

产物体积上,先用 webpack-bundle-analyzer 看清楚包里是什么。常见的大头是整包引入的依赖,比如 lodash、moment、图表库,换成按需引入或更小的替代品;首屏用不到的页面和组件用动态 import() 拆出去;用 splitChunks 把第三方依赖单独打包,业务代码改了也不影响它的缓存;生产模式默认开启 Tree shaking 和压缩,要保证代码是 ES 模块写法,依赖声明了 sideEffects。

要注意,开发环境和生产环境的目标不同。开发时要的是启动和热更新快,可以关掉压缩、用便宜的 Source Map;生产时才需要完整的压缩和优化。

先测量再优化

构建速度可以用 speed-measure-webpack-plugin 看每个 loader 和插件各花了多久,或者用 webpack 自带的 --profile 参数。体积用 webpack-bundle-analyzer,它会生成一张图,每个模块占多大一目了然。

为什么一定要先测?因为构建慢的原因可能完全出乎意料,比如某个 loader 意外地处理了 node_modules 里的几千个文件,或者一个插件在每次构建时做了大量 I/O。不测就上 thread-loader,可能不仅没变快,还因为进程开销更慢了。

一份常见的优化配置

JavaScript
module.exports = {
  mode: 'production',
  cache: { type: 'filesystem' },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        include: path.resolve(__dirname, 'src'),
        loader: 'esbuild-loader',
        options: { target: 'es2018' },
      },
    ],
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor' },
      },
    },
  },
};

cache.type: 'filesystem' 的缓存存在 node_modules/.cache/webpack 下。依赖或配置变了要让缓存失效,webpack 会自动追踪配置文件本身;如果构建还依赖别的文件,要在 cache.buildDependencies 里声明。CI 环境里每次都是全新的目录,要把这个缓存目录保存下来,缓存才有用。

splitChunks 把 node_modules 里的代码拆成单独的 vendor 包。第三方依赖很少变,文件名里的哈希也就不变,用户再次访问时可以直接用浏览器缓存,只需下载变化的业务代码。不过所有依赖打成一个包可能很大,实际项目常再把大的库单独拆出来。

常见追问

为什么 Tree shaking 没有生效?

常见原因是代码被转成了 CommonJS,比如 Babel 配置里把 modules 设成了 commonjs;或者依赖没有声明 sideEffects: false,webpack 不敢删;或者是 export default 一个大对象,用到其中一个属性也会把整个对象留下。详见《Tree shaking 如何工作?为什么有些代码无法移除?》。

DllPlugin 现在还需要吗?

多数项目可以先不用。DllPlugin 把不常变的依赖放到单独的编译里预先打包,webpack 的构建性能文档仍然列出了它,并说明它能提高编译速度,但会让构建流程更复杂。webpack 5 的持久化缓存也能让二次构建跳过没变的模块,配置简单得多,一般先开缓存,测量后仍然不够快再考虑 DllPlugin。

换成 Vite 是不是就不用优化了?

Vite 开发时不打包,启动和热更新的问题基本解决了。可是生产构建仍然要打包,产物体积的问题依然存在,依赖太大、没做代码分割,换了工具也一样大。