前言

啊,不出意外的话,这应该是这个系列的最后一篇了,或许接下来还会有搭Vue或者React项目的文章,但是我感觉不一定能弄出来,嘛,这篇文章会介绍一些常见的性能优化方法,现在应该能给出的内容不多,以后如果看到了我再慢慢补充(虽然我基本都是用脚手架的)。

安装Bundle Analyzer

这里我们使用Bundle Analyzer查看打包体积

Visualize size of webpack output files with an interactive zoomable treemap.

官方文档:https://www.npmjs.com/package/webpack-bundle-analyzer

Webpack Bundle Analyzer是一个可以对打包结果进行可视化的工具,为了能更直观的看到打包结果,我们要先装一下这个东西(当然不装也行)

安装

1
2
3
4
# NPM
npm install --save-dev webpack-bundle-analyzer
# Yarn
yarn add -D webpack-bundle-analyzer

使用

1
2
3
4
5
6
7
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}

打包后自动打开一个页面

image-20210115133435802

  • stat:原文件体积
  • parsed:打包后的文件体积
  • gzipped:使用gzip压缩后的文件体积

鼠标移上去还可以看到模块的相关信息

image-20210115133621042

有点可惜的是,SpeedMeasurePlugin现在还不支持Webpack5,如果你用的是webpack4,可以装一个看看打包时间

(orz,我当初学webpack时还是webpack4,现在都5了)

优化指标

性能优化主要体现在三个方面:

2020-02-12-09-53-01

构建性能

这里所说的构建性能,是指在开发阶段的构建性能,也就是是降低从打包开始,到代码效果呈现所经过的时间

构建性能会影响开发效率。构建性能越高,开发过程中时间的浪费越少

传输性能

传输性能是指,打包后的JS代码传输到浏览器经过的时间

在优化传输性能时要考虑到:

  1. 总传输量:所有需要传输的JS文件的内容加起来,就是总传输量,重复代码越少,总传输量越少
  2. 文件数量:当访问页面时,需要传输的JS文件数量,文件数量越多,http请求越多,响应速度越慢
  3. 浏览器缓存:JS文件会被浏览器缓存,被缓存的文件不会再进行传输

运行性能

运行性能是指,JS代码在浏览器端的运行速度

它主要取决于我们如何书写高性能的代码

这部分就不是我们在这里需要关注的内容了,是体现在平时写代码的过程中的

构建性能

减少模块解析

还记得webpack的打包流程吗

2020-02-13-16-26-41

其中蓝色方框圈起来的部分就是模块解析的部分,包括抽象语法树分析、依赖分析、模块语法替换

如果我们对某个模块不进行模块解析,那么对这个模块的打包过程就会变成这样

2020-02-13-16-28-10

如果某个模块不做解析,该模块经过loader处理后的代码就是最终代码。

那么,如果不对某个模块进行解析,就可以缩短构建时间了

我们可以配置noParse 这个配置来让webpack不解析某个模块,只要能匹配上就不会进行解析

文档:https://webpack.docschina.org/configuration/module/#modulenoparse

1
2
3
4
5
6
module.exports = {
//...
module: {
noParse: /jquery|lodash/,
}
};

或者

1
2
3
4
5
6
module.exports = {
//...
module: {
noParse: (content) => /jquery|lodash/.test(content)
}
};

我试了一下,匹配用的是全路径,所以理论上可以配置成/node_modules/

image-20210115153342120

这个配置要求模块中没有其他依赖,比如一些已经打包好的第三方库,比如jquery,不然有可能导致打包出来的代码不能正常工作

减少loader处理

如同noParse一样,我们可以设置对某些库不使用loader处理

例如:babel-loader可以转换ES6或更高版本的语法,可是对于node_module里的模块来说,大部分都是已经被编译成ES5的,以此没有必要再使用babel-loader来处理

这时候我们通过module.rule.excludemodule.rule.include,排除或仅包含需要应用loader的场景

include:代表该规则只对正则匹配到的模块生效

exclude:代表所有被正则匹配到的模块都排除在该规则之外

比如说

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_module/,
use: "babel-loader"
}
]
}
}

如果exclude和include同时存在,exclude的优先级更高

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_module/,
include : /node_module\/foo/,
use: "babel-loader"
}
]
}
}

这里因为已经排除掉了node_module,include就无法生效了

