乐趣区

webpack构建和性能优化探索

前言
随着业务复杂度的不断的增加,工程模块的体积也会不断增加,构建后的模块通常要以 M 为单位计算。在构建过程中,基于 nodejs 的 webpack 在单进程的情况下 loader 表现变得越来越慢,在不做任何特殊处理的情况下,构建完后的多项目之间公用基础资源存在重复打包,基础库代码复用率也不高,这都慢慢暴露出 webpack 的问题。
正文
针对存在的问题,社区涌出了各种解决方案,包括 webpack 自身也在不断优化。
构建优化
下面利用相关的方案对实际项目一步一步进行构建优化,提升我们的编译速度,本次优化相关属性如下:

机器:Macbook Air 四核 8G 内存
Webpack:v4.10.2
项目:922 个模块

构建优化方案如下:

减少编译体积大小
将大型库外链
将库预先编译
使用缓存
并行编译

初始构建时间如下:

增量构建

Development 构建

Production 构建

备注

3088ms
43702ms
89371ms

减少编译体积大小
初始构建时候,我们利用 webpack-bundle-analyzer 对编译结果进行分析,结果如下:

可以看到,td-ui(类似于 antd 的 ui 组件库)、moment 库的 locale、BizCharts 占了项目的大部分体积,而在没有全部使用这些库的全部内容的情况下,我们可以对齐进行按需加载。
针对 td-ui 和 BizCharts,我们对齐添加按需加载 babel-plugin-import,这个包可以在使用 ES6 模块导入的时候,对其进行分析,解析成引入相应文件夹下面的模块,如下:

首先,我们先添加 babel 的配置,在 plugins 中加入 babel-plugin-import:
{

“plugins”: [

[“import”, [
{libraryName: ‘td-ui’, style: true},
{libraryName: ‘bizcharts’, libraryDirectory: ‘lib/components’},
]]
]
}
可以看到,我们给 bizcharts 也添加了按需加载,配置中添加了按需加载的指定文件夹,针对 bizcharts,编译前后代码对比如下:
编译前:

编译后:

注意:bizcharts 按需加载需要引入其核心代码 bizcharts/lib/core;
到此为止,td-ui 和 bizcharts 的按需加载已经处理完毕,接下来是针对 moment 的处理。moment 的主要体积来源于 locale 国际化文件夹,由于项目中有中英文国际化的需求,我们这里使用 webpack.ContextReplacementPugin 对该文件夹的上下文进行匹配,只匹配中文和英文的语言包,plugin 配置如下:
new webpack.ContextReplacementPugin(
/moment[\/\\]locale$/, // 匹配文件夹
/zh-cn|en-us/ // 中英文语言包
)
如果没有国际化的需求,可以使用 webpack.IgnorePlugin 对整个 locale 文件夹进行忽略,配置如下:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
减少编译体积大小完成之后得到如下构建对比结果:

增量构建

Development 构建

Production 构建

备注

3088ms
43702ms
89371ms

2561ms
27864ms
67441ms
减少编译体积大小

将大型库外链 && 将库预先编译
为了避免一些已经编译好的大型库重新编译,我们需要将这些库放在编译意外的地方,或者预先编译这些库。
webpack 也为我们提供了将模块外链的配置 externals,比如我们把 lodash 外链,配置如下
module.exports = {
//…
externals : {
lodash: ‘window._’
},

// 或者

externals : {
lodash : {
commonjs: ‘lodash’,
amd: ‘lodash’,
root: ‘_’ // 指向全局变量
}
}
};
针对库预先编译,webpack 也提供了相应的插件,那就是 webpack.Dllplugin,这个插件可以预先编译制定好的库,最后在实际项目中使用 webpack.DllReferencePlugin 将预先编译好的库关联到当前的编译结果中,无需重新编译。
Dllplugin 配置文件 webpack.dll.config.js 如下:

dllReference 配置文件 webpack.dll.reference.config.js 如下:

最后使用 webpack-merge 将 webpack.dll.reference.config.js 合并到到 webpack 配置中。
注意:预先编译好的库文件需要在 html 中手动引入并且必须放在 webpack 的 entry 引入之前,否则会报错。
其实,将大型库外链和将库预先编译也属于减少编译体积的一种,最后得到编译时间结果如下:

增量构建

Development 构建

Production 构建

备注

3088ms
43702ms
89371ms

2561ms
27864ms
67441ms
减少编译体积大小

2246ms
22870ms
50601ms
Dll 优化后

使用缓存
首先,我们开启 babel-loader 自带的缓存功能(默认其实就是打开的)。

另外,开启 uglifyjs-webpack-plugin 的缓存功能。

添加缓存插件 hard-source-webpack-plugin(当然也可以添加 cache-loader)
const hardSourcePlugin = require(‘hard-source-webpack-plugin’);

moudle.exports = {
// …
plugins: [
new hardSourcePlugin()
],
// …
}
添加缓存后编译结果如下:

增量构建

Development 构建

Production 构建

备注

3088ms
43702ms
89371ms

2561ms
27864ms
67441ms
减少编译体积大小

2246ms
22870ms
50601ms
Dll 优化后

1918ms
10056ms
17298ms
使用缓存后

可以看到,编译效果极好。
并行编译
由于 nodejs 为单线程,为了更好利用好电脑多核的特性,我们可以将编译并行开始,这里我们使用 happypack,当然也可以使用 thread-loader,我们将 babel-loader 和样式的 loader 交给 happypck 接管。
babel-loader 配置如下:

less-loader 配置如下:

构建结果如下:

增量构建

Development 构建

Production 构建

备注

3088ms
43702ms
89371ms

2561ms
27864ms
67441ms
减少编译体积大小

2246ms
22870ms
50601ms
Dll 优化后

1918ms
10056ms
17298ms
使用缓存后

2252ms
11846ms
18727ms
开启 happypack 后

可以看到,添加 happypack 之后,编译时间有所增加,针对这个结果,我对 webpack 版本和项目大小进行了对比测试,如下:

Webpack:v2.7.0
项目:1013 个模块
全量 production 构建:105395ms

添加 happypack 之后,全量 production 构建时间降低到 58414ms。
针对 webpack 版本:

Webpack:v4.23.0
项目:1013 个模块
全量 development 构建 : 12352ms

添加 happypack 之后,全量 development 构建降低到 11351ms。
得到结论:Webpack v4 之后,happypack 已经力不从心,效果并不明显,而且在小型中并不适用。
所以针对并行加载方案要不要加,要具体项目具体分析。
性能优化
对于 webpack 编译出来的结果,也有相应的性能优化的措施。方案如下:

减少模块数量及大小
合理缓存
合理拆包

减少模块数量及大小
针对减少模块数量及大小,我们在构建优化的章节中有提到很多,具体点如下:

按需加载 babel-plugin-import(antd、iview、bizcharts)、babel-plugin-component(element-ui)
减少无用模块 webpack.ContextReplacementPlugin、webpack.IgnorePlugin
Tree-shaking:树摇功能,消除无用代码,无用模块。
Scope-Hoisting:作用域提升。
babel-plugin-transform-runtime,针对 babel-polyfill 清除不必要的 polyfill。

