公司项目的前端当初选择了Ant Design Pro
脚手架,好用到谁用谁知道。但是当初基于2.0版本搭建的,现在Ant Design Pro
已经切换到umi
框架了,要对项目做升级,难度削微大了些。但是,随着这几年的搬砖积累,项目从最初打包出来2MB到现在的10+MB,而且裸奔部署在tomcat
上,没有CDN
,没有gzip
。首屏加载越来越慢,客户埋怨的声音越来越多。
于是乎,忍不住花了好几天的时间,做了彻底的优化。
第一步,熟悉相关的打包优化方案
阅读了文章https://webpack.wuhaolin.cn/
后获益匪浅,建议没有webpack
优化经验的童鞋可以先仔细的阅读。同时,在对自己的项目改造的时候可以参考。
总结一下,webpack
常见的优化策略如下:
- 利用
BundleAnalyzerPlugin
分析依赖的包 - 开启
gzip
压缩,这个需要服务器做配置即可,能降低50%
左右的包体积 - 将部分引用的外部依赖库分离为
CDN
静态资源引入的方式 - 打包过滤
moment
的语言包 - 分离依赖包(可以将整个
node_modules
)到vendor.js
文件
另外,提升打包效率也是必要的一件事。因为不管是开发环境还是生产环境,尽可能的节约打包等待的时间会提升我们的工作效率。特别是在开发和热更新时,打包编译速度就会有很大的影响。打包效率的常见策略如下:
- 开启多线程打包
- 过滤部分非必要的且耗时的
webpack
插件 - 升级代码压缩的插件,并开启缓存
- 针对开发和生产配置不同的
webpack
插件 - 利用
SpeedMeasurePlugin
插件分析耗时的打包环节
第二步,创建自定义的webpack
配置
Ant Design Pro
默认使用的时roadhog
框架github,roadhog
本身封装了webpack
相关的能力,同时给开发者提供了很遍历的配置项,同步这些配置我们可以实现如本地代理转发、分包加载等功能。但这些配置项还不够灵活,想要做好我们的优化,需要我们用另一种方式来配置我们的webpack
。
roadhog
到底为我们做了什么呢?查看源码,可以很清楚的看到它做的一切:github.com/umijs/umi/b…
自建webpack
配置文件
在根目录创建文件webpack.config.js
,该文件是webpack
默认读取的配置文件。内容编写如下:
export default webpackConfig => {
return webpackConfig;
}
复制代码
这里我们导出了一个函数,这个函数的参数即为已有的webpack
配置,该配置会来自roadhog
配置的信息,因此,我们可以在我们的配置项中修改已有的webpack
配置信息。忽略编译时控制台输出的警告:
到此,我们就有了我们修改webpack
打包的入口。读者可以自行打印log在函数中,查看相应的输出。
添加监控插件
一般我们要分析打包体积和打包时间消耗时,会选择BundleAnalyzerPlugin
插件和SpeedMeasurePlugin
插件。两者的用法也比较简单,如下:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
export default webpackConfig => {
// 打包资源分析
webpackConfig.plugins.push(
new BundleAnalyzerPlugin()
);
// 打包速度监控
return smp.wrap(webpackConfig);
}
复制代码
- 添加了
BundleAnalyzerPlugin
插件后,打包完成会立即自动打开127.0.0.0:8888
网页页面,该页面显示了整个项目依赖包和源文件的体积大小。可以根据此来判断依赖包的大小以及是否有重复依赖包(重复的依赖包可以在package.json
中统一声明解决)。(TIPS:roadhog
本身也提供了该插件,只需要在打包命令中配置ANALYZE=true
环境变量即可,如:cross-env ANALYZE=true roadhog build
,笔者这里为了加深插件的使用控制,因此在自己的配置中重新配置了一遍)。
图中可以看出,node_modules
目录下的文件大小几乎占了一大半整个打包体积。同时有几个依赖库比较大,如antd
、xlsx
、bizcharts
等。
- 添加了
SpeedMeasurePlugin
插件后,打包完成会在控制台输出如下信息:
图中可以看出,部分插件如CaseSensitivePathsPlugin
、IgnorePlugin
等耗时较大,超过1分钟,同时style-loader
和babel-loader
耗时也较大。这都是我们可以在自定义配置中优化的项目。
第三步,【优化】减小打包体积
根据一开始提到的几个压缩打包体积的方法,分别做webpack
的配置。
1. 开启gzip
压缩
现代浏览器默认支持gzip
压缩后的js
文件解析,相对于未压缩的js
文件会消耗一定的解压时间。但相对于网络延迟来说,压缩后解压带来的优势一般大于网络延迟,特别是在弱网环境下体验很重要。
在我们项目中使用的是SpringBoot
+ Tomcat
作为静态资源的服务端。因此,这里介绍以下SpringBoot
里配置gzip
的方式。、找到resources/application.properties
文件,添加如下代码:
# gzip压缩
server.compression.enabled=true
server.compression.mime-types=application/javascript,text/css,application/xml,text/html,text/xml,text/plain
复制代码
SpringBoot
下开启gzip
就是这么简单了。可以通过在浏览器打开页面,在控制台-网络查看我们的任意js
文件或css
文件能看到Response Headers
中的Content-Encoding: gzip
即表示gzip
已生效,同时可以看到文件大小也小了很多。
其他服务器下的gzip
配置方式可以自行Google
,一般操作较为简单。
我们还可以再进一步在服务器上优化:开启Cache Control
我们继续在resources/application.properties
中配置resources
的cache
:
# 缓存7天
spring.resources.cache.cachecontrol.max-age=604800
spring.resources.cache.cachecontrol.no-cache=false
spring.resources.cache.cachecontrol.no-store=false
spring.resources.cache.cachecontrol.cache-private=true
复制代码
配置完成后,第一次加载网页不会走缓存,但后面在7天内刷新网页时都会走缓存。只要文件名不变。
其他服务器下配置Cache-Control
的方式请自行Google
吧。
2. 部分依赖包改为CDN
方式引入
为什么需要使用CDN
方式引入依赖包呢?我认为有几个原因:
- (1)
CDN
使用了公共的资源和服务,可以降低打包体积大小,同时降低对自己的服务器的访问和流量 - (2)
CDN
可以做公共缓存,根据访问者的网络来择优读取数据,访问速度更快 - (3)
CDN
不会受项目发版影响,在访问者的浏览器缓存等方面也有更好的优化 - (4)
CDN
和分包类似,可以在打开网友时并行加载内容
虽然好处很多,但是并不是说我们依赖的包都需要用CDN
静态引入的方式加载。 这和浏览器的并发请求数限制有关。不同的浏览器对同一时间请求的资源数有不同的限制:
这就意味着,虽然可以通过CDN
引入很多依赖库,但是最终还是会分批次串行加载。同时,每一次的HTTP
连接的网络开销也会有很大的影响。因此,尽可能做到在CDN
资源与分包之间做到一个平衡。分包的处理后面会讲。所以,建议同学们在引入CDN
的时候保持以下几个原则:
- 尽可能选择体积占用大的包
- 数量保证在5个以内
- 配合分包将打包体积拆分,充分利用浏览器的并行能力
- 有条件的可以通过使用动态
CDN
域名突破浏览器的并行限制
那么,我们该怎么配置?这里我们有两种方式:
- (1)配置
.webpackrc.js
的externals
和手动在index.ejs
中配置<script.../>
- (2)使用插件
HtmlWebpackExternalsPlugin
当然了,处理原理都一样。这里我们介绍第二种方式,这种方式更简单,且易配置维护。例如我们将react
、react-dom
和jQuery
作为CDN
资源引入。
首先在package.json
中去除react
、react-dom
、jquery
的依赖,在devDependencies
中添加插件对应的"html-webpack-externals-plugin": "^3.8.0"
依赖包。然后在webpack.config.js
中添加插件的相关配置:
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')
// ...
export default webpackConfig => {
// ...
// 引入外部cdn资源
webpackConfig.plugins.push(
new HtmlWebpackExternalsPlugin({
externals: [
{
module: 'react',
entry: isDev ?
'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.development.js' :
'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js',
global: 'window.React'
},
{
module: 'react-dom',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js',
global: 'window.ReactDOM'
},
{
module: 'jquery',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js',
global: 'window.jQuery'
}
],
files: ['index.html'],
})
);
// 打包速度监控
return smp.wrap(webpackConfig);
}
复制代码
该插件的说明参考readme。我们还可以添加其他任意依赖的包,甚至是项目中的公共组件等。
3. 在生产环境中过滤moment
的语言包
(项目未使用moment
库的同学请跳过)
细心的同学可以看出来,在BundleAnalyzerPlugin
插件显示的包体积视图中,moment
占用了不少体积,同时moment
包中的语言包更是占用了大半个空间。然而我们在项目中一般只用到中英文。因此,moment
的语言包优化也是我们可以做的优化之一。在roadhog
的配置中有一个参数ignoreMomentLocale
即是在打包的时候过滤调moment
的语言包。只要配置该参数为true
即可达到效果。
如果我们想要自己做这件事,怎么做呢?这里我们要使用插件IgnorePlugin
,这个插件是webpack
包本身提供的。使用方式很简单,但是该插件耗时较高,会影响整体打包时长。所以建议在build
生产包时再开启。
配置参考:
// ...
export default webpackConfig => {
// ...
// 忽略moment的语言包
if(!isDev) {
webpackConfig.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/));
}
// 打包速度监控
return smp.wrap(webpackConfig);
}
复制代码
4. 分离node_modules
和业务代码
一说到做前端打包优化,一堆教程关于如何打包多个chunk
文件。今天,我们介绍一种简单却很有效的方式,就是将业务代码和依赖包分开打包。也就是node_modules
的代码打包为vendor.js
,业务代码打包为index.js
。这样一来,我们的依赖包都会打包到vendor.js
中,浏览器也能很好的缓存该文件。
我们这里同样要使用webpack
的CommonsChunkPlugin
插件。但是对chunk
文件的处理需要我们写函数实现:
// ...
export default webpackConfig => {
// ...
// 分离出node_modules下的包到vendor.hash8.js中
const nodeModulesPath = path.join(__dirname, './node_modules');
webpackConfig.plugins.push(new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
filename: '[name].[hash:8].js',
minChunks: function (module, _) {
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(nodeModulesPath) >= 0
)
}
}));
// 打包速度监控
return smp.wrap(webpackConfig);
}
复制代码
简单来讲就是只要是node_modules
下的js
文件,都统一打包到vendor
的chunk
文件中。当然css
相关的样式文件是通过loader
处理,不在chunk
打包处理范围中。
这样,我们打包就会生成2个js
文件:index.hash8.js
和vendor.hash8.js
。而且插件会自动的将这两个生成产物添加到index.html
中。
当然,如果分离出来的包依然很大,可以考虑配置多个chunk
文件。
以上便是对打包体积的优化,在我们的项目中最终的index.js
大小约为优化前的10%
,优化效果很明显。
第四步,【优化】提升打包效率
1. 过滤非必须的插件
从一开始我们配置的打包耗时分析来看,CaseSensitivePathsPlugin
和IgnorePlugin
插件等,有着明显的耗时。但是这些插件本身对包体积影响不大,或者说在开发环境下影响不大。因此,我们可以在webpack
的配置中过滤掉部分不需要的插件(这些插件来自于roadhog
配置)。
// ...
export default webpackConfig => {
// 过滤部分无用的插件
webpackConfig.plugins = webpackConfig.plugins.filter(p => {
switch (p.constructor.name) {
case 'UglifyJsPlugin':
case 'CaseSensitivePathsPlugin':
case 'IgnorePlugin':
case 'ProgressPlugin':
return false;
case 'HardSourceWebpackPlugin':
return isDev;
}
return true;
});
//...
return smp.wrap(webpackConfig);
}
复制代码
这里,我过滤了几个对项目打包影响不大的插件。其中UglifyJsPlugin
插件对打包影响很大,但是耗时较长,因此我们用另一个插件ParallelUglifyPlugin
来替代它。
2. 升级UglifyJsPlugin
为ParallelUglifyPlugin
该插件提升了UglifyJsPlugin
插件的构建效率,作用与UglifyJsPlugin
一致。都是用来做js
代码压缩美化的。该插件能有效的降低构建的js
包大小。耗时很大,却不影响打包运行。所以,为了进一步提升开发时的打包效率,可以仅在生产环境下开启该插件,配置参考如下:
// ...
export default webpackConfig => {
// 生产环境
if (!isDev) {
// 代码压缩美化插件
webpackConfig.plugins.push(
new ParallelUglifyPlugin({
// 传递给 UglifyJS 的参数
uglifyJS: {
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
},
compress: {
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
// 未使用的
unused: false
}
},
cacheDir: './cache',
workerCount: os.cpus().length
}),
);
}
//...
return smp.wrap(webpackConfig);
}
复制代码
3. 利用多线程处理babel-loader
webpack
下多线程打包可以优化大项目下loader
构建效率。因为webpack
打包大部分都是在处理js
文件和css
文件,这时就需要各种loader
来处理。而webpack
多线程加载loader
一般有两种方式:Happypack
插件和thread-loader
。两种方案的性能差不多。但是Happypack
在roadhog
的项目上有版本冲突的影响,配置相对来说比thread-loader
复杂一些。因此我们这里采用thread-loader
来处理。
使用thread-loader
也有一个弊端,就是它对css
的loader
支持不太好。同时要修改roadhog
的css
的loader
会有兼容问题,所以我们这里只处理js
的babel-loader
。参考配置如下:
// ...
export default webpackConfig => {
webpackConfig.module.rules.forEach(r => {
switch (r.test + '') {
case '/\\.js$/':
case '/\\.jsx$/':
if (r.use && Array.from(r.use).indexOf('thread-loader') < 0) {
r.use.splice(0, 0, 'thread-loader');
r.exclude = /node_modules/;
}
break;
default:
break;
}
});
//...
return smp.wrap(webpackConfig);
}
复制代码
4. 区分生产环境和开发环境
开发环境需要尽可能的让打包编译(每次热更新)更快,同时控制台输出更多有效的信息方便开发者调试。生产环境则需要尽可能降低包大小。其实在上面已经用到了环境判断标志,获取方式也很简单:
// 我们可以通过读取proccess.env的变量判断当前是什么打包环境。
const isDev = process.env.NODE_ENV === 'development';
复制代码
建议以下插件在dev
环境下使用:
BundleAnalyzerPlugin
HardSourceWebpackPlugin
建议以下插件在prod
环境下使用:
ParallelUglifyPlugin
IgnorePlugin
配置完成后,整体打包效率在我们的项目中提升了50%以上。
最后,附上项目中的完整配置文件,供大家参考
const os = require('os');
const webpack = require('webpack');
const path = require('path');
// 提取公共包
const CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;
// 是否为测试环境
const isDev = process.env.NODE_ENV === 'development';
// 外部资源(cdn)插件
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')
// 代码压缩
const ParallelUglifyPlugin = !isDev ? require('webpack-parallel-uglify-plugin') : null;
// 多线程并发
const threadLoader = require('thread-loader');
threadLoader.warmup({
// pool options, like passed to loader options
// must match loader options to boot the correct pool
}, [
// modules to load
// can be any module, i. e.
'babel-loader',
'url-loader',
'file-loader'
]);
// 构建测速
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
// 进度条
const WebpackBar = require('webpackbar');
// 资源分析
const BundleAnalyzerPlugin = isDev ? require('webpack-bundle-analyzer').BundleAnalyzerPlugin : null;
/**
* 导出最新的webpack配置,会覆盖roadhog导出的配置
* @param webpackConfig
* @returns {*|(function(...[*]): *)}
*
* @author luodong
*/
export default webpackConfig => {
// 过滤部分无用的插件
webpackConfig.plugins = webpackConfig.plugins.filter(p => {
switch (p.constructor.name) {
case 'UglifyJsPlugin':
case 'CaseSensitivePathsPlugin':
case 'IgnorePlugin':
case 'ProgressPlugin':
case 'HardSourceWebpackPlugin':
return false;
}
return true;
});
// 开发环境插件
if (isDev) {
// 打包资源分析插件
webpackConfig.plugins.push(
new BundleAnalyzerPlugin()
);
}
// 生产环境
if (!isDev) {
// 代码压缩美化插件
webpackConfig.plugins.push(
new ParallelUglifyPlugin({
// 传递给 UglifyJS 的参数
uglifyJS: {
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
},
compress: {
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
// 未使用的
unused: false
}
},
cacheDir: './cache',
workerCount: os.cpus().length
}),
);
// 忽略moment的语言包
webpackConfig.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/));
}
// 进度条显示
webpackConfig.plugins.push(
new WebpackBar({
profile: true,
// reporters: ['profile']
})
);
// 分离出node_modules下的包到vendor.hash8.js中
const nodeModulesPath = path.join(__dirname, './node_modules');
webpackConfig.plugins.push(new CommonsChunkPlugin({
name: 'vendor',
filename: '[name].[hash:8].js',
minChunks: function (module, _) {
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(nodeModulesPath) >= 0
)
}
}));
// 引入外部cdn资源
webpackConfig.plugins.push(
new HtmlWebpackExternalsPlugin({
externals: [
{
module: 'react',
entry: isDev ?
'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.development.js' :
'https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js',
global: 'window.React'
},
{
module: 'react-dom',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js',
global: 'window.ReactDOM'
},
{
module: 'bizcharts',
entry: 'http://gw.alipayobjects.com/os/lib/bizcharts/3.4.5/umd/BizCharts.min.js',
global: 'window.BizCharts'
},
{
module: '@antv/data-set',
entry: 'https://cdn.jsdelivr.net/npm/@antv/data-set@0.11.8/build/data-set.min.js',
global: 'window.DataSet'
},
{
module: 'echarts',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/echarts/4.8.0/echarts.min.js',
global: 'window.echarts'
},
{
module: 'jquery',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js',
global: 'window.jQuery'
},
{
module: 'xlsx',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js',
global: 'window.XLSX'
},
{
module: 'lodash',
entry: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js'
}
],
files: ['index.html'],
})
);
// 处理rules,添加thread-loader(暂时只支持js处理)
// 过滤不需要的loader
// webpackConfig.module.rules = webpackConfig.module.rules.filter(r =>
// ['/\\.(sass|scss)$/', '/\\.html$/'].indexOf(r.test + '') < 0);
webpackConfig.module.rules.forEach(r => {
switch (r.test + '') {
case '/\\.less$/':
case '/\\.css$/':
// r.use = ['happypack/loader?id=styles'];
// r.exclude = /node_modules/;
break;
case '/\\.js$/':
case '/\\.jsx$/':
if (r.use && Array.from(r.use).indexOf('thread-loader') < 0) {
r.use.splice(0, 0, 'thread-loader');
r.exclude = /node_modules/;
}
break;
default:
break;
}
});
return smp.wrap(webpackConfig);
};
复制代码