正确操作是,使用exclude对include的子目录进行排除

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
module: {
rules: [
{
test: /\.js/,
include : /src/,
exclude : /src\/libs/,
use: "babel-loader"
}
]
}
}

一般我们会排除node_modules目录中的模块,或设置仅转换src目录的模块

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//或
// include: /src/,
use: "babel-loader"
}
]
}
}

这种做法和noParse不冲突,因为noParse是不进行模块解析,exclude是控制是否应用loader

缓存loader的结果

我们可以基于一种假设:如果某个文件内容不变,经过相同的loader解析后,解析后的结果也不变

于是,可以将loader的解析结果保存下来,让后续的解析直接使用保存的结果

这里我们使用cache-loader

The cache-loader allow to Caches the result of following loaders on disk (default) or in the database.

文档:https://www.npmjs.com/package/cache-loader

1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', ...loaders]
},
],
},
};

也许你会有点奇怪,loader不是从后向前执行的吗,cache-loader放在最前面,为什么可以决定后续的loader是否运行呢

嘛,其实loader运行过程中还包含一个过程pitch

2020-02-21-13-32-36

如果你有兴趣,可以康康官方文档:https://www.webpackjs.com/api/loaders/#%E8%B6%8A%E8%BF%87-loader-pitching-loader-,我直接放个图好了

image-20210115165603330

为loader开启多线程

这里我们介绍thread-loader

Runs the following loaders in a worker pool.

官方文档:https://www.npmjs.com/package/thread-loader

thread-loader会开启一个线程池,线程池中包含适量(默认是根据CPU的核心数确定的)的线程

它会把后续的loader放到线程池的线程中运行,以提高构建效率

由于后续的loader会放到新的线程中,所以,后续的loader不能:

  • 使用 webpack api 生成文件
  • 无法使用自定义的 plugin api
  • 无法访问 webpack options

在实际的开发中,可以进行测试,来决定thread-loader放到什么位置

特别注意,开启和管理线程需要消耗时间,在小型项目中使用thread-loader反而会增加构建时间,所以尽量放在开销比较大的操作上面

但是我现在用的webpack5,装不了Speed Measure Plugin,就很自闭,确认不了时间

开启Webpack缓存

Webpack5多了一个缓存的配置,可以在这里查看

https://webpack.js.org/configuration/other-options/#cache

1
2
3
4
5
6
7
8
9
const path = require('path');

module.exports = {
//...
cache: {
type: 'filesystem',
cacheDirectory: path.resolve(__dirname, '.temp_cache')
}
};

(不知道是不是我的错觉,我感觉还变卡了)

模块热替换

https://webpack.js.org/guides/hot-module-replacement/

在早起开发工具和调试工具都比较简单和匮乏的时候,调试代码的方式基本都是改代码 -> 刷新网页查看结果 -> 再改代码,这样反复地修改和测试。后来一些Web开发框架和工具提供了更便捷的方式,只要检测到代码改动就会自动重新构建,然后触发网页刷新。这种一般被称为live reload。Webpack则在live reload的基础上又进了一步,可以让代码在网页不刷新的前提下得到最新的改动,这就是模块的热替换功能(Hot Module Replacement,HMR)。

HMR对于大型应用尤其应用,试想一个复杂的系统每改动一个地方都要经历资源重新构建,网络请求,浏览器渲染等过程,怎么也要几秒甚至几十秒的时间才能完成,况且我们调试的页面可能位于很深的层级,每次还要通过一些人为操作才能验证结果,其效率是非常低下的。而HMR则可以在保留页面当前状态的前提下呈现出最新的改动,可以节省开发者大量的时间成本。

添加下面的配置

1
2
3
4
5
6
7
8
module.exports = {
devServer: {
hot:true // 开启HMR
},
plugins:[
new webpack.HotModuleReplacementPlugin()
]
}

使用了上面的配置后,Webpack会为每个模块绑定一个module.hot对象,这个对象包含了HMR的API,使用这些API我们可以对特定模块开启或关闭HMR,也可以添加热替换之外的逻辑。比如,得知某个应用的某个模块更新了,为了保证更新后的代码能正常工作,我们可能还要添加一些额外的处理。

调用HMR API 有两种方式,一种是手动地添加这部分代码,另一种是借助一些现成的工具,比如react-hot-loader,vue-loader

