R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

Webpack 打包体积优化:没有银弹

关于 Webpack 打包体积优化的问题,网上的文章已经有很多了,大概的方法有:webpack-bundle-analyzersource-map-explorer)、CommonsChunkPlugin(v4.x 之后变成了 splitChunks 选项)、按需引入用到的库、Tree Shaking、CDN 等。之前我用这些方法对自己负责的某个项目做了一次优化,使得总体积不到 600 KB(after gzip),由于包含了 antdreact,外加有几十个页面(有的页面相当复杂,即使抽离了公共逻辑、封装了一些组件也没能减少太多体积),所以这个大小是可以理解的。但是前几天,我突然发现项目 build 之后光是 vendor 居然已经有 790 KB(after gzip)了!这半年的业务代码增加不可能有那么多,项目中也不可能新引入那么大的库,因此肯定哪里出了问题。于是,我再一次担起了“Webpack 工程师”的职责。

我负责的项目是用微软的 TypeScript-React-Starter 生成的,使用了 react-app-rewired 来覆盖默认的 Webpack 配置。

使用 source-map-explorer

之前在 config-overrides.js 中实现了一些功能:

  • 分离 mainvendor
  • 优先引入 ES Module(config.resolve.mainFields
  • antd 的按需引入

所以为什么还是那么大呢?于是我用 source-map-explorer 来看了一下,发现了一些问题:

  • 另外两个系统的代码被打进了 vendor
  • antd 的按需引入似乎失效了
  • ramda 没有做按需引入

“另外两个系统”,指的是我们大团队的两个新系统,由于共用一套前端和权限控制,因此前端是以一个 Package 的方式被我的项目引入。Starter 是我提供的,可以打包成 NPM Package(ES Module)被我引入,也可以直接作为一个独立的前端项目被打包部署(为了应对以后人家不挂在我这儿的情况)。

antd 的按需引入似乎不光是失效了,而且反而比没有按需引入还要大——图中显示它不光按需引入了 es,还完整引入了 libantd 有三种打包后的代码,dist 就是一般库都会有的“完整版”代码,lib 则是一个个被打包成 CommonJS 的模块,es 则是一个个被打包成 ES Module 的模块。由于 Webpack Tree Shaking 仅支持 ES Module 的语法,因此我们需要引入 es 中的代码。

分离另外两个系统

因为我对 Webpack 的文档还是不熟悉,所以试了好久,做法是这样(TypeScript-React-Starter 用的是 [email protected]):

// index.tsx
declare var __ENV__: string;

// 之所以用 require 而不是 ES Module Import,是因为这两个库对于我们而言是不需要知道的
// 这两个库在不同环境下版本不同,因此无法保存至 package.json,否则不同环境代码就不一致了
// 于是只能在 CI 的时候现场安装,我还写了个 API 来查询某个库在某个环境下当前的版本号:
// yarn add --registry=https://our.private.npm.registry \
//   [email protected]`curl -s ${PREFIX}/dependencyVersion/a-frontend` \
//   [email protected]`curl -s ${PREFIX}/dependencyVersion/b-frontend`
if (__ENV__ !== 'dev') {
  require('a-frontend');
  require('b-frontend');
}
// config-overrides.js
const commonChunksPlugin = deps => [
  ...deps.map(name => new webpack.optimize.CommonsChunkPlugin({
    name,
    filename: `static/js/${name}.[hash:8].js`,
    chunks: [name],
  })),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    filename: 'static/js/vendor.[hash:8].js',
    minChunks: module => (
      module.context
        && module.context.includes('node_modules')
        && deps.every(name => !module.context.includes(name))
    ),
  }),
];
config.entry = {
  main: 'src/index.tsx',
  'a-frontend': 'a-frontend',
  'b-frontend': 'b-frontend',
};
config.plugins.push(
  ...commonChunksPlugin(['a-frontend', 'b-frontend']),
);

中间踩过一些坑,例如我尝试在 index.tsx 中使用 import('xxx') 而非 require('xxx') 来使得 Webpack 把它打成单独一个包,但这两个项目都用了 ramda 等一些库,这些库会被分别强行打到两个 Chunk 中,即使用了 children: true 也无济于事;以及由于没有设置 config.entry 而导致 CommonsChunkPlugin 报错 ERROR in CommonsChunkPlugin: While running in normal mode it's not allowed to use a non-entry chunk (a-frontend)

最后的打出的包应该有 vendormaina-frontendb-frontend 这四个(额外多了一个 vendor.css,但这是在意料之中的)。

