文章分为3个章节,前两章先讲webpack优化的各种理论,第三个章节再讲我对部分理论的实操,分享减少打包时间95%的经历。
webpack优化无非就是两个大方向:提升编译速率、减少打包工作量。
速率高了,打包速度就快;工作量少了,打包速度自然也快了。
下面是我总结的脑图,具体内容文内会详细提到。

一. 理论章------提升编译速率
1. 使用 thread-loader 启动多进程/并行处理
通过将耗时的 loader(如 Babel、TypeScript)放入独立进程池并行处理。
- 原理:
thread-loader将后续 loader 移至 worker 进程池,避免主进程阻塞 - 适用场景:适用于处理时间长的 loader(如 Babel 转译大型项目)
 
注意:通常只有耗时较长的任务才需要,小项目不需要。根据webpack官网的说法,每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。此外,进程间通信也会带来额外的开销。因此不要过度迷恋多进程,要小心得不偿失.
            
            
              js
              
              
            
          
           // webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: 3,        // 进程数(建议设为 CPU 核数-1)  
              workerParallelJobs: 50  // 每个进程并行任务数
            }
          },
          'babel-loader'      // 后续 loader 在子进程执行
        ],
        exclude: /node_modules/
      }
    ]
  }
};
        2. 升级node和webpack版本loader及plugin版本
每一次Webpack的版本升级,都可能优化了内部的构建算法,从而减少构建时间。例如:改进依赖图解析算法、代码生成算法等...甚至还能有一些高级的优化特性。
每一次Node版本升级,都可能带来V8的引擎优化、更快的模块加载速度、更快的文件读写能力等等...
3. 直接提升硬件性能(最硬核的方式)
这方面不用多说,属于最硬核也最不实用的方式。有条件的,可以特别给CI/CD的运行机器升级硬件。
- 对CPU而言,升级主频、核数;
 - 对硬盘而言,换成SSD,直接增加文件读写性能;
 - 对内存而言,尽量换成更大的。
 
二. 理论章------减少工作量
1. 运用Webpack缓存
Webpack 缓存分为:内存缓存 与 持久化缓存

webpack5配置持久化缓存:
            
            
              js
              
              
            
          
          const path = require('path');
module.exports = {
    mode: 'production',
    cache: {
        type: 'filesystem'
    },
    entry: './src/main.js',
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: "[name].js",
    },
}
        执行npx webpack命令之后会在node_modules下生成.cache目录

当然还可以扩展很多cache特性:
            
            
              js
              
              
            
          
          module.exports = {
    cache: {
        type: 'filesystem', // 可选值 memory | filesystem
        cacheDirectory: './.cache/webpack', // 缓存文件生成的地址
        buildDependencies: { // 哪些文件发现改变就让缓存失效,一般为 webpack 的配置文件
           config: [__filename],      web     // Webpack 配置
           plugins: ["./babel.config.js", "./postcss.config.js"], // eslint,babel等配插件配置
           custom: ["./build-utils.js"]    // 自定义字段
        },
        managedPaths: ['./node_modules', './libs'], // 受控目录,下面有解释
        profile: true, // 是否输出缓存处理过程的详细日志,默认为 false
        maxAge: 1000 * 60 * 60 * 24, // 缓存失效时间,默认值为 5184000000
    }
}
        特殊参数解释:

具体而言,缓存都节约了哪些性能?
生成文件、调用Loader进行转译、生成并分析Ast、构建依赖图、替换import、treeshaking等过程都需要密集的调用CPU和内存,同时也是webpack打包过程中最耗时的过程。只要将node_modules下不会频繁变更的模块生成的chunk一次性缓存,这样下次打包直接取chunk缓存,就会省去上面的很多过程。
Webpack持久化缓存什么时候失效?
webpack持久化缓存是根据 chunkhash 判断的,所以粒度是 chunk 这个级别。而一个chunk中可能包含多个模块(module)。所以只要 chunkhash 变化的场景,都是缓存失效的场景:
- 模块内容变化(即使只是空格or注释)
 - 模块依赖关系变化
 - Webpack配置变化
 - 依赖的loader/plugin版本变化
 - ....
 
2. 开启babel-loader持久化缓存
道理和webpack的持久化缓存类似,但webpack持久化缓存是以chunk为单位缓存,而babel-loader是以单个js module为单位进行缓存。因此babel-loader的持久化缓存更具细粒度、更有针对性。可以它和webpack的持久化缓存形成黄金搭档。
场景:webpack某chunk包含下面3个module, chunk =[js1, js2, js3] 。现在持久化缓存已生成,后修改js2和js3文件中的代码,重新打包。会大致经历下面几个步骤:
- webpack持久化缓存失效:由于js2和js3改变,导致chunkhash改变,整个chunk的缓存失效,重新对js1,js2,js3三个文件进行打包;
 - babel-loader持久化缓存生效:js1 被送入 babel-loader进行ast解析,但js1的content-hash没变,意味着ast解析结果没变,babel-loader利用了js1先前的ast解析结果。
 - 结果:babel-loader节省了对js1的重编译性能开销。
 