如果应用的逻辑比较简单,直接手动添加代码来开启HMR即可

index.js

1
2
3
4
if (module.hot) {
// 是否开启了热更新
module.hot.accept(); // 接受热更新
}

如果index.js是应用的入口,那么我们就可以把调用HMR API的代码放在该入口,这样HMR对于index.js和其所依赖的模块都会生效,当发现有模块发生变动时,HMR会使当前浏览器重新执行一遍index.js (包括其依赖) 的内容,但是页面本身不会刷新

大多数时候,还是建议开发者使用第三方提供的HMR解决方案,因为HMR触发过程中有可能会有很多预料不到的问题,导致模块更新后应用的表现和正常加载的表现不一致。为了解决这类问题,Webpack社区中已经有许多相应的工具提供了解决方案,比如react组件的热更新由react-hot-loader来处理,我们直接拿来用就行

效果就是浏览器在不刷新的情况下可以更新模块

image-20210117221049260

下面介绍下HMR的工作原理,在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于是我们的服务端,HMR的核心是客户端从服务端拉取更新后的资源(准确的说,HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分)。

那么浏览器什么时候去拉取这些更新呢,这需要WDS对本地源文件进行监听,实际上WDS和浏览器和浏览器间维护了一个websocket,当本地资源发生变化时WDS会向浏览器推送更新事件,并带上这次构建的hash,让客户端与上一次资源进行比对,通过hash的比对可以防止亢余更新的出现(因为很多时候源文件的更改,并不一定代表构建结果的更改,比如添加了一个文件末尾空行等)。

image-20210117232203047

这同时解释了为什么我们开启多个本地页面时,代码一改所有页面都会刷新,当然websocket并不是只有开启了HMR才会有,live reload也是依赖这个实现而已。

有了恰当的拉取资源的时机,下一步就是要知道拉取什么,这部分信息没有包含在刚刚的Websocket中,因为刚刚我们只是想知道这次构建的结果是不是和上次一样,现在客户端已经知道新的构建结果有了差别,就会向WDS发起一个请求来获取更改文件的列表,即那些模块做了改动。通常这个请求的名字为[hash].hot-update.json

image-20210117234611554

image-20210117234657528

该返回结果会告诉客户端,需要更新的chunk为main,版本为(构建hash)636faa08213a32eeb34a,这样客户端就可以再借助这些信息向WDS获取该chunk的增量更新

image-20210117234834978

image-20210117234914714

在客户端收到chunk的更新后,就要处理怎么处理这些增量更新的问题了,那些状态需要保留,哪些状态需要更新,但是这些不是属于webpack的工作了,它提供了相关的API(比如module.hot.accept),开发者可以使用这些API针对自身场景进行处理。react-hot-loader和vue-loader也是借助这些API实现的HMR

顺带一提,如果想对样式使用热替换,就要使用style-loadermini-css-extract-plugin生成文件是在构建期间,运行期间无法改动文件,以此热替换是无效的

传输性能

使用CDN

这里我们使用jquery做一些演示

我们新建一个home.js

1
2
3
4
5
6
7
import _ from "lodash";
import $ from "jquery";
console.log(_.clone({
name : "SakuraSnow",
age : "20"
}));
console.log($);

然后修改index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import jquery from "jquery";
import _ from "lodash";
import "./style/index.css";
import add from "@/script/add";

console.log(jquery);
console.log(add(1,2));
console.log(_.clone({
name : "sena",
age : "16"
}))

class A {
name = "";
constructor(name) {
this.name = name;
}
}

修改webpack.config.js

1
2
3
4
5
6
module.exports = {
entry: {
index: "./index.js",
home : "./home.js"
},
}

运行npm run build,查看打包结果

image-20210116105751325

这时候我们可以使用CDN把jquery这个模块提取出来

webpack.config.js

1
2
3
4
5
module.exports = {
externals: {
jquery: "$"
},
}

在页面手动引入jquery

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
</head>
<body>

</body>
</html>

打包结果

img

应该很好理解吧,webpack不会打包这个库到最终的打包结果里,而是导出页面中的一个值

手动分包

在这里我们使用lodash这个库做一些演示

现在的打包结果是这样的,我们用手动分包来优化一下lodash这个模块

image-20210116105751325

