16

前言

之前接手公司一个前端项目,开发了几个月后越来越难以忍受项目结构的混乱和打包体积的臃肿(脚手架和基本功能代码都是从公司的其他项目复制过来的),如果不立即进行重构,难以想象以后要怎么维护各个产品线。于是我自告奋勇承担了项目框架的优化任务,这里分享一下我在打包体积优化中所研究的成果,经过几轮的努力,成功的将我们这个 react+antd+immutable+rxjs的较大项目从打包后的9MB降低到了2.5MB,首屏加载(gzip)从600KB+降低到了200KB,并且基本上将稳定的第三方库,webpack runtime代码和业务代码完全分离,最低限度减少网站更新时用户需要加载的代码量。
废话不多说,下面详细说明我所做的每一个步骤。

1. 优化第三方库

项目里对库的使用较为混乱,有些库安装了但很少用或者根本没用,但是又在webpack中的vendor入口指定打包了进来,造成体积上的浪费,所以需要仔细评估每个库是否必要安装。
react v16对比react v15,加上react-dom,体积上降低了30%,因此果断升级。

2. moment.js

分析完stats.json后,发现的第一个问题就是moment很大,具体原因webpack是把所有的locale文件打包了进来。我们的项目不需要多语言,因此我们可以使用ContextReplacementPlugin插件来舍弃中文之外的其他语言文件:

new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn/)

去除掉locale后,又发现了另一个问题:依赖分析显示,我的项目里打包了两份moment,一份es module版的,一份umd版的。经过一番排查后发现,使用import 'moment'导入的会加载es module版的,这是webpack配置的mainFields决定的,但是在locale中的语言文件中,它会用相对路径导入umd版的moment,这就导致我的项目里出现了两份moment。为了统一版本,我们将moment设置为一个别名并指向umd版:

alias: {
  'moment$': path.resolve('node_modules/moment/moment'),
},
// $表示绝对匹配

另外还有一个库dayjs值得一提,其API基本与moment一致,但是体积仅为几KB,不知道antd会不会加入对dayjs的支持。

3. ECharts

项目之前是直接使用的完整版的echarts,并且没有将echarts组件抽取为公共chunk,结果导致每个异步加载的页面组件,只要用了echarts就会变得硕大无比。
解决方案:在echarts官网定制一份仅包含项目所需图表类型的阉割版,并且将echarts组件抽取为异步加载的chunk,这样就只需要加载一次。
关于如何将组件抽取为单独的chunk,可以用import()语法,或者使用react-loadable这个库,它可以直接将react组件包装成异步组件,并在需要时才进行加载。

4. 抽取异步加载的chunk中的公共代码

上面的步骤抽取echarts就是指的抽取异步chunk中的公共代码,除了echarts之外还有很多大体积的公共代码,例如各种antd的组件以及其依赖的底层组件rc-components,这部分也是我们要提取出来的。我们没必要将每个antd组件包装为异步组件,这里只需要配置一下CommonsChunkPlugin就可以了:

new webpack.optimize.CommonsChunkPlugin({
  async: 'async-vendor',
  deepChildren: true,
  minChunks: (module) => {
    return /node_modules/.test(module.context);
  },
}),

在没有将children设为true时,CommonsChunkPlugin会从入口文件(entry)提取公共代码,这时就不会对异步加载的chunk起作用。因此为了提取异步chunk的公共代码,我们设置deepChildrentruechildren指的是入口文件的直接子节点,deepChildren指的是全部子节点)。async表示生成一个懒加载的chunk,只有当需要时才会被加载。
上面只是将第三方库的公共代码提取了出来,如果希望把异步chunk当中自己的业务代码提取出来,则可以修改minChunks规则,或者再增加一个配置:

    new webpack.optimize.CommonsChunkPlugin({
      async: 'async-biz',
      deepChildren: true,
      minChunks: 2,
    }),

5. 并非每个路由页面组件都需要异步加载

