csm0 2019-06-27
之前接手公司一个前端项目,开发了几个月后越来越难以忍受项目结构的混乱和打包体积的臃肿(脚手架和基本功能代码都是从公司的其他项目复制过来的),如果不立即进行重构,难以想象以后要怎么维护各个产品线。于是我自告奋勇承担了项目框架的优化任务,这里分享一下我在打包体积优化中所研究的成果,经过几轮的努力,成功的将我们这个 react
+antd
+immutable
+rxjs
的较大项目从打包后的9MB
降低到了2.5MB
,首屏加载(gzip)从600KB+
降低到了200KB
,并且基本上将稳定的第三方库,webpack runtime
代码和业务代码完全分离,最低限度减少网站更新时用户需要加载的代码量。
废话不多说,下面详细说明我所做的每一个步骤。
项目里对库的使用较为混乱,有些库安装了但很少用或者根本没用,但是又在webpack
中的vendor
入口指定打包了进来,造成体积上的浪费,所以需要仔细评估每个库是否必要安装。react v16
对比react v15
,加上react-dom
,体积上降低了30%,因此果断升级。
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
的支持。
ECharts
项目之前是直接使用的完整版的ECharts
,并且没有将ECharts
组件抽取为公共chunk
,结果导致每个异步加载的页面组件,只要用了ECharts
就会变得硕大无比。
解决方案:在ECharts
官网定制一份仅包含项目所需图表类型的阉割版,并且将ECharts
组件抽取为异步加载的chunk
,这样就只需要加载一次。
关于如何将组件抽取为单独的chunk
,可以用import()
语法,或者使用react-loadable
这个库,它可以直接将react
组件包装成异步组件,并在需要时才进行加载。
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
的公共代码,我们设置deepChildren
为true
(children
指的是入口文件的直接子节点,deepChildren
指的是全部子节点)。async
表示生成一个懒加载的chunk
,只有当需要时才会被加载。
上面只是将第三方库的公共代码提取了出来,如果希望把异步chunk
当中自己的业务代码提取出来,则可以修改minChunks
规则,或者再增加一个配置:
new webpack.optimize.CommonsChunkPlugin({ async: 'async-biz', deepChildren: true, minChunks: 2, }),
项目之前的做法是,每个路由对应的页面根组件都需要异步加载,这样做的结果是打包出了很多个chunk
,而有一半的chunk
在gzip
之前体积都不足5KB
,浪费请求是一方面,更严重的是影响了首屏加载体积。
这是为什么?明明把每个页面都异步加载了,怎么会影响首屏体积呢?其实原因就是第三步中的async-vendor
被首屏加载了,该chunk
主要包含了antd
组件,gzip
之后约为120KB
。
对于用户来说,第一次打开我们的网站一定是到登录界面,此时需要完全加载我们的首屏代码,之后有了缓存,除了业务代码更新需要加载很小的chunk
之外,理论上是不需要再下载任何代码的,因此我们需要针对登录界面进行首屏优化。
登录界面包含了登录、修改密码、申请账号等子路由,之前将这些都打包为异步chunk
,由于这些界面需要async-vendor
当中的某几个antd
组件,因此首屏加载一定会包含async-vendor
。拆分async-vendor
是一种办法,但是还要分析到底用了哪些组件,改动业务代码后又要重新分析,显得很麻烦,最简单的做法就是取消登录相关路由的异步加载,将其打包到main
当中,同时只需加载需要的antd
组件,因此完全避免了加载async-vendor
,首屏体积得到了大大降低。
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
当中。
webpack
内部优化这部分内容很简单,就两个插件的使用,HashedModuleIdsPlugin
和ModuleConcatenationPlugin
。
默认情况下,webpack
会为每个模块用数字做为ID
,这样会导致同一个模块在添加删除其他模块后,ID
会发生变化,不利于缓存。为了解决这个问题,有两种选择:NamedModulesPlugin
和HashedModuleIdsPlugin
,前者会用模块的文件路径作为模块名,后者会对路径进行md5
处理,降低了文件体积,相比较而言,应该开发时选择前者,生产环境选择后者。ModuleConcatenationPlugin
主要是作用域提升,将所有模块放在同一个作用域当中,一方面能提高运行速度,另一方面也能降低文件体积。前提是你的代码是用es
模块写的。
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
,看来又要研究新的东西了。。。