可以看到,lodash被打包到了两个chunk里,最后生成的文件里,实际上lodash的代码是重复的,这就不利于我们传输,而且对于这种不常变动的三方库,我们希望能单独分出一个文件,这样就可以充分利用浏览器缓存来减少传输的体积

所以要分包的情况总结一下就是下面两点

  • 多个chunk引入的公共模块,希望减少传输体积
  • 公共模块体积较大,或者变动较少,希望能利用缓存

下面讲讲怎么进行分包,其实原理和上面的使用CDN差不多,只是要自己先把要提取出来的库打包一下而已

打包公共模块

打包公共模块是一个独立的打包过程

新建webpack.dll.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const webpack = require("webpack");
const path = require("path");

module.exports = {
mode: "production",
entry: {
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js", // 打包的库输出到哪里
library: "[name]", // 每个bundle暴露的全局变量名
libraryTarget: "var" // 暴露的方式
},
plugins: [
// 利用DllPlugin生成资源清单
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"), // 资源清单文件保存的位置
name: "[name]" // 资源清单中暴露的变量名
})
]
};

加个命令

1
2
3
4
5
6
7
{
"scripts": {
"build": "webpack",
"dll": "webpack --config webpack.dll.config.js",
"server": "webpack serve"
},
}

运行npm run dll打包

image-20210116113933753

dll库里暴露了一个全局变量,资源清单里描述了这个dll库,包括暴露的变量名

引用公共模块

在页面中手动引入公共模块

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="./dll/lodash.js"></script>
</head>
<body>
<img src="../assets/img.png">
</body>
</html>

如果使用了插件clean-webpack-plugin,为了避免它把公共模块清除,需要做出以下配置

1
2
3
4
5
new CleanWebpackPlugin({
// 要清除的文件或目录
// 排除掉dll目录本身和它里面的文件
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})

匹配规则:globbing patterns

关联公共模块到打包结果

使用DllReferencePlugin关联到打包结果

1
2
3
4
5
6
7
8
9
10
module.exports = {
plugins:[
// new webpack.DllReferencePlugin({
// manifest: require("./dll/jquery.manifest.json")
// }),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}

重新运行npm run build打包即可

image-20210116131024861

(如果你的打包结果有问题,可以试试去掉context这个配置,开始我打包一直没用,后来去掉才好了)

看看打包结果

image-20210116131858929

image-20210116131916607

应该挺好看懂的,原理和上面使用CDN的差不多

放个配置文件webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
const path = require("path");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require("webpack");

const cssLoaderConfig = {
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:5]"
}
}
};
const webpackConfig = {
entry: {
index: "./src/index.js",
home : "./src/home.js"
},
mode: "development",
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[chunkhash:5].js",
publicPath: "/"
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
port: 9000,
open: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
]
},
{
test: /\.m?js$/,
exclude: /node_modules/,
use: [
{
loader: "cache-loader",
options: {
cacheDirectory: "./loader-cache"
}
},
"thread-loader",
"babel-loader"
]
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"sass-loader",
]
},
{
test: /\.less$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"less-loader",
]
},
{
test: /\.pcss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
]
},
{
test: /\.(png)|(gif)|(jpg)$/,
use: [{
loader: "url-loader",
options: {
limit: 10 * 1024,
name: "imgs/[name].[contenthash:5].[ext]"
}
}]
}],
noParse: (content) => {
return /jquery|lodash/.test(content)
}
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/public/index.html",
filename: "html/index.html",
chunks: ["index"]
}),
new CopyWebpackPlugin({
patterns: [
{
from: "./src/assets",
to: "./assets"
},
],
}),
new webpack.ProvidePlugin({
$: 'jquery'
}),
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:5].css"
}),
new BundleAnalyzerPlugin({
openAnalyzer: false,
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
],
resolve: {
alias: {
"@": path.resolve(__dirname, 'src')
}
},
externals: {
jquery: "$"
},
stats: {
builtAt: true,
},
}

module.exports = webpackConfig;

自动分包

不同与手动分包,自动分包是从实际的角度出发,从一个更加宏观的角度来控制分包,而一般不对具体哪个包要分出去进行控制,因此使用自动分包,不仅非常方便,而且更加贴合实际的开发需要。
要控制自动分包,关键是要配置一个合理的分包策略,有了分包策略之后,不需要额外安装任何插件,webpack会自动的按照策略进行分包