配置方式:
            
            
              js
              
              
            
          
          {
  test: /.js$/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: ['@babel/preset-env'],
      cacheDirectory: true  // 启用缓存
    }
  }
}
        3. treeshaking也能减少工作量
treeshaking在性能方面,是一把双刃剑,需要权衡。虽然treeshaking带来了编译时的额外工作量,但在后续编译和代码生成时,可能会带来更多性能节省。也就是说,treeshaking带来的额外工作量,相比后来能节省的工作量来说,性价比可能更高。

treeShaking 配置方式:
webpack.config.js
            
            
              js
              
              
            
          
          const path = require('path');
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    usedExports: true,
    minimize: true
  }
};
        package.json
            
            
              js
              
              
            
          
          {
  "name": "tree-shaking-demo",
  "version": "1.0.0",
  "sideEffects": false,
  "scripts": {
    "build": "webpack"
  },
  "dependencies": {
    "webpack": "^5.89.0",
    "webpack-cli": "^5.1.4"
  }
}
        4. splitChunk更能发挥持久化缓存的优势
假设我们有一个Webpack配置,有两个入口点:index.js和about.js,并且我们使用SplitChunksPlugin将公共模块提取出来,我们会得到下面Chunk:
- 入口chunk:
index和about,分别包含index.js及其依赖、about.js及其依赖。 - 公共chunk:通过
SplitChunksPlugin提取的公共模块(如utils.js)会形成一个单独的chunk。 
那为什么多打包出一个chunk还能提升webpack打包性能?
这还是要结合webpack的持久化缓存来看。如果我们不借助代码分割,得到的index chunk和about chunk中会同时存在about.js模块的代码。所以无论是index、about还是util的修改都会使得chunkHash改变,使得webpack持久化缓存无效。
如果对utils进行分割,那么对utils的修改,其影响范围仅仅是utils形成的chunk的缓存无效。index和about的缓存还是保住了,这降低重新编译的成本。
下面是一个最简单实用的 webpack.config.js 中关于 splitChunks 的配置示例,它会自动将 node_modules 中的第三方库提取到单独的 vendors 块中:
            
            
              js
              
              
            
          
          module.exports = {
  // 其他配置...
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,
          name: 'vendors',
        }
      }
    }
  }
};
        5. Webpack DLL 动态链接库直接减少工作量
假设项目中用了 react 、lodash,这些静态资源可以被单独打包,且独立于项目。这样每次主项目打包的时候,都可以直接使用过打包好的 react、lodash,节省了性能。
原理:
- 单独一份ddl config文件单独对react、lodash打包,会将打包产物和一个manifest文件放在某个目录下,manifest文件。
 - 打包的产物是以全局变量的方式导出模块的,manifest文件用来告诉使用方react、lodash打包后生成的全局变量叫什么,
 - 使用方在遇到react、lodash时候,就无需再次打包,将使用react和lodash模块的地方,根据manifest文件的指引去通过全局变量访问。
 
简单的例子:
单独开一个dll config文件 webpack.dll.config.js
            
            
              js
              
              
            
          
          const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
  mode: 'production',
  entry: {
     // 将react和lodash以及你想要预打包的静态文件打包成一个名为vendor的bundle
     // 这里没有路径,直接就一个react,就是去打包纯node_modules下的文件
    // 当然我们没有loader,webpack默认只能打包js和json文件。如果这里出现一个css入口文件,必然会发生打包错误。
    vendor: ['react', 'react-dom', 'lodash'] 
  },
  output: {
     // 将生成的bundle生成在dll目录下
    path: path.join(__dirname, 'dll'),
    filename: '[name]_[hash].dll.js',
     // 因为dll对打包生成的模块的导出方式是全局变量的方式,需要通过library告诉webpack应该将模块导出维护在哪个全局变量上。这个全局变量叫做 '[name]_[hash]',实际编译后即 vendor_<hash> 
    library: '[name]_[hash]'
  },
  plugins: [
    new CleanWebpackPlugin(),
     // 使用DllPlugin,这是webpack内置的plugin
    new webpack.DllPlugin({
       // 将生成的bundle生成在dll目录下,并且生成的manifest文件应该是[name]-manifest.json的形式
      path: path.join(__dirname, 'dll', '[name]-manifest.json'),
       // 表明这些模块的导出的全局变量名,必须和 output.library一致
      name: '[name]_[hash]'
    })
  ]
};
        然后是主体项目的webpack文件 webpack.config.js
            
            
              js
              
              
            
          
          const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
     // 应用方必须配合使用 DllReferencePlugin 才能完成工作
    new webpack.DllReferencePlugin({
       // 告诉manifest文件在哪
      manifest: require('./dll/vendor-manifest.json')
    }),
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
     // 需要注意的是,我们需要手动将react、lodash这样被预打包生成的bundle引入在index.html中
     // 可以选择直接手动通过<scripe>标签的方式引入,但这样不如直接使用 AddAssetHtmlPlugin 享受工程化带来的福利,比如 publicPath,以及index.html删除后还能自动引入
    new AddAssetHtmlPlugin({
      filepath: path.resolve(__dirname, 'dll/vendor_*.dll.js'),
      publicPath: './'
    })
  ]
};
        随后可以通过往package.json中加一些npm指令。在发布部署(CICD)时先npm run dll,然后npm run build
