Webpack是当下最热门的前端资源模块强大打包工具。它可以将许多松散的模块按照依赖和规则打包成符合生成环境部署的前端资源;还可以将按需加载的模块进行代码分割,等到实际需要再异步加载。在使用webpack时,如果不注意性能优化,非常大的可能产生性能问题。性能问题主要分为开发时构建速度慢、开发调试时的重复工作、输出打包文件过大等,因此优化啊方案也主要针对这些方面来分析得出。
一、优化构建速度
webpack打包,首先根据entry配置的入口出发,递归遍历解析所依赖的文件。这个过程分为搜索文件和匹配文件进行分析、转化的2个过程,因此可以从这两个角度来进行优化配置。
1、缩小文件的搜索范围
减小文件搜索的优化配置如下:
1)合理配置resolve,告诉webpack如何去搜索文件
- 设置resolve.modules:[path.resolve(__dirname, ‘node_modules’)],避免层层查找
resolve.modules告诉webpack解析模块时应该搜索的目录。默认值为[‘node_modules’],会依次查找当前目录及祖先路径(即./node_modules、../node_modules、../../node_modules等)。
如果你想要添加一个目录到模块搜索目录,此目录优先于node_modules/搜索,配置如下:
modules: [path.resolve(__dirname, "src"), "node_modules"]
设置resolve.mainFields:[‘main’],设置尽量少的值,可以减少入口文件的搜索解析
webpack打包Node应用程序默认会从module开始解析,resolve.mainFields默认值为:
mainFields: ["module", "main"]
第三方模块为了适应不同的使用环境,会定义多个入口文件。我们可以设置mainFields统一第三方模块的入口文件main,减少搜索解析。(大多数第三方模块都使用main字段描述入口文件的位置)
- 对庞大的第三方模块设置resolve.alias,直接引用第三方库的min文件,避免库内解析
resolve.alias:{
'react':path.resolve(__dirname, './node_modules/react/dist/react.min.js')
}
这样会影响Tree-Shaking,适合对整体性比较强的库使用,如果是像lodash这类工具类的比较分散的库,比较适合Tree-Shaking,避免使用这种方式。
- 合理配置resolve.extensions,减少文件查找
默认值:resolve.extensions: [‘.js’, ‘.json’],当引入语句没带文件后缀时,webpack会根据extensions定义的后缀列表进行查找,所以: - 列表值尽量少 - 频率高的文件后缀写在前面 - 代码中引入语句尽可能地带上文件后缀,比如require(‘./data’)改写成require(‘./data.json’)。
2)module.noParse,告诉webpack不必解析哪些文件,可以用来排除对非模块化库文件的解析
比如 JQuery、React,另外如果使用resolve.alias配置了react.min.js,则应该排除解析,因为react.min.js经过构建,已经是可以直接运行在浏览器的、非模块化的文件。
module: {
noParse: [/jquery|lodash, /react\.min\.js$/]
}
有异曲同工的效果,就是使用externals外部扩展,剥离第三方依赖模块(如jquery、react、echarts等),不打包到bundle.js。
3)配置loader时,通过test、exclude、include缩小搜索范围
module: {
loaders: [{
test: /\.js$/,
loader: 'babel-loader',
include: [
path.resolve(__dirname, "app/src"),
path.resolve(__dirname, "app/test")
],
exclude: /node_modules/
}]
}
2、使用DllPlugin减少基础模块编译次数
使用DllPlugin动态链接库插件,大量复用的模块只用编译一次,其原理是把网页依赖的基础模块抽离出来打包到dll文件中,当需要导入模块存在于某个dll中,这个模块不再打包,直接从dll中获取。我认为使用DllPlugin链接第三方模块,和配置resolve.alias和module.noParse的效果有异曲同工之处。
使用方法:
1)使用DllPlugin插件,配置webpack_dll.config.js来构建dll文件:
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
entry: {
// 把 React 相关模块的放到一个单独的动态链接库
react: ['react', 'react-dom'],
// 把项目需要所有的 polyfill 放到一个单独的动态链接库
polyfill: ['core-js/fn/promise', 'whatwg-fetch']
},
output: {
// 输出的动态链接库的文件名称
filename: '[name].dll.js',
// 输出的文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
// 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
// 之所以在前面加上 _dll_ 是为了防止全局变量冲突
library: '_dll_[name]'
},
plugins: [
new DllPlugin({
// 动态链接库的全局变量名称
name: '_dll_[name]',
// 描述动态链接库的 manifest.json 文件输出时的文件名称
path: path.join(__dirname, 'dist', '[name].manifest.json')
})
]
}
构建输出的以下这四个文件:
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
2)在主config文件,使用DllReferencePlugin
插件引入xx.manifest.json
动态链接库文件:
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
module.exports = {
entry: {
main: './main.js'
},
// ... 省略output和loader配置
plugins: [
new DllReferencePlugin({
// 描述 react 动态链接库的文件内容
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
// 描述 polyfill 动态链接库的文件内容
manifest: require('./dist/polyfill.manifest.json'),
})
]
}
3、使用ParallelUglifyPlugin开启多进程压缩JS文件
ParallelUglifyPlugin
插件可以开启多个子进程,每个子进程使用uglifyJsPlugin
压缩代码,可以并行执行,能显著缩短压缩代码时间。
使用方法:
1)安装 webpack-parallel-uglify-plugin 插件:
npm install -D webpack-parallel-uglify-plugin
2)然后在webpack.config.js 配置代码如下:
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
module.exports = {
plugins: [
new ParallelUglifyPlugin({
uglifyJS: {
// 这里放uglifyJs的参数
}
})
]
}
二、优化首屏加载
1、使用loading配置
html-webpack-plugin插件,给html文件载入时添加loading图。使用方法如下:
1)安装 html-webpack-plugin 插件:
npm install -D html-webpack-plugin
2)webpack.config.js配置如下:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const loading = require('./render-loading');// 事先设计好的loading图
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
loading: loading
})
]
}
2、预渲染
prerender-spa-plugin插件,预渲染极大地提高了首屏加载速度。其原理是此插件在本地模拟浏览器环境,预先执行我们打包的文件,返回预先解析的首屏html。使用方法入如下:
1)安装 prerender-spa-plugin 插件:
npm install -D prerender-spa-plugin
2)webpack.config.js配置如下:
const PrerenderSPAPlugin = require('prerender-spa-plugin');
module.exports = {
plugins: [
new PrerenderSPAPlugin({
// 生成文件的路径,也可以与webpakc打包的一致。
staticDir: path.join(__dirname, '../dist'),
// 要预渲染的路由
route: [ '/', '/team', '/analyst','/voter','/sponsor'],
// 这个很重要,如果没有配置这段,也不会进行预编译
renderer: new Renderer({
headless: false,
renderAfterDocumentEvent: 'render-active',
// renderAfterTime: 5000
})
})
]
}
3)项目入口文件main.js启动预渲染:
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
i18n,
components: { App },
template: '<App/>',
render: h => h(App),
/* 这句非常重要,否则预渲染将不会启动 */
mounted () {
document.dispatchEvent(new Event('render-active'))
}
})
三、优化输出质量-压缩文件体积
1、压缩代码-JS、ES6、CSS
1)压缩JS,使用webpack内置UglifyJSPlugin、ParallelUglifyPlugin
会分析JS代码语法树,理解代码的含义,从而去掉无效代码、日志输出代码,缩短变量名,进行压缩等优化。使用UglifyJSPlug配置webpack.config.js如下:
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
//...
plugins: [
new UglifyJSPlugin({
compress: {
warnings: false, //删除无用代码时不输出警告
drop_console: true, //删除所有console语句,可以兼容IE
collapse_vars: true, //内嵌已定义但只使用一次的变量
reduce_vars: true, //提取使用多次但没定义的静态值到变量
},
output: {
beautify: false, //最紧凑的输出,不保留空格和制表符
comments: false, //删除所有注释
}
})
]
2)压缩ES6,使用第三方UglifyJS插件
如今越来越多的浏览器支持直接执行ES6代码了,这样比起转换后的ES5代码量更少,且性能更好。直接运行的ES6代码,也是需要代码压缩,第三方uglify-webpack-plugin提供了压缩ES6代码的功能,使用方法如下:
a、安装uglify-webpack-plugin插件:
uglify-webpack-plugin
b、webpack.config.js配置如下:
const UglifyESPlugin = require('uglify-webpack-plugin');
//...
plugins:[
new UglifyESPlugin({
uglifyOptions: { //比UglifyJS多嵌套一层
compress: {
warnings: false,
drop_console: true,
collapse_vars: true,
reduce_vars: true
},
output: {
beautify: false,
comments: false
}
}
})
]
另外要防止babel-loader转换ES6代码,要在.babelrc中去掉babel-preset-env,因为正是babel-preset-env负责把ES6转换为ES5。
3)压缩CSS
将js里面分离出来的多个css合并成一个,然后进行压缩、去重等处理。
a、安装引入mini-css-extract-plugin、optimize-css-assets-webpack-plugin插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
b、配置loader
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-import')(),
require('autoprefixer')({
browsers: ['last 30 versions', "> 2%", "Firefox >= 10", "ie 6-11"]
})
]
}
}
]
}
]
}
c、将多个css文件合并成单一css文件
主要是针对多入口,会产生多分样式文件,合并成一个样式文件,减少加载次数 配置如下
- 配置splitChunks
optimization:{
splitChunks: {
chunks: 'all',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
name: true,
cacheGroups: {
styles: {
name: 'style',
test: /\.css$/,
chunks: 'all',
enforce: true
}
}
}
}
- 配置插件
- filename 与output中的filename 命名方式一样
- 这里是将多个css合并成单一css文件, 所以chunkFilename 不用处理
- 最后产生的样式文件名大概张这样 style.550f4.css ;style 是 splitChunks-> cacheGroups-> name
new MiniCssExtractPlugin({
filename: 'assets/css/[name].[hash:5].css'
})
d、优化css文件,去重压缩等处理
- 主要使用 optimize-css-assets-webpack-plugin 插件和 cssnano 优化器
- cssnano 优化器具体做了哪些优化,可参考 官网
配置方式有两种,效果等同。
方式一:
module.exports = {
optimization:{
minimizer: [
new OptimizeCSSAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require('cssnano'),
// cssProcessorOptions: cssnanoOptions,
cssProcessorPluginOptions: {
preset: ['default', {
// 对注释的处理
discardComments: {
removeAll: true,
},
// 建议设置为false,否则在使用 unicode-range 的时候会产生乱码
normalizeUnicode: false
}]
},
// 是否打印处理过程中的日志
canPrint: true
})
]
}
}
方式二:
module.exports = {
plugins:[
new OptimizeCSSAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require('cssnano'),
// cssProcessorOptions: cssnanoOptions,
cssProcessorPluginOptions: {
preset: ['default', {
discardComments: {
removeAll: true,
},
normalizeUnicode: false
}]
},
canPrint: true
})
]
}
2、启用Tree Shaking剔除死代码
Tree Shaking可以剔除用不上的死代码,它依赖ES6的import、export的模块化语法,最先在Rollup中出现,Webpack 2.0将其引入。适合用于Lodash、utils.js等工具类较分散的文件。它正常工作的前提是代码必须采用ES6的模块化语法,因为ES6模块化语法是静态的(在导入、导出语句中的路径必须是静态字符串,且不能放入其他代码块中)。如果采用了ES5中的模块化,例如:module.export = {...}
、require( x+y )
、if (x) { require( './util' ) }
,则Webpack无法分析出可以剔除哪些代码。
如何启用Tree Shaking:
1)修改.babelrc以保留ES6模块化语句:
{
"presets": [
[
"env",
{ "module": false }, //关闭Babel的模块转换功能,保留ES6模块化语法
]
]
}
2)启动webpack时带上 –display-used-exports可以在shell打印出关于代码剔除的提示
3)使用UglifyJSPlugin,或者启动时使用–optimize-minimize
4)在使用第三方库时,需要配置 resolve.mainFields: [‘jsnext:main’, ‘main’] 以指明解析第三方库代码时,采用ES6模块化的代码入口
四、优化输出质量-提升代码运行速度
1、使用Prepack提前求值
prepack-webpack-plugin插件能提前计算,代码运行时直接获取结果,提升代码运行速度。其原理是,编译代码时提前将计算结果放到编译后的代码中,而不是运行时才去求值计算,运行代码时直接将运算结果输出以提升性能。prepack的使用方法:
1)安装prepack-webpack-plugin插件:
npm install -D prepack-webpack-plugin
2)webpack.config.js配置如下:
const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;
const configuration = {};
module.exports = {
// ...
plugins: [
new PrepackWebpackPlugin(configuration)
]
};
2、使用Scope Hoisting(作用域提升)
Scope Hoisting是Webpack3.x内置的功能,它分析模块间的依赖关系,尽可能将被打散的模块合并到一个函数中,但不能造成代码冗余,所以只有被引用一次的模块才能被合并。由于需要分析模块间的依赖关系,所以项目代码中需使用ES6模块化,否则Webpack会降级处理不采用Scope Hoisting。Scope Hoisting的使用配置如下:
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
// ...
plugins: [
new ModuleConcatenationPlugin()
]
}
五、优化输出质量-加速网络请求
1、使用cdn加速静态资源加载
1)CDN加速的原理
CDN通过将资源部署到世界各地,使得用户可以就近访问资源,加快访问速度。要接入CDN,需要把网页的静态资源上传到CDN服务上,在访问这些资源时,使用CDN服务提供的URL。
由于CDN会为资源开启长时间的缓存,例如用户从CDN获取index.html,即使之后替换了index.html,用户那边仍会在使用之前的版本直到缓存时间过期。业界的做法:
- HTML文件:放在自己的服务器上且关闭缓存,不接入CDN
- 静态的JS、CSS、图片等资源:开启CDN和缓存,同时文件名带上有内容计算出的hash值,这样只要内容变化hash就会变化,文件名就会变化,就会被重新下载而不论缓存时间多长。 举个详细的例子,有一个单页应用,构建出的代码结构如下:
lua复制代码dist
|-- app_9d89c964.js
|-- app_a6976b6d.css
|-- arch_ae805d49.png
|-- index.html
另外,HTTP1.x版本的协议下,浏览器会对于同一个域名并行发起的请求限制在4-8个。那么把所有静态资源放在同一域名下的CDN服务上就会遇到限制,所以可以把静态资源分散在不同的CDN服务上,例如JS文件放在js.cdn.com域名下,CSS文件放在css.cdn.com域名,图片文件放在img.cdn.com域名下。使用了多个域名后又会带来一个新问题:增加域名解析时间。是否采用多域名分散资源需要根据自己的需求去衡量得失。 当然你可以通过在HTML HEAD标签中加入<link rel="dns-prefetch" href="//js.cdn.com">
去预解析域名,以降低域名解析带来的延迟。
2)Webpack实现CDN的接入
Webpack接入CDN主要的配置如下:
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const {WebPlugin} = require('web-webpack-plugin');
module.exports = {
// 省略 entry 配置...
output: {
// 给输出的 JavaScript 文件名称加上 Hash 值
filename: '[name]_[chunkhash:8].js',
path: path.resolve(__dirname, './dist'),
// 指定存放 JavaScript 文件的 CDN 目录 URL
publicPath: '//js.cdn.com/id/',
},
module: {
rules: [
{
// 增加对 CSS 文件的支持
test: /\.css$/,
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
// 压缩 CSS 代码
use: ['css-loader?minimize'],
// 指定存放 CSS 中导入的资源(例如图片)的 CDN 目录 URL
publicPath: '//img.cdn.com/id/'
}),
},
{
// 增加对 PNG 文件的支持
test: /\.png$/,
// 给输出的 PNG 文件名称加上 Hash 值
use: ['file-loader?name=[name]_[hash:8].[ext]'],
},
// 省略其它 Loader 配置...
]
},
plugins: [
// 使用 WebPlugin 自动生成 HTML
new WebPlugin({
// HTML 模版文件所在的文件路径
template: './template.html',
// 输出的 HTML 的文件名称
filename: 'index.html',
// 指定存放 CSS 文件的 CDN 目录 URL
stylePublicPath: '//css.cdn.com/id/',
}),
new ExtractTextPlugin({
// 给输出的 CSS 文件名称加上 Hash 值
filename: `[name]_[contenthash:8].css`,
}),
// 省略代码压缩插件配置...
],
};
2、提取页面间公共代码,以便使用浏览器缓存
1)原理
大型网站通常是由多个页面组成,肯定会依赖同样的样式文件、脚本文件等。如果不把这些公共文件提取出来,那么每个单页打包出来的chunck中都会包含公共代码,相当于要传输n份重复代码。如果把公共代码提取成一个文件,那么当用户访问了一个网页加载了这个公共文件,再访问其他依赖公共文件的网页,就直接使用文件在浏览器的缓存,不用重复加载请求。
2)使用方法
a、把多个页面依赖的公共代码提取到common.js
const CommonsPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
module.exports = {
plugins:[
new CommonsChunkPlugin({
chunks:['a','b'], //从哪些chunk中提取
name:'common', // 提取出的公共部分形成一个新的chunk
})
]
}
b、找出依赖的基础库,写一个base.js文件,再与common.js提取公共代码到base中,common.js就剔除了基础库代码,而base.js保持不变。
//base.js
import 'react';
import 'react-dom';
import './base.css';
//webpack.config.json
module.exports = {
entry:{
base: './base.js'
},
plugins:[
new CommonsChunkPlugin({
chunks:['base','common'],
name:'base',
//minChunks:2, 表示文件要被提取出来需要在指定的chunks中出现的最小次数,防止common.js中没有代码的情况
})
]
}
c、得到基础库代码base.js,不含基础库的公共代码common.js,和页面各自的代码文件xx.js。
页面引用顺序如下:base.js–> common.js–> xx.js
3、限制chunk分割数量,减小HTTP请求开销
1)原理
在webpack编译完之后,你可能会注意到有一些很小的 chunk - 这产生了大量 HTTP 请求开销。幸运的是使用LimitChunkCountPlugin插件可以通过合并的方式,处理 chunk,以减少http请求数。
2)使用方法
const LimitChunkCountPlugin = require('webpack/lib/optimize/LimitChunkCountPlugin');
module.exports = {
// ...
plugins: [
new LimitChunkCountPlugin({
// 限制 chunk 的最大数量,必须大于或等于1的值
maxChunks: 10,
// 设置 chunk 的最小大小
minChunkSize: 2000
})
]
}
六、优化开发体验
1、使用自动刷新
1)Webpack监听文件
监听文件有两种方式:
方式一:
在配置文件 webpack.config.js 中设置 watch: true。
// 从配置的 Entry 文件出发,递归解析出 Entry 文件所依赖的文件,
// 把这些依赖的文件加入到监听列表
// 而不是直接监听项目目录下的所有文件
module.export = {
// 只有在开启监听模式时,watchOptions 才有意义
// 默认为 false,也就是不开启
watch: true,
// 监听模式运行时的参数
// 在开启监听模式时,才有意义
watchOptions: {
// 不监听的文件或文件夹,支持正则匹配
// 默认为空
ignored: /node_modules/,
// 在 Webpack 中监听一个文件发生变化的原理是定时的不停的去获取文件的最后编辑时间,
// 每次都存下最新的最后编辑时间,如果发现当前获取的和最后一次保存的最后编辑时间不一致,
// 就认为该文件发生了变化。
// poll 就是用于控制定时检查的周期,具体含义是每隔多少毫秒检查一次
// 默认每隔1000毫秒询问一次
poll: 1000,
// 监听到文件发生变化时,webpack 并不会立刻告诉监听者,
// 而是先缓存起来,收集一段时间的变化后,再一次性告诉监听者
// aggregateTimeout 就是用于配置这个等待时间,
// 目的是防止文件更新太快导致重新编译频率太高,让程序构建卡死
// 默认为 300ms
aggregateTimeout: 300,
// 不监听的 node_modules 目录下的文件
ignored: /node_modules/,
}
}
方式二:
在执行启动 Webpack 命令时,带上 –watch 参数,完整命令是 webpack –watch。
2)控制浏览器自动刷新
方式一:webpack-dev-server
在使用 webpack-dev-server 模块去启动 webpack 模块时,webpack 模块的监听模式默认会被开启。 webpack 模块会在文件发生变化时告诉 webpack-dev-server 模块。
方式二:koa + webpack-dev-middleware + webpack-hot-middleware前后端同构
segmentfault.com/a/119000000…
2、开启模块热更新HMR
模块热替换(HMR - Hot Module Replacement)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面,所以预览反应更快,等待时间更少。原理是向每个chunk中注入代理客户端来连接DevServer和网页。开启方式:
1)webpack-dev-server -hot
2)使用HotModuleReplacementPlugin插件
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
module.exports = {
plugins: [
new HotModuleReplacementPlugin()
]
}
开启后,如果修改子模块就可以实现局部刷新,但如果修改的是根JS文件,会整个页面刷新。原因在于,子模块更新时,事件一层层向上传递,直到某个层的文件接收了当前变化的模块,然后执行回调函数。如果一层层向外抛到最外层都没有文件接收,就会刷新整页。
永远不要在生产环境(production)下启用 HMR。
小结:
在使用webpack构建前端项目中,逐渐暴露出一些性能问题,其主要有如下几个方面:
- 代码全量构建速度过慢,即使是很小的改动,也要等待长时间才能查看到更新与编译后的结果。(引入HMR热更新后有明显改进)
- 随着项目业务的复杂度增加,工程模块的体积也会急剧增大,构建后的模块通常要以M为单位计算。
- 多个项目之间共用基础资源存在重复打包,基础库代码复用率不高。
- 首屏加载依赖过多,白屏时间较长等问题。
针对如上问题,上述webpack优化方案便派上用场了。作为开发工程师,我们要不断追求项目工程高性能,秉持“什么方案解决什么问题”的准则,针对实际开发项目,持续改进优化项目性能,不断提升开发效率、降低资源成本。