webpack在内部是使用SplitChunksPlugin进行分包的
webpack文档:https://webpack.js.org/configuration/optimization/#optimizationsplitchunks
SplitChunksPlugin:https://webpack.js.org/plugins/split-chunks-plugin/

2020-02-24-17-19-47

从分包流程中至少可以看出以下几点:

  • 分包策略至关重要,它决定了如何分包
  • 分包时,webpack开启了一个新的chunk,对分离的模块进行打包
  • 打包结果中,公共的部分被提取出来形成了一个单独的文件,它是新chunk的产物

基本配置

Webpack提供了optimization配置项,用于配置一些优化信息

其中splitChunks是分包策略的配置

1
2
3
4
5
6
7
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}

事实上,分包策略有其默认的配置,我们只需要轻微的改动,即可应对大部分分包场景

  1. chunks

该配置项用于配置需要应用分包策略的chunk

我们知道,分包是从已有的chunk中分离出新的chunk,那么哪些chunk需要分离呢

chunks有三个取值,分别是:

  • all: 对于所有的chunk都要应用分包策略
  • async:【默认】仅针对异步chunk应用分包策略
  • initial:仅针对普通chunk应用分包策略

所以,只需要配置chunksall即可

  1. maxSize

该配置可以控制包的最大字节数,如果某个包(包括分出来的包)超过了该值,则webpack会尽可能的将其分离成多个包

但是,分包的基础单位是模块,如果一个完整的模块超过了该体积,它是无法做到再切割的,因此,尽管使用了这个配置,完全有可能某个包还是会超过这个体积

因为分包的目的是提取大量的公共代码,从而减少总体积和充分利用浏览器缓存

虽然该配置可以把一些包进行再切分,但是实际的总体积和传输量并没有发生变化,反而容易切得太开还要多新建几条连接来传输

修改webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
const path = require("path");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require("webpack");

const cssLoaderConfig = {
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:5]"
}
}
};
const webpackConfig = {
entry: {
index: "./src/index.js",
home : "./src/home.js"
},
mode: "development",
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[chunkhash:5].js",
publicPath: "/"
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
port: 9000,
open: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
]
},
{
test: /\.m?js$/,
exclude: /node_modules/,
use: [
{
loader: "cache-loader",
options: {
cacheDirectory: "./loader-cache"
}
},
"thread-loader",
"babel-loader"
]
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"sass-loader",
]
},
{
test: /\.less$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"less-loader",
]
},
{
test: /\.pcss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
]
},
{
test: /\.(png)|(gif)|(jpg)$/,
use: [{
loader: "url-loader",
options: {
limit: 10 * 1024,
name: "imgs/[name].[contenthash:5].[ext]"
}
}]
}],
noParse: (content) => {
return /jquery|lodash/.test(content)
}
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/public/index.html",
filename: "html/index.html",
chunks: ["index"]
}),
new CopyWebpackPlugin({
patterns: [
{
from: "./src/assets",
to: "./assets"
},
],
}),
new webpack.ProvidePlugin({
$: 'jquery'
}),
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:5].css"
}),
new BundleAnalyzerPlugin({
openAnalyzer: false,
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, 'src')
}
},
stats: {
builtAt: true,
},
optimization: {
splitChunks: {
chunks: "all",
maxSize: 50 * 1024
}
}
}

module.exports = webpackConfig;

可以看出来jquery,lodash还有babel-runtime都被单独提取出来了

image-20210116141137625

我们看看打包结果

image-20210116141513265

image-20210116141906000

image-20210116142007058

很明显,不同包之间的导入导出也是通过window上的变量来实现的,和上面手动分包的原理一样

其他配置

如果不想使用其他配置的默认值,可以手动进行配置,下面是一些常用的配置

  • automaticNameDelimiter:新chunk名称的分隔符,默认值~
  • minChunks:一个模块被多少个chunk使用时,才会进行分包,默认值1
  • minSize:当分包达到多少字节后才允许被真正的拆分,默认值30000

缓存组

上面我们配置的都是全局策略,但是其实策略是基于缓存组的,每一个缓存组有不同的配置,webpack按照缓存组优先级进行分包处理,处理过一次的包就不会再进行分包了

之前配置的分包策略是全局的

而实际上,分包策略是基于缓存组的