前面两点我们就不具体描述,在构建优化章节中有说。
Tree-shaking
树摇功能,将树上没用的叶子摇下来,寓意将没有必要的代码删除。该功能在 webapck V2 中已被 webpack 默认开启,但是使用前提是,模块必须是 ES6 模块,因为 ES6 模块为静态分析,动态引入的特性,可以让 webpack 在构建模块的时候知道,那些模块内容在引入中被使用,那些模块没有被使用,然后将没有被引用的的模块在转为为 AST 后删除。
由于必须使用 ES6 模块,我们需要将 babel 的自动模块转化功能关闭,否则你的 es6 模块将自动转化为 commonjs 模块,配置如下:
{
“presets”: [
“react”,
“stage-2”,
[
“env”,
{
“modlues”: false // 关闭 babel 的自动转化模块功能,保留 ES6 模块语法
}
]
]
}
Tree-shaking 编译时候可以在命令后使用 –display-used-exports 可以在 shell 打印出关于代码剔除的提示。
Scope-Hoisting
作用域提升,尽可能的把打散的模块合并到一个函数中,前提是不能造成代码冗余。因此只有那些被引用了一次的模块才能被合并。
可能不好理解,下面 demo 对比一下有无 Scope-Hoisting 的编译结果。
首先定义一个 util.js 文件
export default ‘Hello,Webpack’;
然后定义入口文件 main.js
import str from ‘./util.js’
console.log(str);
下面是无 Scope-Hoisting 结果:

然后是 Scope-Hoisting 后的结果:

与 Tree-Shaking 类似,使用 Scope-Hoisting 的前提也是必须是 ES6 模块,除此之外,还需要加入 webpack 内置插件,位于 webpack 文件夹,webpack/lib/optimize/ModuleConcatenationPlugin,配置如下:
const ModuleConcatenationPlugin = require(‘webpack/lib/optimize/ModuleConcatenationPlugin’);
module.exports = {
//…
plugins: [
new ModuleConcatenationPlugin()
]
//…
}
另外,为了跟好的利用 Scope-Hoisting,针对 Npm 的第三方模块,它们也可能提供了 ES6 模块,我们可以指定优先使用它们的 ES6 模块,而不是使用它们编译后的代码,webpack 的配置如下:
module.exports = {
//…
resolve: {
// 优先采用 jsnext:main 中指定的 ES6 模块文件
mainFields: [‘jsnext:main’, ‘module’, ‘browser’, ‘main’]
}
//…
}
jsnext:main 为业内大家约定好的存放 ES6 模块的文件夹,后续为了规范,更改为 module 文件夹。
babel-plugin-transform-runtime
在我们实际的项目中,为了兼容一些老式的浏览器,我们需要在项目加入 babel-polyfill 这个包。由于 babel-polyfill 太大,导致我们编译后的包体积增大,降低我们的加载性能,但是实际上,我们只需要加入我们使用到的不兼容的内容的 polyfill 就可以,这个时候 babel-plugin-transform-runtime 就可以帮我们去除那些我们没有使用到的 polyfill,当然,你需要在 babal-preset-env 中配置你需要兼容的浏览器,否则会使用默认兼容浏览器。
添加 babel-plugin-transform-runtime 的.babelrc 配置如下:
{
“presets”: [[“env”, {
“targets”: {
“browsers”: [“last 2 versions”, “safari >= 7”, “ie >= 9”, “chrome >= 52”] // 配置兼容浏览器版本
},
“modules”: false
}], “stage-2”],
“plugins”: [
“transform-class-properties”,
“transform-runtime”, // 添加 babel-plugin-transform-runtime
“transform-decorators-legacy”
]
}
合理使用缓存
webpack 对应的缓存方案为添加 hash,那我们为什么要给静态资源添加 hash 呢?

避免覆盖旧文件
回滚方便,只需要回滚 html
由于文件名唯一,可开启服务器永远缓

然后,webpack 对应的 hash 有两种,hash 和 chunkhash。

hash 是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,并且全部文件都共用相同的 hash 值
chunkhash 根据不同的入口文件 (Entry) 进行依赖文件解析、构建对应的 chunk,生成对应的哈希值。

细想我们期望的最理想的 hash 就是当我们的编译后的文件,不管是初始化文件,还是 chunk 文件或者样式文件,只要文件内容一修改,我们的 hash 就应该更改,然后刷新缓存。可惜,hash 和 chunkhash 的最终效果都没有达到我们的预期。
另外,还有来自于的 extract-text-webpack-plugin 的 contenthash,contenthash 针对编译后的每个文件内容生成 hash。只是 extract-text-webpack-plugin 在 wbepack4 中已经被弃用,而且这个插件只对 css 文件生效。
webpack-md5-hash
为了达到我们的预期效果,我们可以为 webpack 添加 webpack-md5-hash 插件,这个插件可以让 webpack 的 chunkhash 根据文件内容生成 hash,相对稳定,这样就可以达到我们预期的效果了,配置如下:

var WebpackMd5Hash = require(‘webpack-md5-hash’);

module.exports = {
// …
output: {
//…
chunkFilename: “[chunkhash].[id].chunk.js”
},
plugins: [
new WebpackMd5Hash()
]
};

合理拆包
为了减少首屏加载的时候,我们需要将包拆分成多个包,然后需要的时候在加载,拆包方案有:

第三方包,DllPlugin、externals。
动态拆包,利用 import()、require.ensure()语法拆包
splitChunksPlugin

针对第一点第三方包,我们也在第一章节构建优化中有介绍,这里就不详细说了。
动态拆包
首先是 import(),这是 webpack 提供的语法,webpack 在解析到这样的语法时,会将指定的目录文件打包成一个 chunk,当成异步加载文件输出到编译结果中,语法如下:
import(/* webpackChunkName: chunkName */ ‘./chunkFile.js’).then(_module => {
// do something
});
import()遵循 promise 规范,可以在 then 的回调函数中处理模块。
注意:import()的参数不能完全是动态的,如果是动态的字符串,需要预先指定前缀文件夹,然后 webpack 会把整个文件夹编译到结果中,按需加载。
然后是 require.ensure(),与 import()类似,为 webpack 提供函数,也是用来生成异步加载模块,只是是使用 callback 的形式处理模块,语法如下:
// require.ensure(dependencies: String[], callback: function(require), chunkName: String)

require.ensure([], function(require){
const _module = require(‘chunkFile.js’);
}, ‘chunkName’);
splitChunksPlugin
webpack4 中,将 commonChunksPlugin 废弃,引入 splitChunksPlugin,两个 plugin 的作用都是用来切割 chunk。
webpack 把 chunk 分为两种类型,initial 和 async。在 webpack4 的默认情况下,production 构建会分析你的 entry、动态加载(import()、require.ensure)模块,找出这些模块之间共用的 node_modules 下的模块,并将这些模块提取到单独的 chunk 中,在需要的时候异步加载到页面当中。
默认配置如下:
module.exports = {
//…
optimization: {
splitChunks: {
chunks: ‘async’, // 标记为异步加载的 chunk
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: ‘~’, // 文件名中 chunk 的分隔符
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2, // 最小共享的 chunk 数
priority: -20,
reuseExistingChunk: true
}
}
}
}
};
splitChunksPlugin 提供了灵活的配置,开发者可以根据自己的需求分割 chunk,比如下面官方的例子 1 代码:
module.exports = {
//…
optimization: {
splitChunks: {
cacheGroups: {
commons: {
name: ‘commons’,
chunks: ‘initial’,
minChunks: 2
}
}
}
}
};
意思是在所有的初始化模块中抽取公共部分,生成一个 chunk,chunk 名字为 comons。
在如官方例子 2 代码:
module.exports = {
//…
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: ‘vendors’,
chunks: ‘all’
}
}
}
}
};
意思是从所有模块中抽离来自于 node_modules 下的所有模块,生成一个 chunk。当然这只是一个例子,实际生产环境中并不推荐,因为会使我们首屏加载的包增大。
针对官方例子 2,我们可以在开发环境中使用,因为在开发环境中,我们的 node_modules 下的所有文件是基本不会变动的,我们将其生产一个 chunk 之后,每次增量编译,webpack 都不会去编译这个来自于 node_modules 的已经生产好的 chunk,这样如果项目很大,来源于 node_modules 的模块非常多,这个时候可以大大降低我们的构建时间。
最后
现在大部分前端项目都是基于 webpack 进行构建的,面对这些项目,或多或少都有一些需要优化的地方,或许做优化不为完成 KPI,仅为自己有更好的开发体验,也应该行动起来。

退出移动版