打包后的运行时状态:
vendor.dll.js
            
            
              js
              
              
            
          
          var vendor_2e8f1b = (function(modules) {
  // ▼ 模块缓存机制
  var installedModules = {};
  
  // ▼ 模块加载实现 (类似__webpack_require__)
  function __dll_require__(moduleId) {
    if(installedModules[moduleId]) 
      return installedModules[moduleId].exports;
    
    var module = installedModules[moduleId] = {
      id: moduleId,
      loaded: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, exports, __dll_require__);
    module.loaded = true;
    return module.exports;
  }
  
  // ▼ 暴露全局变量
  return __dll_require__(0);
})({
  /*! ▼ 预编译的第三方库模块集合 */
  0: function(module, exports) {
    // React核心实现
    module.exports = window.React = ... // 完整React库代码
  },
  1: function(module, exports) {
    // ReactDOM实现
    module.exports = window.ReactDOM = ... 
  },
  2: function(module, exports) {
    // lodash工具库
    module.exports = window._ = ... 
  }
});
        index.bundle.js
            
            
              js
              
              
            
          
           // 在bundle中动态绑定DLL模块,通过访问直接变量的方式来读取react、lodash。
 // 也就是说打包后的react和lodash的源码将不会出现在 index.bundle.js 中。 
__webpack_require__.d(exports, "lodash", () => vendor_2e8f1b);
        Manifest 文件
            
            
              js
              
              
            
          
          {
  "name": "vendor_2e8f1b",  // 导出的全局变量名
  "content": {  // 提供给使用方的预打包bundle信息
    "./node_modules/react/index.js": {
      "id": 1,  // react的id为1,使用方的 import { useState } from 'react'; 打包后会通过 window.vendor_2e8f1b[1].useState 访问
      "buildMeta": {"providedExports": true}
    },
    "./node_modules/lodash/lodash.js": {
      "id": 2,
      "buildMeta": {"usedExports": ["default"]}
    }
  }
}
        6. external 方案直接减少工作量
和Dll的原理类似,都是将打包工作甩给其他独立项目。具体external的使用方法这里就不介绍了。
要知道DLL不能完全替代external方案,虽然两者具备相同的应用原理。

三. 实践章------实测性能提升95%
1. 使用speed-measure-webpack-plugin量化性能,找到优化方向
想要知道webpack优化方向,可以先量化webpack每个Loader和Plugin的消耗时间,以及各部分Loader处理的模块数量,这样最直观的指标。它也能帮助我们验证成果是否真的"优化"了。
这里需要先使用 speed-measure-webpack-plugin 插件,只需简单几步:
            
            
              bash
              
              
            
          
          npm install speed-measure-webpack-plugin --save-dev
        
            
            
              js
              
              
            
          
          // 1. 引入插件
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
// 2. 实例化插件
const smp = new SpeedMeasurePlugin();
// 3. 定义你原本的 Webpack 配置,这里的示意仅供参考,具体使用方式可以去查插件文档
const webpackConfig = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    path: __dirname + '/dist',
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /.js $ /,
        use: ['babel-loader']
      }
    ]
  },
  plugins: []
};
// 4. 使用 smp.wrap 方法包裹原始配置并导出
module.exports = smp.wrap(webpackConfig);
        下图就是我在自己的项目中应用的情况,技术栈 Taro React,先看一点优化都没有的默认版本:

我们需要重点关注的有四处:
- 各个Loader和Plugin的消耗时间
 - 各个Loader和Plugin的模块处理量
 - 各个Loader和Plugin的执行顺序
 - General output time即整体打包时间
 