每个缓存组提供一套独有的策略,webpack按照缓存组的优先级依次处理每个缓存组,被缓存组处理过的分包不需要再次分包

默认情况下,webpack提供了两个缓存组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
optimization:{
splitChunks: {
//全局配置
cacheGroups: {
// 属性名是缓存组名称,会影响到分包的chunk名
// 属性值是缓存组的配置,缓存组继承所有的全局配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 当匹配到相应模块时,将这些模块进行单独打包
priority: -10 // 缓存组优先级,优先级越高,该策略越先进行处理,默认值为0
},
default: {
minChunks: 2, // 覆盖全局配置,将最小chunk引用数改为2
priority: -20, // 优先级
reuseExistingChunk: true // 重用已经被分离出去的chunk
}
}
}
}
}

很多时候,缓存组对于我们来说没什么意义,因为默认的缓存组就已经够用了

但是我们同样可以利用缓存组来完成一些事情,比如对公共样式的抽离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配样式模块
minSize: 0, // 覆盖默认的最小尺寸,这里仅仅是作为测试
minChunks: 2 // 覆盖默认的最小chunk引用数
}
}
}
},
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",
// 用于配置来自于分割chunk的文件名
chunkFilename: "common.[hash:5].css"
})
]
}

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
const path = require("path");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const webpack = require("webpack");

const cssLoaderConfig = {
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:5]"
}
}
};
const webpackConfig = {
entry: {
index: "./src/index.js",
home : "./src/home.js"
},
mode: "development",
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[chunkhash:5].js",
publicPath: "/"
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
port: 9000,
open: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
]
},
{
test: /\.m?js$/,
exclude: /node_modules/,
use: [
{
loader: "cache-loader",
options: {
cacheDirectory: "./loader-cache"
}
},
"thread-loader",
"babel-loader"
]
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"sass-loader",
]
},
{
test: /\.less$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"less-loader",
]
},
{
test: /\.pcss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
]
},
{
test: /\.(png)|(gif)|(jpg)$/,
use: [{
loader: "url-loader",
options: {
limit: 10 * 1024,
name: "imgs/[name].[contenthash:5].[ext]"
}
}]
}],
noParse: (content) => {
return /jquery|lodash/.test(content)
}
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/public/index.html",
filename: "html/index.html",
chunks: ["index"]
}),
new CopyWebpackPlugin({
patterns: [
{
from: "./src/assets",
to: "./assets"
},
],
}),
new webpack.ProvidePlugin({
$: 'jquery'
}),
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:5].css",
chunkFilename: "common.[hash:5].css"
}),
new BundleAnalyzerPlugin({
openAnalyzer: false,
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
}),
// new webpack.DllReferencePlugin({
// manifest: require("./dll/jquery.manifest.json")
// }),
// new webpack.DllReferencePlugin({
// manifest: require("./dll/lodash.manifest.json")
// })
],
resolve: {
alias: {
"@": path.resolve(__dirname, 'src')
}
},
// externals: {
// jquery: "$"
// },
stats: {
builtAt: true,
},
optimization: {
splitChunks: {
chunks: "all",
maxSize: 50 * 1024,
cacheGroups: {
styles: {
test: /\.css$/, // 匹配样式模块
minSize: 0, // 覆盖默认的最小尺寸,这里仅仅是作为测试
minChunks: 2 // 覆盖默认的最小chunk引用数
}
}
},

}
}

module.exports = webpackConfig;

打包结果

image-20210116144245077

按需引入

用我在工作室写的一个项目举例子

1
2
3
import ElementUI from 'element-ui'
import './styles.scss'
Vue.use(ElementUI);

image-20210221164256000

如果导入整个Element,就会让打包的代码变大

如果按需引入

1
2
3
import {Message} from "element-ui";
Vue.use(Message);
Vue.prototype.$message = Message;

Element-UI的打包体积就会小很多

image-20210221164534237

代码压缩

webpack-代码压缩:https://webpack.js.org/configuration/optimization/#optimizationminimize

Terser官网:https://terser.org/

代码压缩应该算是生产环境必备的东西了,它的好处主要是下面两点

  • 减少代码体积
  • 破坏代码的可读性,提升破解成本

webpack在生产环境自动开启代码压缩,所以你配置一下mode就行

1
2
3
module.exports = {
mode: 'production'
};

