如何提升 Webpack 打包速度

avatar
大前端 @阿里巴巴
文/米酒

背景

前段时间在某个项目进行需求开发的时候,该项目是基于 webpack3 进行打包构建的。在开发过程中我发现打包很慢,开发体验不佳,于是做了简单的优化并梳理了优化方案

分析打包速度

进行优化的第一步需要知道我们的构建到底慢在那里。通过 speed-measure-webpack-plugin 测量你的 webpack 构建期间各个阶段花费的时间:

// 分析打包时间
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin")
const smp = new SpeedMeasurePlugin()
// ...
module.exports = smp.wrap(prodWebpackConfig)

我们可以看到打包速度慢主要是因为对样式文件和对 js 文件的处理 loader 耗时较久

当文件发生修改,进行重新编译时,此时的打包各阶段时间如下

重新编译时耗时其实并不久,但是在浏览器上实际上却花费了更多的时间在看到了新的修改,这是为何呢

分析 bundle 包

  • 打包后的 bundle 文件生成一个分析文件
"analyse": "webpack --config ./webpack.config.js --profile --json>states.json"
  • 对 json 文件进行分析

我们可以看到在使用 webpack 3.5.6 进行打包的过程中涉及到了 705 个模块,生成了 2 个 chunks, 耗时约 51s

  • 进一步分析 chunks 文件

打包出的两个 chunks 文件分别为 app 和 vendor 文件,其中 app.js 文件体积高达 6M

在绝大多数的情况下,应用刚开始工作时,并不是所有的模块都是必需的。如果这些模块全部被打包到一起,即便应用只需要一两个模块工作,也必须先把 bundle.js 整体加载进来,而且前端应用一般都是运行在浏览器端,这也就意味着应用的响应速度会受到影响,也会浪费大量的流量和带宽。

开发环境下的 bunlde 依赖表也能看出 node_modules 中大部分内容是随着 app.js 一起打包,这些就是引起我们打包速度缓慢的元凶

  • 当我们代码有更新时,HMR 会重新打包 app.js,也就是说那些没有被修改的 node_modules 中内容也会跟着重新打包到 app.js 中去

这意味着,每次代码修改,浏览器都会重新加载这个 6M 大的文件,所以为啥改了一点点内容,浏览器也需要很久才有反应,元凶在这

  • Bundle optimize Helper 的优化建议
Entrypoints are code that are loaded on page load. To get best possible user experience, you should keep the total size of entrypoints to less than 200kb and load the rest dynamically by using code splitting.

我们将 json 文件上传到 Bundle optimize Helper 得到的优化建议是去进行代码分割,入口文件的代码体积不要超过 200K

优化

代码分割

高达 6M 的入口文件显然是非常影响体验的,因此优化的第一步就是从代码分割开始。代码分割通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle 中,从而降低应用的启动成本,提高响应速度。

  • 项目本身已经配置了多入口,将 lodash 等三方库文件单独进行打包,生成 vendor.js 文件
  • 将入口文件依赖的 node_modules 中内容打包到 common 中,将业务代码进行单独打包,这样可以有效减少 app.js 的体积
new webpack.optimize.CommonsChunkPlugin({
  name: 'common',
  minChunks: function(module) {
    return (
      module.resource &&
      /\.js$/.test(module.resource) &&
      module.resource.indexOf(
        path.join(__dirname, './node_modules')
      ) === 0
    )
  }
})
  • 再使用一次 CommonsChunkPlugin 抽取 mainfest.js 文件,保证 common.js 的 hash 不会因为每次打包发生变化
new webpack.optimize.CommonsChunkPlugin({
  name: 'manifest',
  chunks: ['vendor', 'common', 'app']
}),
  • 使用 HashedModuleIdsPlugin 来保持模块引用的 module_id 不变,CommonsChunkPlugin 提取入口指定的依赖独立打包,mainifest 则保存运行时的函数和模块标识

可以看到此时 app.js 中以来的一些三方库被单独抽取出来了,体积从 6M 降至 3.49 M

  • 当修改业务代码时我们看下新的打包文件

当我们的业务代码发生修改时,会重新进行打包,而依赖的三方库并不会重新打包,此时重新打包的业务代码 app.js 体积也为 3.49M

我们可以看到没有变更的依赖包会走 304 协商缓存,而有变更的 app.js 的会重新请求并且因为体积比之前小,加载性能得到了优化

速度优化

在前面的速度分析中我们已经知道了打包速度主要耗费在 loader 的处理上

很显然在开发过程中进行 webpack 缓存是极其有必要的,我们在处理样式文件和 js 文件的 loader 之前添加 cache-loader 将结果缓存到磁盘中,可以显著提升二次构建速度

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
      {
        test: /\.scss$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
    ],
  },
};

加入缓存后我们可以看到打包速度有了显著的提升

进行优化后我们可以看到:优化后入口包体积缩小 42%,打包速度从 51s 提升至 11s

Do More

wepack 的打包优化没有固定的模式,需要我们针对项目去进行分块、拆包、压缩等,常见的优化思路主要分为四部分

  • 优化搜索时间,即开始打包时获取所有的依赖模块的时间
  • 优化解析时间,即根据配置的 loader 解析相应文件所花费的时间
  • 优化压缩时间,即 wepack 对代码进行优化压缩所花费的时间
  • 优化二次打包时间,即重新打包时所花费的时间

在当前的生产构建时会使用 UglifyJsPlugin 来进行代码压缩,但这个插件是单线程的,压缩时会将代码先解析为 AST 抽象语法树,然后根据规则去分析和处理 AST, 最后再将处理后的 AST 还原为 JS 代码,这种涉及大量运算的操作都是非常耗时的。在 Webpack4 中内置了 TerserPlugin 来处理 JS 代码的压缩,我们可以开启多进程压缩模式,可以进一步优化我们的打包速度

我们对 chunks 进行代码分割,但目前 app.js 在未压缩的情况下体积为 3.49M,依然比较大,Webpack4 中 splitChunks 中提供了更为丰富的配置规则,我们可以将代码中公共的部分抽取出来,以及异步加载的模块进行抽取,这样也可以进一步优化代码体积

考虑到项目的稳定性,我们将延后进行 webpack 的升级改造。

以上是本次基于 webpack 优化项目开发体验的小结。了解 webpack 的打包原理,使用 webpack 新特性,一定可以给我们带来更佳的开发体验。