从结果上看,我当前项目的指标是:
- babel-loader、sass-loader消耗时间显著,都在11~12s左右;
 - babel-loader的处理module量最大,1000+;css相关module量较少,30+;
 - General output time即整体打包时间:20.92s;
 - 各个Loader和Plugin的执行顺序:**还看不出异常,但这是一个很重要且容易被忽视的点!后面会讲。
 
2. thread-loader启动多进程编译,提升9s,↑ ≈45%
前面理论中说过,过度开启进程可能导致内存开销增大,所以我们得先找给哪个环节用thread-loader。
上面babel-loader编译了1146个模块,而css相关loader只有32个模块,因此给babel-loader用最合适。给css相关loader用可能会得不偿失。
配置后,再次打包编译,结果如下:

不难发现,优化是有问题的,不仅没缩减,反倒整体时间提升了10s...... 现在就需要根据结果来发现问题。
进行问题分析:
- 很明显,babel-loader连续执行了两次,导致对js module的处理上时间翻倍。
 - thread-loader没有在babel-loader之前执行,多进程能力配置了个寂寞...
 
所以,这里就体现出了"各个Loader和Plugin的执行顺序"的重要性,我需要在这个Taro 项目中,找到并删除额外的babel-loader,并想办法调整thread-loader的执行顺序即可。
当下的框架为了便利,大多都内置webpack,仅暴露给用户覆盖内置webpack配置的方式,这就导致我们的优化手段很可能会和内置的webpack配置产生冲突,最后反倒加重了webpack的负担,甚至让打包产物出现错误。我应用的项目使用了Taro,这是典型的内置了webpack的多端框架。
经过一顿探索和修改后,我得到了正确的优化方式。
- 通过webpack chain,删除了Taro的babel-loader,应用自定义的babel-loader
 - 将thread-loader放在use的最前面
 
不同工程不一样,难在需要找到自己的webpack和框架内置的webpack的协调关系和修改方法,下面贴上我的情况处理办法,仅供参考:
            
            
              js
              
              
            
          
          webpackChain(chain) {
  // ......
  chain.module.rules.delete('script'); // 删除Taro中配置的babel-loader
  chain.merge({
    module: {
      rule: {
        myloader: {
          test: /.(js|ts|jsx|tsx) $ /,
          use: [
            {
              loader: 'thread-loader', // 启用多核编译
              options: {
                workers: Math.max(require('os').cpus().length - 1, 8), // 进程数 = CPU 核数 -1
                workerParallelJobs: 100,
                poolTimeout: 2000,
              },
            },
            {
              loader: 'babel-loader',
            },
          ],
        },
      },
    },
  });
   
  // ......
  // 各loader和plugin耗时统计
  chain.plugin('speed').use(new SpeedMeasurePlugin());
},
        最终得到了理想的优化成果, 提升8s,↑ ≈45%

3. 应用webpack持久化缓存 + babel-loader缓存,二次打包,降低至 1.54s,↑ ≈95%
在我的项目中,我的设置方式如下,当然每个项目配置webpack的方式都不同,原生的配置方式可查阅webpack文档cache相关:
webpack持久化缓存配置
            
            
              js
              
              
            
          
            cache: {
    enable: true, // 开启持久化缓存
    type: 'filesystem', // 使用文件系统缓存
    cacheDirectory: path.resolve(__dirname, '../node_modules/.cache/webpack'),
    buildDependencies: {
      // 当这些文件变更时使缓存失效
      config: [path.resolve(__dirname)],
      plugins: [path.resolve(__dirname, '../babel.config.js')],
      env: [
        path.resolve(__dirname, '../.env.development'),
        path.resolve(__dirname, '../.env.preview'),
        path.resolve(__dirname, '../.env.production'),
        path.resolve(__dirname, '../.env.testing'),
      ],
      dependencies: [path.resolve(__dirname, '../package.json')],
    },
  },
        成效极其显著 ,降低至 1.54s,↑ ≈95%

当然,我们的项目会面临频繁修改,基于webpack持久化缓存的特性,chunkHash改变会导致chunk缓存失效,需要对涉及到的整个chunk重新打包。
例如我修改了我项目中的 buycar.js,根据下面chunk分析图来看,涉及的重编译范围就是整个 js/app.4bcd31...js 这个chunk,但实际上改变的只有buycar.js这个module,其他的诸如unstated下的js module都没改变,按理说没必要对整个chunk下的每一个module都重新打包。
因此我们需要通过babel-loader尽可能的将缓存粒度精确到具体的js module。这时候就要用babel-loader进一步兜底优化。
 babel-loader持久化缓存配置
            
            
              js
              
              
            
          
          //...
{
  loader: 'babel-loader',
  options: {
    cacheDirectory: true, // 开启babel-loader缓存
  },
}
//...
        一路很长~感谢你能阅读到这儿,如有错误请指点!
End.