如果你想在开发环境开启它,进行下面的配置

1
2
3
4
5
module.exports = {
optimization: {
minimize: true
}
};

打包一下

image-20210116150912037

可以看到代码已经压缩了,但是只压缩了JS文件,因为Terser只支持压缩JS

如果想更改、添加压缩工具,又或者是想对Terser进行配置,可以使用下面的webpack配置

1
2
3
4
5
6
7
8
9
10
11
12
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
// 是否要启用压缩,默认情况下,生产环境会自动开启
minimize: true,
minimizer: [ // 压缩时使用的插件,可以有多个
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
},
};

现在CSS也是被压缩过的了

image-20210116151905153

减少重复的三方库打包

严格来说这个问题我没有遇到过,不过我舍友在实验室遇到过链接,我就顺手看了一下怎么解决

image-20210126212734717

可以使用下面的配置

1
2
3
4
5
6
7
module.exports = {
resolve : {
alias : {
'bn.js' : path.resolve(process.cwd(), 'node_modules', 'bn.js')
}
}
}

vue脚手架中这么设置别名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const path = require('path');
function resolve(dir) {
return path.join(__dirname, dir);
}
module.exports = {
lintOnSave: true,
chainWebpack: (config) => {
config.resolve.alias
.set('@img', resolve('src/assets/img'))
.set('@assets',resolve('src/assets'))
.set('@style', resolve("src/assets/style"));
if (process.env.use_analyzer) {
config.plugin('webpack-bundle-analyzer')
.use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
}
}
};

Tree Shaking

https://webpack.js.org/guides/tree-shaking/

Tree-shaking的本质是消除无用的js代码,只要是生产环境,tree shaking自动开启

1
2
3
module.exports = {
mode: 'production'
};

那么Tree Shaking大概是怎么运行的呢

其实很简单,webpack会从入口模块出发寻找依赖关系,当解析一个模块时,webpack会根据ES6的模块导入语句来判断,该模块依赖了另一个模块的哪个导出

Webpack之所以选择ES6的模块导入语句,是因为ES6模块有以下特点:

  1. 导入导出语句只能是顶层语句
  2. import的模块名只能是字符串常量
  3. import绑定的变量是不可变的

比如说,你可以有下面的代码

1
2
3
if (Math.random() < 0.5) {
require("./util/index.js")
}

但是你绝对不能这么写

1
2
3
if (Math.random() < 0.5) {
import "./util/index.js";
}

这些特征都非常有利于分析出稳定的依赖

在具体分析依赖时,webpack坚持的原则是:保证代码正常运行,然后再尽量tree shaking

所以,如果你依赖的是一个导出的对象,由于JS语言的动态特性,以及webpack还不够智能,为了保证代码正常运行,它不会移除对象中的任何信息

因此,我们在编写代码的时候,最好做到下面几点:

  • 使用import而不是使用require
  • 使用export xxx导出,而不使用export default {xxx}导出
  • 使用import {xxx} from "xxx"导入,而不使用import xxx from "xxx"导入

依赖分析完毕后,webpack会根据每个模块每个导出是否被使用,标记其他导出为dead code,然后交给代码压缩工具处理

代码压缩工具最终移除掉那些dead code代码


尽可能使用ES版本的三方库

顺带一提,如果某些库有es的版本,尽可能使用es的版本,比如lodash,这样会方便依赖分析,更容易Tree shaking

比如下面的场景

index.js

1
2
3
4
5
6
7
8
import lodash from "lodash";
import lodashES from "lodash-es";
console.log(lodash.clone({
name : "sena"
}));
console.log(lodashES.clone({
name : "sena"
}))

我们使用两个版本的lodash进行clone操作

打包结果

image-20210116213701898

lodash有547k,而lodash-es打包出来的只有17k,可以说非常明显了

使用IgnorePlugin进一步排除文件

1
2
import moment from "moment";
console.log(moment().format('MMMM Do YYYY, h:mm:ss a'));

moment是一个非常常用的时间处理库,但是它的打包结果非常蛋疼

image-20210116214703242

这个库的打包结果会包含很多我们用不上的语言包,我们可以用IgnorePlugin这个插件来处理它