解决 antd 按需引入的问题

source-map-explorer 没办法查看某个包究竟是被谁引入的,但这难不倒我,因为在未压缩的 bundle 代码中是有完整的注释的,里面包含了模块名和 Import / Export 的模块,因此我直接 yarn start 之后,在开发环境构建的代码中查找 antd/lib,很容易就定位到了我们自己的大文件分片上传工具、那两个新系统,以及新系统的前端团队写的 Common Library,这四个库都对 antd/lib 有引用,而且是全量加载。

由于这些对于我的项目来说都属于 node_modules 的内容,我引入的都是构建好的包,而不是 TypeScript 文件(antd 的按需引入是在 ts-loader 中做的),因此只能给负责的同学提一下,让他们在这些库中做按需引入,并且必须将 libraryDirectory 设置为 es

对于那两个新项目,由于也用了 react-app-rewired,因此只需要复制一份我的配置即可:

const tsLoader = getLoader(
  config.module.rules,
  rule => rule.loader && typeof rule.loader === 'string' && rule.loader.includes('ts-loader'),
);
tsLoader.options = {
  getCustomTransformers: () => ({
    before: [
      tsImportPluginFactory({ libraryName: 'antd', libraryDirectory: 'es', style: true }),
    ],
  }),
};

大文件分片上传工具默认的 build:lib Script 就是 tsc,我只需要加一下 babel 的调用就好,.babelrc 的内容就是:

{
  "plugins": [
    [
      "import",
      {
        "libraryName": "antd",
        "libraryDirectory": "es",
        "style": true
      }
    ],
  ]
}

Common Library 就比较蛋疼了,这个库用了一个叫 microbundle 的打包工具,然而这个工具不支持 .babelrc(可以查看这个 Issue)!在对方不打算换成其它打包工具的情况下,最简单的方法就是在我这儿加一个 babel-loader 并且强行转换 Common Library:

const babelLoader = getBabelLoader(config);
// 修改 include 强行让 babel 处理 node_modules 里面的 Common Library 库
babelLoader.include = [
  // 这个值一开始是 string,而不是数组,所以可以不用 spread 运算符
  babelLoader.include,
  path.resolve(__dirname, 'node_modules', 'my-private-common-lib'),
];
babelLoader.options = {
  ...babelLoader.options,
  plugins: [
    ['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }],
  ],
};

然而我发现还是不行,于是只能去看 Common Library 的代码,发现里面的某个文件居然全量引入了 antd 的 CSS!这哪行,于是我再给负责的同学说了一声。

随着那一行被删,antd 被引入的无用部分终于全部消失了。最终的打包结果如下:

541.2 KB  build\static\js\vendor.ac56d0e1.js
72.18 KB  build\static\js\main.ac0c1c7f.js
44.31 KB  build\static\css\vendor.c5364785.css
11.77 KB  build\static\js\a-frontend.ac56d0e1.js
10.29 KB  build\static\js\b-frontend.ac56d0e1.js

除了 vendor 以外完全都是业务代码,vendor 中的库也都是按需引入过的,可以说是比较完美了。

其它工具

可能是因为我比较喜欢看打包后的代码,而且效率也不是那么低,因此我在查找依赖关系的时候直接从未压缩的代码中搜索了(好比以前我在大型项目中 Find all references 都是用的全局搜索)。实际上,查看依赖关系有一些现成的工具,例如 Webpack Analyzer,可以可视化 Webpack 打包时生成的统计数据,包括模块、Chunk、静态文件、Warnings 等详细信息。

对于 react-app-rewired 来说,生成 stat.json 的方法是:

const { StatsWriterPlugin } = require('webpack-stats-plugin');

config.plugins.push(new StatsWriterPlugin({
  // `stats.json` 是默认文件名,文件会被放到 output 目录下
  filename: 'stats.json',
  // 生成所有的信息,如果不设置这行则只有 `assetsByChunkName` 信息
  fields: null,
}));

没有银弹

随着前端的复杂度日渐提高,前端包体积也会越来越大。对于如何减小包体积,网上有很多文章,虽然很多文章最后都会附上一句“优化没有银弹”,但直到今天我才真正感受到这句话的重要性。“往配置文件里复制粘贴插件代码”这种通用的优化方法已经不适用了,需要深入到底层,了解 Webpack 和那些优化插件的工作原理,甚至需要去查看其它项目的代码,才能得出有针对性的解决方案。

这篇文章只是我优化的一个过程,重点在于思路,其中具体的代码可能对其它的项目完全不适用。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1426 天