企业级项目:webpack中的性能优化
webpack性能优化概述
很多很多人都认为性能是一个项目必不可少的,我总结了有关webpack构建项目中的性能优化的几个方面。在了解性能优化之前,最好对webpack编译原理有所了解,方便更深入的学习。
可以参考:大神眼中的webpack构建工具:对编译原理的分析
本文中性能优化目录:
- 构建性能:是指在开发阶段的构建性能,而不是生产环境的构建性能,尽可能提高开发效率
- 减少模块解析:
- 优化loader性能
- 热替换
- 传输性能:服务端的JS传输给客户端的时间。总代码量越少,时间越少。文件数量越少,http请求次数越少。
- 分包
- 手动分包
- 自动分包
- 体积优化
- 代码压缩
- tree shaking
- 懒加载
- gzip
- 分包
- 运行性能:在浏览器端的运行速度。
- 运行性能主要在书写代码中体现
一、构建性能
1、减少模块解析
模块解析包括:抽象语法树分析、依赖分析、模块语法替换。如果一个模块不做模块解析,那么经过loaders处理后的代码就是最后的源码。但是模块解析又是必须要做的步骤,那么如何减少模块解析?嘿嘿。如果一个模块中没有其他依赖就可以不对其进行模块解析,其实,减少模块解析主要是针对一些已经打包好的第三方库,比如jquery
等。配置一个模块不进行解析很简单,只要在module
中配置noParse
。一个正则表达式。
module.exports = {
mode: "development",
devtool: "source-map",
module: {
rules:[],
noParse: /jquery/
}
}
2、优化loader性能
(1)减少loader应用范围
优化loader的性能,其实就是进一步限制loader的应用范围,对于某些库,不需要使用loader,比如说babel-loader
,babel-loader
是将某些ES2015+转换为浏览器识别的语法。但是某些库,本来就没有使用这么高版本的语法,使用loader处理完全是浪费时间,所以不需要对其进行loader处理了呀。比如loadsh
库。我们可以通过配置,让其跳过loader处理。
通过module.rule.exclude
或module.rule.include
,排除或仅包含需要应用loader的场景
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /lodash/,
use: "babel-loader"
}
]
}
}
当然,第三方大部分库都已经对其进行了babel处理,如果暴力一点,甚至可以排除掉node_modules
目录中的模块,或仅转换src
目录的模块。但是要慎重,排除之前要去官网看看是否已经处理过了。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//也可以用 include: /src/,效果相同
use: "babel-loader"
}
]
}
}
(2)缓存loader的结果
如果某个文件内容不变,经过相同的loader解析后,解析后的结果也不变,所以我们可以将loader的解析结果保存下来,让后续的解析直接使用缓存的结果,当然这种方式会增加第一次构建时间
cache-loader
可以实现这样的功能,要将cache-loader
放在最前面,虽然放在最前面,但是他可以决定让后续loader是否运行。具体配置看官方文档!
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "cache-loader",
options:{
cacheDirectory: "./cache" //缓存的目录
}
}, ...loaders] //其余的loaders
},
],
},
};
实际上,loader的运行过程中,还包含一个过程,即pitch
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l6TffEJ7-1584435464160)(F:\博客\前端工程化\assets\pitch-loader运行过程.png)]
(3)为loader开启多线程
如果loader进行处理的过程是一个异步操作的话,可以大大减少处理时间,thread-loader
会开启一个线程池,它会把后续的loader放到线程池的线程中运行,以提高构建效率。因为后续的loader是放入新的线程池中,就无法使用webpack api、自定义的plugin api,也无法访问的webpack options。具体把thread-loader
放在什么位置,要根据项目视情况而定,可以傻瓜式测试。但要注意的是,开启和管理新的线程也是需要时间的。
module.exports = {
mode: "development",
devtool: "source-map",
module: {
rules: [
{
test: /\.js$/,
use: [
"file-loader",
"thread-loader", //将thread-loader和babel-loader放入新的线程中
"babel-loader"
]
}
]
}
};
3、热替换 HMR
我们在使webpack-dev-server
开发服务器时,他会事时的监控代码变动,不需要重新带包,但是webpack-dev-server
发现代码变动的时候,浏览器会刷新,重新请求所有资源。这显然不是我们开发最理想的结果,我们更希望,当我们更改一部分代码的时候,浏览器不刷新,只是局部进行替换。热替换就是实习了局部替换。要注意热替换不会讲题构建的性能,但是它可以降低代码变动到效果呈现的时间。
使用webpack-dev-server
的流程:
使用热替换的流程:
使用热替换
- 更改配置:
module.exports = {
devServer:{
hot:true // 开启热替换
},
plugins:[
new webpack.HotModuleReplacementPlugin() //使用插件
]
}
- 更改代码:随便一个文件写入以下代码,只要保证运行即可
// index.js
if(module.hot){ // 是否开启了热更新
module.hot.accept() // 接受热更新
}
热替换原理
当在配置文件中开启了热替换后,webpack-dev-server
会向打包结果中注入module.hot
属性,所以在上述文件中添加的module.hot
代码。默认情况下,webpack-dev-server
不管是否开启了热更新,当重新打包后,都会调用location.reload
刷新页面,但是如果运行了module.hot.accept()
,就不会再调用loaction.reload()
来刷新页面,而是使用websocket
的方式,module.hot.accept()
会让服务器更新的内容通过websocket
传送给浏览器,仅仅是传输修改的部分。然后将结果交给插件HotModuleReplacementPlugin
注入的代码执行,插件HotModuleReplacementPlugin
会根据覆盖原始代码,然后让代码重新执行。
比如我在这里修改了一个js文件的模块导出,当监控到代码发生变化以后,websocket向客户端发出了两个服务,第一个是热替换的哈希值,不解释。
第二个就是需要热替换的代码,当接收到这个这段代码的时候,HotModuleReplacementPlugin
插件就会根据key值"./src/a.js"来找到模块的位置,将模块的value重新覆盖。
module.hot.accept()
的作用是让webpack-dev-server
通过socket
管道,把服务器更新的内容发送到浏览器
简单来说,热替换就是开启热替换的webpack-dev-server
开发服务器监控到代码变化,通过websocket从服务的向客户端发送变化的内容,客户端接受到变化的内容后替换掉原内容。
样式热替换
对于样式也是可以使用热替换的,需要使用style-loader
,因为热替换发生时HotModuleReplacementPlugin
只会简单的重新运行模块代码。因此style-loader
的代码一运行,就会重新设置style
元素中的样式。而mini-css-extract-plugin
,由于它生成文件是在构建期间,运行期间并会也无法改动文件,因此它对于热替换是无效的。
二、传输性能
1、手动分包
手动分包的总体思路是先单独打包公共模块,公共模块会被打包成一个动态链接库(ddl),并且形成一个资源清单。然后再根据入口模块进行正常的打包过程。
当正常打包时,如果发现模块中使用了资源清单中描述的模块,如下
//源码,入口文件index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));
由于资源清单中包含jquery
和lodash
两个模块,因此打包结果不会出现jquery
和lodash
的源代码,而是通过导出一个模块的方式,如下
(function(modules){
//...
})({
// index.js文件的打包结果并没有变化
"./src/index.js":
function(module, exports, __webpack_require__){
var $ = __webpack_require__("./node_modules/jquery/index.js")
var _ = __webpack_require__("./node_modules/lodash/index.js")
_.isArray($(".red"));
},
// 由于资源清单中存在,jquery的代码并不会出现在这里
"./node_modules/jquery/index.js":
function(module, exports, __webpack_require__){
module.exports = jquery;
},
// 由于资源清单中存在,lodash的代码并不会出现在这里
"./node_modules/lodash/index.js":
function(module, exports, __webpack_require__){
module.exports = lodash;
}
})
这样一来,重复代码就会减少,也就减少了传输时的体积。
(1)打包公共模块
打包公共模块是一个独立的打包过程,所以我们通常会重建一个配置文件webpack.dll.config.js
,需要两个过程,首先打包公共模块,暴露变量名,然后用DllPlugin
插件生成资源清单
const path = require("path")
const webpack = require("webpack")
module.exports = {
mode: "production",
entry: {//打包公共模块
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]"//
},
plugins: [//生成资源清单
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"), //资源清单的保存位置
name: "[name]"//资源清单中,暴露的变量名
})
]
};
运行后,即可完成公共模块打包
npx webpack --config webpack.dll.config.js
(2)使用公共模块
- 在页面中手动引入公共模块
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
- 为了避免把公共模块清除,需要重新设置
clean-webpack-plugin
,如果没有使用你该插件则忽略
new CleanWebpackPlugin({
// 要清除的文件或目录
// 排除掉dll目录本身和它里面的文件
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})
目录和文件的匹配规则使用的是globbing patterns
- 使用
DllReferencePlugin
,告诉webpack资源清单的位置,如果遇到导出模块已经在资源清单中,则不需要再进行打包。
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
简单来说,手动打包首先要开启output.library
暴露公共模块,使用webpack.DllPlugin
插件生成资源清单(可以不使用,自己写),然后在页面中引入资源清单中的依赖,最后用DllReferencePlugin
插件使用资源清单。
在手动打包的过程中,我们需要注意,资源清单是不参与运行的,所以不能把资源清单放在打包目录中。
手动打包优点:
- 极大提升自身模块的打包速度
- 极大的缩小了自身文件体积
- 有利于浏览器缓存第三方库的公共代码
缺点:
- 使用非常繁琐
- 如果第三方库中包含重复代码,则效果不太理想
2、自动分包
自动包区别于手动分包的是不需要确定具体为那个模块分包,而是从一个宏观的角度来控制分包,那么要控制分包,就需要有一个合理的分包策略。webpack4已经放弃了原来用CommonsChunkPlugin
实现分包,而是在内部使用SplitChunksPlugin
进行分包。
分包流程:分包时,webpack根据分包策略,实现具体的分包,它会开启一个新的chunk,对分离的模块进行打包处理。公共代码会生成新chunk即common,最后打包成budle_common.js 如图所示:
自动分包原理:自动分包会检查每个chunk编译的结果,根据分包策略,找到那些满足策略的模块,并生成新的chunk打包这些模块,再将打包出去的模块从原来的包中移除,并修改原来包的代码
(1)分包策略的基本配置
webpack提供了optimization
配置项,用于配置一些优化信息,其中splitChunks
是分包策略的配置,其中有以下常用配置
-
chunks:该配置项用于配置需要应用分包策略的chunk,有以下三个值,默认时
async
- all: 对于所有的chunk都要应用分包策略,一般来说使用这个值
- async:仅针对异步chunk应用分包策略
- initial:仅针对普通chunk应用分包策略
- maxSize:如果一个要被分出来的包超过了该值,webpack就会尽可能的将其分成多个包。注意:分包的基础单位是模块,如果一个完整的模块超过了该体积,它是无法做到再切割的,因此,尽管使用了这个配置,完全有可能某个包还是会超过这个体积。通常不使用
- automaticNameDelimiter:新chunk名称的分隔符,默认值~
- minChunks:一个模块至少被多少个chunk使用时,才会进行分包,默认值1
- minSize:当分包达到多少字节后才允许被真正的拆分,默认值30000
module.exports = {
optimization: {
splitChunks: {
//分包配置
chunks: "all",
//maxSize: 60000
automaticNameDelimiter: ".",
minChunks: 2,
minSize: 30000
}
}
}
(2)缓存组
实际上,分包策略是基于缓存组的。每个缓存组提供一套独有的策略,webpack按照缓存组的优先级依次处理每个缓存组,被缓存组处理过的分包不需要再次分包。默认情况下,webpack提供了两个缓存组,很多时候,缓存组对于我们来说没什么意义,因为默认的缓存组就已经够用了。
webpack默认缓存组
module.exports = {
optimization:{
splitChunks: {
chunks:"all",
//全局配置
cacheGroups: {
// 属性名是缓存组名称,会影响到分包的chunk名
// 属性值是缓存组的配置,缓存组继承所有的全局配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 当匹配到相应模块时,将这些模块进行单独打包
priority: -10 // 缓存组优先级,优先级越高,该策略越先进行处理,默认值为0
},
default: {
minChunks: 2, // 覆盖全局配置,将最小chunk引用数改为2
priority: -20, // 优先级
reuseExistingChunk: true // 重用已经被分离出去的chunk
}
}
}
}
}
通过缓存组对公共样式分离:webpack.config.js
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配样式模块
minSize: 30000,
minChunks: 2
}
}
}
},
module: {
rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] }]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
}),
new MiniCssExtractPlugin({
filename: "[name].[hash:5].css",
// chunkFilename是配置来自于分割chunk的文件名
chunkFilename: "common.[hash:5].css"
})
]
}
3、代码压缩
为生产环境进行代码压缩,减少代码体积是增加传输性能必不可少的环节,进行代码压缩同时也可以破坏代码可读性,提升**成本,目前流行的代码压缩工具主要有UglifyJs
和Terser
UglifyJs
是一个传统的代码压缩工具,已存在多年,曾经是前端应用的必备工具,但由于它不支持ES6
语法,所以目前的流行度已有所下降。
Terser
是一个新起的代码压缩工具,支持ES6+
语法,因此被很多构建工具内置使用。
Terser官网:https://terser.org/
webpack
已经内置了Terser
,所以我们在启用生产环境后即可用其进行代码压缩。
webpack自动集成了Terser如果你想更改、添加压缩工具,又或者是想对Terser进行配置,使用下面的webpack配置即可
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
// 是否要启用压缩,默认情况下,生产环境会自动开启
minimize: true,
minimizer: [ // 压缩时使用的插件,可以有多个
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
},
};
4、tree shaking
代码压缩可以移除模块内部的无效代码,而tree shaking
可以移除模块之间的无效代码。比如说
// myMath.js
export function add(a, b){
console.log("add")
return a+b;
}
export function sub(a, b){
console.log("sub")
return a-b;
}
这个工具模块有两个导出方法,但是整个项目只使用了add
,如果在打包的时候两个方法都打包的话无疑会增加无效代码量,tree shaking
的作用就是移除无效的代码块。webpack2
开始就支持了tree shaking
。只要是生产环境,tree shaking
自动开启
tree shaking工作原理:webpack
会从入口模块出发寻找依赖关系,当解析一个模块时,webpack
会根据ES6的模块导入语句来判断,该模块依赖了另一个模块的哪个导出。依赖分析完毕后,webpack
会根据每个模块每个导出是否被使用,标记未使用的导出为dead code
,然后交给代码压缩工具处理。代码压缩工具最终移除掉那些dead code
代码
在具体分析依赖时,webpack
坚持的原则是:保证代码正常运行,然后再尽量tree shaking
。
我们在书写导入导出时,尽量使用以下方式:
- 使用
export xxx
导出,而不使用export default {xxx}
导出 - 使用
import {xxx} from "xxx"
导入,而不使用import xxx from "xxx"
导入
所以,如果你依赖的是一个导出的对象,由于JS语言的动态特性,以及webpack
还不够智能,为了保证代码正常运行,它不会移除对象中的任何信息。
ES6的模块导入语句:使用ES6的模块导入语句,有利于更好的分析依赖,是因为ES6模块有以下特点:
-
导入导出语句只能是顶层语句
-
import的模块名只能是字符串常量
-
import绑定的变量是不可变的
使用第三方库tree shaking注意:
某些第三方库可能使用的是commonjs
的方式导出,比如lodash
又或者没有提供普通的ES6方式导出。对于这些库,tree shaking
是无法发挥作用的。但好在很多流行的库都发布了它的ES6
版本,比如lodash-es
。我们在使用loadsh
的时候可以使用lodash-es
副作用函数(side effect):函数运行过程中,可能会对外部环境造成影响的功能。如果函数中包含以下代码,该函数叫做副作用函数:异步代码、localStorage、对外部数据的修改
纯函数(pure function):如果一个函数没有副作用,同时,函数的返回结果仅依赖参数,则该函数叫做
webpack
坚持的原则是:保证代码正常运行,然后再尽量tree shaking
。因此当webpack
无法确定某个模块是否有副作用时,它往往将其视为有副作用。当我们知道某个导出没有副作用,但是webpack
担心common.js
有副作用,如果去掉会影响某些功能,这时候我们就需要标记该文件是没有副作用的。当然,第三方插件中,一般都已经标记过了,我们无需自己添加。
在package.json
中加入sideEffects
{
"sideEffects": false
//"sideEffects": ["!src/common.js"]
}
5、懒加载
懒加载就是动态加载,按需加载,当我们需要的时候再加载。使用import()
语法。import()
会返回一个promise
。
if(Math.random()<0.5){
const {add} =await import("./utils.js")
const result = add(1,3)
}
此时,utils
工具类是等到要执行时才会引入,而不会变成顶层语句直接执行。当我们执行到import时才会到服务端请求该模块的js文件,而不是在页面加载的时候就去请求,可以减少首页加载时间过长
6、gzip
gzip是一种压缩文件的算法,当我们js文件过大的时候,就可以使用gzip的方式,对文件进行压缩,配合服务器端进行使用。
具体使用参照1:
webpack-dev-server开发服务器 和 webpack中常用plugin和loader一文中的compression-webpack-plugin
插件
具体使用参照2:
webpack+nginx实现gzip压缩解决vue首屏加载过慢
上一篇: JavaScript中的事件循环机制:你不得不懂的JS原理
下一篇: Docker 十分钟快速入门