1
2
3
4
5
6
7
8
9
10
11
const webpack = require('webpack');
module.exports = {
plugins: [
// 忽略 moment.js的所有本地文件
new webpack.IgnorePlugin({
resourceRegExp : /^\.\/locale$/, // 匹配资源文件
contextRegExp : /moment$/ // 匹配检索目录
}),
],
};

这样打包结果中就不含那些语言库了

image-20210116215724472

懒加载

这里我们用MathJS这个库做演示

index.js

1
2
3
4
document.querySelector("button").addEventListener("click",() => {
const MathJS = import("MathJS");
console.log(MathJS);
});

在webpack.config.js中,我们把mode改成development,然后去掉自动分包相关的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
const path = require("path");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const webpack = require("webpack");

const cssLoaderConfig = {
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:5]"
}
}
};
const webpackConfig = {
entry: {
index: "./src/index.js",
home : "./src/home.js"
},
mode: "development",
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[chunkhash:5].js",
publicPath: "/"
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
port: 9000,
open: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
]
},
{
test: /\.m?js$/,
exclude: /node_modules/,
use: [
{
loader: "cache-loader",
options: {
cacheDirectory: "./loader-cache"
}
},
"thread-loader",
"babel-loader"
]
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"sass-loader",
]
},
{
test: /\.less$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
"less-loader",
]
},
{
test: /\.pcss$/i,
use: [
MiniCssExtractPlugin.loader,
cssLoaderConfig,
"postcss-loader",
]
},
{
test: /\.(png)|(gif)|(jpg)$/,
use: [{
loader: "url-loader",
options: {
limit: 10 * 1024,
name: "imgs/[name].[contenthash:5].[ext]"
}
}]
}],
noParse: (content) => {
return /jquery|lodash/.test(content)
}
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/public/index.html",
filename: "html/index.html",
chunks: ["index"]
}),
new CopyWebpackPlugin({
patterns: [
{
from: "./src/assets",
to: "./assets"
},
],
}),
new webpack.ProvidePlugin({
$: 'jquery'
}),
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:5].css",
chunkFilename: "common.[hash:5].css"
}),
new BundleAnalyzerPlugin({
openAnalyzer: false,
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
}),
// new webpack.DllReferencePlugin({
// manifest: require("./dll/jquery.manifest.json")
// }),
// new webpack.DllReferencePlugin({
// manifest: require("./dll/lodash.manifest.json")
// })
],
resolve: {
alias: {
"@": path.resolve(__dirname, 'src')
}
},
// externals: {
// jquery: "$"
// },
stats: {
builtAt: true,
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
// splitChunks: {
// chunks: "all",
// maxSize: 50 * 1024,
// cacheGroups: {
// styles: {
// test: /\.css$/, // 匹配样式模块
// minSize: 0, // 覆盖默认的最小尺寸,这里仅仅是作为测试
// minChunks: 2 // 覆盖默认的最小chunk引用数
// }
// }
// },

}
}

module.exports = webpackConfig;

运行打包命令

image-20210116211925029

可以看到,webpack自动把MathJS这个包单独打包出去了,并不在index这个chunk里

在页面中也可以看到,在没有点击按钮前,是没有加载MyMath库的

image-20210116212243218

点击按钮后,才会使用JSONP的方法加载这个包

image-20210116212423361

查看index.js打包的结果

image-20210116212918075

最后转化成了Promise来执行,等到MathJS加载出来后才打印console.log(MathJS)

所以如果你想减少刚刚打开页面时的资源加载数量,可以使用懒加载的方法

gzip

有的时候,我们可以把一些文件压缩后再传给浏览器,gzip就是一种压缩文件的算法

这个方式虽然可以提高传输效率,但是服务器的压缩和客户端的解压都需要时间

2020-02-28-15-37-26

所以慎重使用

我们可以使用CompressionWebpackPlugin来进行预压缩,压缩后就可以移除服务器的压缩时间

1
2
3
4
5
6
7
8
9
const CompressionWebpackPlugin = require("compression-webpack-plugin")
module.exports = {
plugins : [
new CompressionWebpackPlugin({
test: /\.js/,
minRatio: 0.8
})
]
}

运行npm run build

打包结果里就有了对应的gz文件

image-20210116221856477

后记

断断续续总算弄完了,虽然最近奇怪的事情有点多,不过还是告一段落了,github我会在明天睡醒时丢上来,咳,太晚了,睡了睡了QAQ