项目之前的做法是,每个路由对应的页面根组件都需要异步加载,这样做的结果是打包出了很多个chunk,而有一半的chunkgzip之前体积都不足5KB,浪费请求是一方面,更严重的是影响了首屏加载体积。
这是为什么?明明把每个页面都异步加载了,怎么会影响首屏体积呢?其实原因就是第三步中的async-vendor被首屏加载了,该chunk主要包含了antd组件,gzip之后约为120KB
对于用户来说,第一次打开我们的网站一定是到登录界面,此时需要完全加载我们的首屏代码,之后有了缓存,除了业务代码更新需要加载很小的chunk之外,理论上是不需要再下载任何代码的,因此我们需要针对登录界面进行首屏优化。
登录界面包含了登录、修改密码、申请账号等子路由,之前将这些都打包为异步chunk,由于这些界面需要async-vendor当中的某几个antd组件,因此首屏加载一定会包含async-vendor。拆分async-vendor是一种办法,但是还要分析到底用了哪些组件,改动业务代码后又要重新分析,显得很麻烦,最简单的做法就是取消登录相关路由的异步加载,将其打包到main当中,同时只需加载需要的antd组件,因此完全避免了加载async-vendor,首屏体积得到了大大降低。

6. 分离出webpack runtime代码

webpack在客户端运行时会首先加载webpack相关的代码,例如require函数等,这部分代码会随着每次修改业务代码后发生变化,原因是这里面会包含chunk id等容易变化的信息。如果不抽取出来将会被打包在vendor当中,导致vendor每次都要被用户重新加载,vendor也失去了它的意义。分离的配置很简单:

    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity,
    }),

minChunks: Infinity表示创建一个什么都没有的chunk,因为不会有任何模块被无限次引用过,这样webpack runtime代码就会被CommonsChunkPlugin放入这个最后的chunk当中。

7. webpack内部优化

这部分内容很简单,就两个插件的使用,HashedModuleIdsPluginModuleConcatenationPlugin
默认情况下,webpack会为每个模块用数字做为ID,这样会导致同一个模块在添加删除其他模块后,ID会发生变化,不利于缓存。为了解决这个问题,有两种选择:NamedModulesPluginHashedModuleIdsPlugin,前者会用模块的文件路径作为模块名,后者会对路径进行md5处理,降低了文件体积,相比较而言,应该开发时选择前者,生产环境选择后者。
ModuleConcatenationPlugin主要是作用域提升,将所有模块放在同一个作用域当中,一方面能提高运行速度,另一方面也能降低文件体积。前提是你的代码是用es模块写的。

8. babel-polyfill

polyfill也是体积很大的一部分,但是又不得不加载,关于这部分的优化可以参考这篇文章,ES6和Babel你不知道的事儿。还有一种方法是使用polyfill.io,这个解决思路个人觉得很不错,但是还不敢在生产环境用,先观望观望。

总结

以上内容是我这些天找资料研究的结果,总的来说打包体积算是得到了有效控制,关于chunk的打包配置如下:

entry: {
    main: path.join(process.cwd(), 'src/index.js'),
    vendor: [
      'babel-polyfill', 'immutable', 'moment', 'react', 'react-dom' ...
    ],
},
output: {
    filename: '[name].[chunkhash].js',
    chunkFilename: '[name].[chunkhash].chunk.js',
},
plugins: [
    new webpack.HashedModuleIdsPlugin(),
    
    new webpack.optimize.ModuleConcatenationPlugin(),
    
    new webpack.optimize.CommonsChunkPlugin({
      async: 'async-vendor',
      deepChildren: true,
      minChunks: (module) => {
        return /node_modules/.test(module.context);
      },
    }),
    
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
    }),
    
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity,
    }),
]

webpack 4已经出了,再也没有CommonsChunkPlugin了,取而代之的是SplitChunksPlugin,看来又要研究新的东西了。。。

参考文章:
CommonsChunkPlugin学习小结


TonyZhu
764 声望56 粉丝

蚂蚁金服大安全风控+团队招人,前端后台都要,简历发送至huitong.zht@antfin.com