最近一直在看umi
打包相关的优化,踩了好多坑,总算有了一点小小的心得,在此分享一下,希望能帮到有类似需要的朋友们。
我做的优化主要分成两个方面:
- 优化打包体积,即减少打包产物的大小,减少用户需要加载的文件体积,提升网站的加载速度
- 提升缓存效率,用户在首次访问网站之后,浏览器会缓存加载过的
js
、css
等资源文件。我们发布新版本时,应该尽可能少地更新文件,以最大程度地利用用户浏览器本地的缓存。降低用户二次或多次访问网站的成本。
打包产物分析
在开始优化之前,我们首先要分析一下现有的打包产物,看看有哪些问题,再考虑如何解决、优化它们。
umi
项目已经配置好了分析所用的插件 (webpack-bundle-analyzer),直接运行npm run analyze
命令即可。
运行完成之后会自动打开打包产物分析页面。这是我的项目优化之前的打包结果,目前所有打包产物体积一共是4.23mb。
接下来,我们就一起分析一下,目前打包存在的问题,以及如何优化它们。
第三方依赖被重复打包
以qrcode
这个依赖为例,我们在打包结果中搜索它,可以看到有不止一个页面文件都包含了它。说明这个依赖被重复打包到多个文件里了。
如果你没有对umi
中的配置进行过任何更改,完全使用默认的配置,umi
会把每个页面中用到的依赖各自打包到对应页面的打包产物中。这使得如果在不同页面间使用了相同的依赖,会导致这个依赖被重复打包多次。
一个依赖被重复打包显然会造成打包产物的冗余,其实理想情况中,我们希望这些依赖只被打包一次,放到一个公共的文件中,需要使用它的页面都去引用这个文件就好了。
为了实现这种理想情况,我们需要配置中webpack中的optimization.splitChunks
,umi
中修改webpack配置需要通过chainWebpack
,具体配置如下:
javascript
{
//...other umi config
chainWebpack: function (config, { webpack }) {
config.merge({
minimize: true,
optimization: { //webpack配置
splitChunks: {
chunks: 'all',
minSize: 20000,
minChunks: 2,
cacheGroups: {
vendors: {
name: 'vendors',
test({ resource }) {
return /[\\/]node_modules[\\/]/.test(resource);
},
priority: 10,
},
},
},
},
});
}
这段配置的关键在于optimization.splitChunks.cacheGroups.vendors
,它的作用是告诉webpack,只要是在/node_modules
目录下,被引用次数大于等于2次的模块,全都打包到一个叫vendors的文件中,而不是像之前那样,被打包到不同的页面文件中。
这样我们就实现了把公共依赖提取到一个单独的文件,以供其他页面引用的目的。
之后,不要忘了在chunks
配置中加上我们刚刚抽离出来的vendors 文件。chunks
代表初始加载时需要加载的文件。vendors包含了所有的第三方库,所以初始加载时肯定是需要的。
javascript
{
//...other umi config
chunks: ['vendors', 'umi'],
}
现在我们再来npm run analyze
一下, 看看打包产物优化的效果:
可以看到,qrcode
这个依赖只被打包了一次,被放到vendors.js 中。并且打包产物的总体积也从4.23mb降低到了3.48mb。这一部分体积的减少,实际就是之前被重复打包的依赖。比如还有echarts
等等。
按需引入echarts
在React项目中使用echarts,我自己用的是echarts-for-react
这个库。之前的引入方式是这样的:
javascript
import ReactECharts from 'echarts-for-react';
//...
<ReactECharts option={option} />
这样的引入方式,使用是没问题,但是会造成打包产物的冗余。因为echarts-for-react
不支持tree-shaking。按上面的方式引入,即使我们只使用了其中的一两种图表类型,比如我的项目里只用到了折线图和柱状图,也会把整个echarts-for-react
(包含了整个echarts)都打包。
可以看到,vendors.js 的体积为559kb,其中echarts-for-react
的体积占了其中的大部分。
我们可以通过按需引入的方式,只import
我们需要的部分组件,来避免打包其他的、并不会用到的echarts代码。以降低打包产物的体积。
比如像下面这样:
javascript
import ReactEChartsCore from 'echarts-for-react/lib/core';
import { BarChart } from 'echarts/charts'; //只使用柱状图
import { GridComponent, TooltipComponent, LegendComponent, LegendScrollComponent } from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([LegendComponent, LegendScrollComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer]);
//...
<ReactEChartsCore echarts={echarts} option={option} />
将echarts-for-react
的使用都改成按需引入的方式以后,再打包:
可以看到vendors.js 的体积从559kb降低到了422kb,这减少的体积就是echarts-for-react
中多余的文件体积。
减少首屏需要加载的文件体积
除了打包产物总的体积以外,我们还需要关注的是用户打开网站,加载第一个页面所需要下载的文件体积。除了相应的页面文件以外,vendors.js 和umi.js ,是用户不管打开哪个页面,都需要下载的文件。我们可以尝试一下继续降低vendors.js的体积。
我们查看vendors.js 的组成,可以看到,echarts-for-react
/ zrender
/ brace
/ react-json-view
这些第三方库占了其中的绝大部分体积。这些库其实在加载大部分页面的时候都不需要用到,所以没有必要放到vendors.js中。
我们需要把它们从vendors.js中拆分出来,拆成单独的文件,用户访问到使用这些库的页面时,再去加载它们。
至于配置,又要用到optimization.splitChunks.cacheGroups
。这次,我们把配置换成下面这样子:
javascript
{
//...other umi config
chunks: ['vendors', 'umi'],
chainWebpack: function (config, { webpack }) {
config.merge({
optimization: {
minimize: true,
concatenateModules: true,
splitChunks: {
chunks: 'all',
minSize: 20000,
minChunks: 2,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name(module) {
const reg = /(echarts|zrender|brace|react-json-view)/;
if (reg.test(module.identifier())) {
const [chunkName] = reg.exec(module.identifier()) || [];
return `npm.${chunkName}`;
};
return 'vendors';
},
priority: 10,
},
},
},
},
});
},
}
再次打包,查看打包结果:
可以看到,echarts-for-react
/ zrender
/ brace
/ react-json-view
这些第三方库全都被抽离到了单独的文件中。vendors.js的体积也降低到了62kb。
这里请注意 :虽然vendors.js的体积变小了很多,但是打包产物的总体积基本是没变的,因为我们这里只是拆包,把原本放在一起的依赖拆分成多个,对总体积的影响是很小很小的。
webpack 5打包
umi v3中默认使用webapck 4打包,如果开启配置项webpack5
,才会使用webpack 5打包,并且默认开启webpack 5中cache特性,对提升打包速度也很有帮助。
javascript
{
//...other umi config
webpack5: {}
}
让我有点惊喜的是,完全相同的配置,开启webpack5
之后,打包产物总的体积有明显的变小。
优化到现在,开启webpack5
之前,打包产物总的体积为3.44mb;开启之后,总体积为2.95mb。有明显的降低。
到目前为止,我们可以发现,虽然vendors.js 的体积得到了明显的优化。但是umi.js的体积似乎没什么变化?依然有着1.11mb的大小。
在优化过程中,我发现umi.js 中的第三方依赖,即/node_modules
目录下的模块,通过optimization.splitChunks
是拆不出来的,给官方提了discussion,也没有得到支持的答复。
umi.js 中包含了多个几乎不更新的第三方依赖,但是当我们更改代码,重新打包,umi.js 的hash值经常会变化。这就导致了一旦我们发布新版本,用户访问网站,需要重新下载umi.js里这些其实并没有发生更新的依赖。
这对于缓存效率是很不利的。umi.js 包含了一些体积很大,项目必需,又很少更新的第三方库,比如:antd
/ moment
/ react
/ react-dom
/ xlsx
(用于导出excel文件)。对于这些库,我们应该考虑把它们拆分出来,并且在用户首次访问之后,尽可能地通过缓存加载,而不是需要频繁地下载它们。
externals
既然通过optimization.splitChunks
拆分不出来,那么可以考虑另一种方式:externals。
externals
用于将一些第三方库排除出打包过程,不出现在最终的打包结果中。再通过其他方式引入它们,比如<script>标签引入。
externals
的值是一个对象,其中key
的值是第三方库的名字,value
的值是通过<script>标签引入的全局变量的名字。
我们把antd
/ moment
/ react
/ react-dom
/ xlsx
全部配置到externals
中,并通过cdn引入它们。这样,就可以实现把它们排除出umi.js。每次更新、发布新版本,如果用户之前访问过,有缓存,就不需要重新下载它们了。
具体配置如下:
javascript
import * as antd from 'antd';
import _ from 'lodash';
let externalsAntd = {};
for (const key in antd) {
if (Object.hasOwnProperty.call(antd, key)) {
externalsAntd[`antd/es/${_.kebabCase(key)}`] = `antd.${key}`;
}
};
const config = {
//...other umi config
externals: {
react: 'React',
moment: 'moment',
xlsx: 'XLSX',
'react-dom': 'ReactDOM',
'moment/locale/zh-cn': 'moment.locale',
...externalsAntd,
},
styles: ['https://cdnjs.cloudflare.com/ajax/libs/antd/4.23.6/antd.min.css'],
scripts: [
'https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/antd/4.23.6/antd.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/locale/zh-cn.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.5/xlsx.full.min.js',
],
};
可以看到moment
和antd
的配置比较特殊:
moment
是因为<DatePicker />
组件里引入了moment/locale/zh-cn
,如果不配置它,整个moment
还是会被打包。详情请见:ant-design issue #4136。
antd
是因为umi
内部使用了babel-plugin-import
,所以引入的不是antd
,而是antd/es/xxx
。详情请见:umi issue #4542
再次打包,查看打包结果:
可以看到,umi.js 中已经没有了antd
/ moment
/ react
/ react-dom
/ xlsx
等依赖,体积也减少到了458kb。
这里请注意 :虽然umi.js 的体积有了明显地降低,但是实际上用户首次访问网站需要加载的文件体积不会减少,甚至还有可能会增加。这是因为externals
配置的第三方库,通过cdn引入,是不经过webpack打包的,自然就不支持webpack的tree-shaking。 所以对于那些可以通过optimization.splitChunks
单独拆分出来的、支持tree-shaking的依赖,就不建议配置到externals
了。
即使首次访问加载的文件体积没有减少,但通过cdn引入的依赖,我们之后每次发布新版本,用户都无需再重新下载依赖文件。这是我们配置externals
的目的所在。
runtimeChunk
我们以Tracking_detail
这个页面为例,如果修改了这个页面的代码,重新打包。会发现umi.js 和Tracking_detail.js 的hash值都会改变。这就导致,我们虽然只修改了一个页面的代码,却需要重新下载umi.js + Tracking_detail.js。
这是因为默认情况下,umi.js 包含整个应用的runtime和manifest,runtime和manifest
包含了打包后的带有hash值的文件名。所以只要有一个文件的hash值改变,都会导致umi.js 的内容及其hash值的更新,进而导致用户缓存的umi.js失效,需要重新下载。
如果我们可以把runtime和manifest
抽离成单独的文件,每次更改单个页面的代码,就只需要重新下载对应页面的js文件 + 小小的runtime.js ,并使用缓存的umi.js了。
抽离runtime和manifest
需要用到webpack的optimization.runtimeChunk
,具体配置如下:
javascript
{
//...other umi config
chunks: ['runtime~umi', 'vendors', 'umi'], //添加'runtime~umi'
chainWebpack: function (config, { webpack }) {
config.merge({
optimization: {
runtimeChunk: true, //添加runtimeChunk
splitChunks: {
//...splitChunks config
},
},
});
},
};
添加完runtimeChunk
之后,我们先打包一次,可以看到会生成一个单独的runtime~umi.js 文件,我们记住此时umi.js 、Tracking_detail.js 和runtime~umi.js的hash值:
修改Tracking_detail
代码并重新打包:
可以看到,umi.js 的hash值没变,Tracking_detail.js 和runtime~umi.js 的hash值有更新,成功地避免了umi.js的缓存失效。
量化缓存效率的提升
做了这些优化,我也假设一个场景,简单算一下,这些优化对缓存真正的提升有多少。
浅算一下哈,文件的大小都是以我自己的这个项目为例。
假设:我们的网站只有Tracking_detail
一个页面,用户每周访问一次,我们也每周更新一次Tracking_detail
并发布新版本。用户连续五周访问。我们看一下,配置externals
和runtimeChunk
前后,用户加载所需文件体积的变化:
配置前,五周加载文件体积总计:6.67mb:
配置后,五周加载文件体积总计:1.63mb:
在这个场景下,优化后,加载文件体积从6.67mb降低到1.63mb,降低了74%。(回头周报就这么写✌)