前言:
自从前端抛弃石器时代进入工业时代以后, 构建工具就已经跟我们前端开发紧密联系在一起了, 而其中脱颖而出的
webpack
也是目前市面上使用率高达百分之九十的构建工具, 虽然我们每天都在使用webpack
, 但是你的webpack
真的用对了吗? 举个例子, 你知道怎么去缩短webpack
的构建时间吗? 你知道怎么去通过webpack
去优化用户加载页面的时间吗? 可能有的同学会说, 我在公司都不需要去做webpack
配置, 那可能是你的公司太烂或者你的位置太低, 无论你想要进大厂或者是想在大厂升职, 性能优化绝对是你跑不掉的一个大点, 就好比你在小公司没有人会在乎会不会算法, 而大厂却对算法紧追不舍一样, 性能优化也是你成为高级前端的必修课
在前端领域, 性能优化大概分散在三个纬度:
- 构建性能:
可能某些同学有做过大项目, 然后每次去公司敲
yarn dev / start
命令直接要等好几分钟, 整个工程才会帮我们开启localhost:8000
服务器, 才能进行开发, 每次改动了工程文件, 页面中都要反应十多秒才能加载出来, 这些都是构建性能过慢的体现, 这种十多秒单看起来不怎么样, 一个月你大概会浪费接近两天的时间在等项目构建, 这两天你拿去打王者荣耀他不香吗, 拿去学习他不爽吗? 所以优化构建性能是非常重要的, 构建性能越高, 你的代码从打包到呈现到开发服务器的时间就越短 - 传输性能:
这个不用多说, “页面响应速度能不能快一点”这句话产品经理都说倦了~, 传输性能越高, 用户访问你的应用从白屏到呈现内容的时间就越短
- 运行性能:
运行性能越高, 用户使用你的应用就越流畅不卡顿, 用户粘性高了, 公司钱就多了, 而做优化的你也就升职加薪走上人生巅峰了
其中运行性能跟我们书写代码的方式是跑不掉的, 在本章节里我们就不多聊, 如果想了解如何优化运行性能, 我推荐大家看一本书《高性能JavaScript》
而在webpack中, 性能优化主要聚焦在构建性能和辅助传输性能上, 所以我们就切入webpack, 聊聊webpack有哪些骚操作帮助我们做性能优化, 主要的交流点如下
在本篇博文中, 我们会一起交流如下几点:
- 减少不必要的模块解析
- 优化loader执行性能
- 热替换
- 分包
减少不必要的模块解析
首先, 我们必须要明白什么叫做模块解析, 嗯, 既然来看webpack
性能优化了, 我相信你对webpack
的编译原理已经有一定的认知了, 不过我还是画了一张图来帮你加深一下印象
在上面我用红虚线框框在一起的部分, 其实就是
webpack
做模块解析的流程
知道了什么是模块解析, 我们当然还要知道, 不做模块解析的后果是什么, 很简单, 不做模块解析的模块将不会参与AST词法分析, 也不会去解析他的依赖, 更不会替换字符串, 简而言之, 如果你对一个模块不进行模块解析, 则通过
loader
转换后的代码就是最终webpack
对该模块的解析结果
那可能有的同学就会说了, 那这不行啊, 假如我A模块依赖了B, C模块, 那不做模块解析的话那require
代码不就不能被替换为webpack
自己的webpack_require
, 那在浏览器还咋用, 你别急, 我没说所有的模块都不做模块解析, 而是有些模块我们并不需要再做模块解析了
我们来看一个webpack
的经典使用场景
我们建立一个工程webpack_test
, 然后在工程中加入jquery
, 我们来看看使用再工程中引入jquery
以后打包结果会是怎样的
yarn init -y # 初始化package.json
yarn add webpack webpack-cli -D # 安装webpack依赖
yarn add jquery -S # 安装jquery依赖
我们可以直接在src中新建一个index.js
, 然后引入jquery
// src/index.js
import $ from "jquery";
console.log("jquery", $);
然后我们直接运行webpack
进行打包
# 你也可以在package.json中配置脚本来编译代码
# 我就直接使用npx了
npx webpack --mode=development
我们来看一下打包结果
我们发现打包耗时
225ms
, 毋庸置疑, webpack
对入口模块index.js
和index.js
依赖的模块jquery
都做了模块解析
那么jquery
真的有必要在进行一次词法分析和依赖分析吗? 我们打开node_modules
中的jquery
看看
他的
package.json
中的main
字段指向了dist/jquery.js
然后我们可以打开
jquery
目录下的dist
文件夹看看, 我们会发现这哥们没有任何的模块化代码, 也没有任何的敏感字段, 他的依赖都在他自己的立即函数里
如图, 我们可以理解为
jquery
的代码已经被他自己进行模块解析过了, 依赖都被收集进了立即执行函数中, 该降级的代码都降级了, 这样的代码我们是不是无论什么环境都可以兼容了, 那么webpack
还有必要重新去解析一下这个立即执行函数中的内容吗? 当然是没有必要, 所以我们要在模块解析的时候排除jquery
模块
要排除不需要进行模块解析的模块只需要在
webpack
配置中进行noParse
配置
noParse
配置文档: webpack.js.org/configurati…
我们新建一个webpack.config.js
// webpack.config.js
module.exports = {
mode: "development",
module: {
// 如果要排除多个模块, 继续用正则匹配就行, 比如/jquery|lodash/
noParse: /jquery/, // 这样就排除掉了jquery模块
}
}
这个时候我们再来进行一次打包
npx webpack # 由于在webpack.config.js中配置了mode, 所以我们不必再在这里配置mode
我们发现, 时间直接锐减到了
72ms
, 之前是用了224ms
, 如果我们去把这种没有必要进行模块解析的模块都排除了, 在一个工程中至少可以提升好几秒的构建速度, 这就是减少模块解析的必要性, 因为他大幅度的提升了我们的构建性能
优化loader执行性能
限制loader的解析范围
在上面代码的基础上, 我们再来加入一些情景, 我们会在代码中书写一些es6的代码
// src/index.js
import $ from "jquery"; // ------- 这是之前的code
console.log("$", $); // ------- 这是之前的code
// ------------下面为新加入的code
const add = (a, b) => (a + b);
const result = add(3, 4);
console.log("result", result);
我们如果想在低版本的浏览器上运行上面的es6代码, 首先想到的就是使用babel
, 那我们就来安装一下babel-loader
yarn add babel-loader @bebel/core @babel/preset-env -D
接下来就是我们需要在
webpack.config.js
中进行配置
// webpack.config.js
module.exports = {
...
module: {
noParse: /jquery/, // 之前写的不解析jquery模块
rules: [{
test: /\.js$/,
use: [{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"]
}
}]
}]
}
}
没什么问题, 我们再来打包一次
npx webpack
这个时候我们会发现打包结果中, 之前书写的es6代码, 已经被转换成了es5, 而webpack
所花费的时间也上升到了1727ms
, 这主要是因为babel-loader
在解析源码字符串并进行替换造成的
同样, 我们可以想想, 我们引入的jquery
模块的代码是不是已经被打包过后的代码, 那我们还有必要再交给babel-loader
进行解析吗?
答案当然是不需要, 所以我们在使用babel-loader
的时候要排除掉一些模块
我们可以配置
webpack.config.js
中的exclude
参数来实现
exclude
配置文档: webpack.js.org/configurati…
// webpack.config.js
module.exports = {
module: {
rules: [{
test: /\.js$/,
exclude: /jquery/, // 排除掉jquery, 这里你可以写成数组的
use: [{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"]
}
}]
}]
}
}
当我们排除掉jquery
以后, babel-loader
只要看到jquery
目录就不会再去进行解析, 这样就可以大幅度的节约构建时间
我们会发现构建时间直接从1727ms
到了410ms
这就是针对loader
进行了范围配置以后的效果, 所以你还会看到有些公司的exclude
配置会直接排除掉node_modules
目录, 但是这样是不太推荐的哈, 只是说明这个现象
缓存loader
的结果
我们知道一个工程中, 每个
loader
怎么说也要处理十多个文件, 那么我们可以做出一种假设, 只要文件内容的字符串真的没有发生改变, 那么loader
就不必处理这些字符串(因为相同loader
处理相同字符串的结果肯定是一样的), 所以我们就要想办法将每一次loader
的处理结果保存下来, 当文件没有切实发生改动的时候都直接使用之前保存的处理结果
我们可以使用cache-loader
来做到这件事情
cache-loader
文档: www.npmjs.com/package/cac…
我们安装一下cache-loader
yarn add cache-loader -D
module.exports = {
module: {
rules: [{
test: /\.js$/,
use: ["cache-loader", {
loader: "babel-loader",
options: {
presets: "@babel/preset-env"
}
}]
}]
}
}
这个时候我们在打包一次webpack
, 注意, 因为要做一个缓存功能, 所以第一次打包时间肯定比较长, 我们直接用第二次打包时间来看
npx webpack
当没有cache-loader
的时候, 我们进行多次webpack
打包的时候, 所用的时间都是相差无几的(上面也演示了, 大概是420ms
), 而使用了cache-loader
以后, 我们可以看看效果
直接又缩短到了109ms
, 这就是缓存loader
的必要性
可能有的同学会有一个疑惑, 就是
loader
都是从后往前执行的, 那为什么cache-loader
放在第一个却能够阻止后续loader
的执行呢
这是因为cache-loader
在自己的loader
函数上加入了pitch
属性
关于
pitch
文档: webpack.js.org/api/loaders…
如果有同学知道loader
的原理的话, 就知道loader
本质上就是导出了一个函数, 接受一串字符串作为参数, 并返回一个新的字符串, 这就是loader
的本质, 且在webpack
中注册的loader
是从右到左依次执行的, 但是loader
身上有一个pitch
属性, 该属性也是一个函数, 在webpack
从右到左执行loader
之前, 会先从左到右依次执行每个loader
的pitch
函数, 一旦有loader
的pitch
函数存在了返回值, 那么后续的loader
的pitch
将不会执行, 同时pitch
的返回值会被移交给该pitch
的loader
的前一个loader
进行处理
我们可以用图来说明一下上述cache-loader
的执行过程
当然, 我们也可以写一个自己的loader
来控制webpack
的执行规律
// src/loader/test-loader
module.exports = (sourceCode) => {
return sourceCode;
}
module.exports.pitch = (mainRq, preRq, data) => {
return ""; // pitch函数必须返回字符串或者buffer, 否则会报错
}
这样如果我们上面写的loader排在第一位的话, 那么webpack
的打包结果将永远都只能是空串了, 感兴趣的同学可以自己试一下
对于
loader
的优化还有很多方式, 但是其他的方式都要见机行事了, 比如开启多线程, 这里我就不多作赘述了, 感兴趣的同学可以自己去查查thread-loader
这个库
热替换
在聊热替换之前, 我们或许先应该知道当我们使用了webpack-dev-server
这个插件帮助我们开启localhost
本地服务以后, 从代码变更到页面呈现大概走了哪些步骤
其实看我标红的文字, 就知道, 这里肯定有问题, 假设我们只是改动了一个文件里的一个变量的名字, webpack
直接帮我把整个工程都重新打包一次, 这有必要吗? 第二个, 假设我有这么一个场景, 我在编写一个表单提交的功能, 大概有30多个input
框, 当我填到第29个的时候, 我发现有个保存表单的逻辑出了点问题, 然后我去改代码, 结果代码一改浏览器给我刷新了,我所有的表单都需要重新填写一次, 那这种场景下我不是想死了
第一个问题我们没办法解决, 这是webpack
的工作模式, 大家可以去看看vite
, vite
的工作模式很好的解决了这个问题, 而第二个问题想要得到解决我们就要聊聊热更新了
vite官方文档: vitejs.dev/guide/
热更新webpack文档: webpack.js.org/api/hot-mod…
使用了热更新以后, 当文件发生变动时, 整个流程会发生一定的改变, 如下图
我们可以发现, 热更新提供给了我们类似于ajax的能力, 在不刷新页面的情况下, 使用JS更新页面内容
而开启热更新的方法非常简单, 我们首先需要在webpack.config.js
中设置hot
为true
, 当我们设置了hot
为true
以后, webpack
会自动向plugins中注入一个热更新插件webpack.HotModuleReplacementPlugin
, 这个不需要我们自己去添加的插件, 你要自己添加也可以, 不过你不添加的话webpack
会自动帮你添加, 所以我们自己是没必要去添加的
// webpack.config.js
module.exports = {
...
devServer: {
hot: true, // 配置hot为true代表我们需要开启热更新
}
...
}
上面的配置配好了以后, 代表我们需要开启热替换, 这个时候当我们开启webpack-dev-server
以后, 开发服务器会跟浏览器开启一个websocket
长连接, 当文件发生改变以后, 开发服务器会主动告诉浏览器说“诶, 你看我文件改变了”, 浏览器就会去跟服务器说“那你把改变的文件给我吧?”, 这个时候“服务器就会把更改的描述文件给到浏览器”, 但是我们还忽略了一个问题, 上面我们都没有提到无刷新啊? 浏览器怎么知道自己要无刷新呢?
是这样的, 当我们开启了
hot
为true
以后,webpack
会像module
对象中注入一个hot
属性,webpack
最终打包结果是什么样不用我说吧?module
属性是肯定会有的,hot
属性中有一个方法叫做accept
, 该方法一旦被执行, 就会改变浏览器的行为方式, 之前的设定是拿到资源以后立即刷新页面, 而调用了该方法以后, 浏览器将不再刷新页面, 而是通过更新JS中的代码来更新页面, 也正是因为这样, 调用accept
方法最好在入口文件中进行调用
// index.js
import $ from "jquery";
console.log("$", $);
const add = (a, b) => (a + b);
const result = add(3, 4);
console.log("result", result);
// ------------下面为新加入的code
if( module.hot ) {
module.hot.accept();
}
至此, 浏览器将不会刷新页面, 而是通过JS来更新代码, 我们的表单问题也完美得到解决了
我们需要注意的是, 热更新对mini-css-extract-plugin
是没有效果的, 只能对style-loader
有效, 你仔细想想也可以想明白, mini-css-extract-plugin
会生成新的css文件, 浏览器又没有刷新, 怎么请求新的css资源呢, 热替换只能够更新已有的资源, 如果服务器跟他说新增了一个css文件, 你说他怎么处理
解决方案也很简单, 我们根据开发环境和生产环境各自配置一个
webpack
配置文件就好了
分包
我们先来看一个例子, 基于上面的工程, 我们目前是在index.js
中引入了jquery
, 我们再创建一个文件test.js
, 其中也引入jquery
, 而且这个test
文件是``webpack```的另一个入口, 注意我们这里是在处理多入口场景
// test.js
import $ from "jquery";
console.log("test jquery", $);
// webpack.config.js
module.exports = {
...
entry: {
index: "./src/index.js",
test: "./src/test.js"
}
...
}
这个时候当我们敲下npx webpack
的时候, 毋庸置疑dist
目录一定会生成两个JS文件, 我们仔细阅读这两个JS文件, 其实会发现一些问题
如你所见, 无论是
index.js
的最终打包结果还是test.js
的最终打包结果, 都将jquery
这个模块打包了进去, 那么, 这会造成什么问题呢? 当我们这样重复的代码打包的越多, 每次http请求所要传输的量就越大, 用户从白屏到看到界面呈现的时间就越长
那么要解决这个问题, 我们能够想到的最直接的途径就是能不能跟我们写代码的时候一样, 将公共模块直接提取出去, 而分包就是这么一种处理手段
分包有什么好处:
- 第一个直接不用多说, 减少了打包结果的体积, 优化了浏览器请求文件的传输成本
- 第二个我们都知道浏览器有缓存文件的功能, 所以这些公共的模块(特别是一些第三方库)一旦被抽离出去了, 就可以直接被浏览器缓存, 这样又帮助我们提升了不少性能
分包的形式有两种: 手动分包和自动分包
- 手动分包: 手动分包大概的意思就是我们自己去指定哪些文件需要分包, 让
webpack
从几个点切入来帮助我们进行分包 - 自动分包: 自动分包大概的意思就是我们提供一个分包策略, 让
webpack
从一个更加宏观的角度来帮我们处理分包
可能听概念会有点头晕, 往下看手动分包和自动分包的详细介绍可能会好的多
手动分包
如上所述, 我们要进行手动分包, 就需要自己去决定哪些文件需要被分包, 下图我画了一个自动分包的流程, 可供大家更好的了解手动分包
我们大概可以通过上图得到以下步骤:
- 先将确定的公共模块单独打包(这意味着我们需要一个单独的
webpack
配置文件) - 在单独打包的过程中, 我们需要让每一个单独打包的模块都提供一个对外暴露的全局变量, 同时要生成一个
manifest
清单, 该清单主要是用于告诉webpack
哪些是已经打包好了的公共模块 - 将打包结果在
index.html
中引入(如果你有使用cleanWebpackPlugin
, 你要防止该插件清空公共模块的打包结果) - 打包入口模块发现有
jquery
依赖, 于是去manifest
清单中找, 发现确实找到了, 就不再进行后续打包流程了, 而是直接用manifest
清单中对应的该模块名(其实就是暴露的全局变量名)对代码进行一定的替换
那我们一步一步来解决这几部
单独打包公共模块且在index.html中引入
新建一个
webpack.dll.config.js
// webpack.dll.config.js
const webpack = require("webpack");
const path = require("path");
module.exports = {
entry: { // 假设我们要打包的公共模块是jquery
jquery: ["jquery"], //
},
output: {
// 由于是公共模块, 我们希望浏览器缓存掉他, 所以不给hash值
filename: "dist/[name].js",
// 一旦设置了library字段, 该模块最终就会暴露一个全局变量
library: "[name]", // library这个字段就是决定你暴露出去的全局变量是什么变量名
},
// 至于生成manifest清单, 我们就要用到webpack的一个插件.DllPlugin
plugins: [
new webpack.DllPlugin({
// 这个清单只是webpack后续去读的东西, 我们是没必要生成到dist目录下
path: path.resolve(__dirname, "dll/[name].manifest.json"),
// 这个name值是要注意的, 最终webpack去读这个清单的时候, 他如果匹配到了
// 对应的模块, 那么他就会将这个name值去作为全局变量名去替换掉你的模块代码,
// 所以这个name值最好是要保持和library一致, 如果不一致, 那你需要做特殊处理
name: "[name]",
})
]
}
OK, 通过这么一个配置, 我们的公共模块就可以打包完毕了, 我们需要在
index.html
中引入
...
<!-- 至于怎么使用HtmlWebpackPlugin生成html文件我就不说了, 这是基础, 然后这里
你要注意的地方就是你要记得最终你这个index.html是会被打包进dist目录, 所以这个
src要假定你在dist目录下开始引入 -->
<script src="./dll/jquery.js"></script>
...
如果你在主配置文件中使用了cleanWebpackPlugin
的话, 你需要控制不清除dist/dll
目录
// webapck.config.js
module.exports = {
...
plugins: [
new CleanWebpackPlugin({
// 排除掉dll目录本身和他里面的文件: 语法就是Globbing Patterns
cleanOnceBeforeBuildPatterns: ["**/*", "!dll", "!dll/*"]
})
]
...
}
对主配置文件的更改
现在我们根目录下的dll
目录页生成了, 里面有jquery
的manifest
文件, 这个时候我们在主配置文件webpack.config.js
进行打包的时候, 怎么去识别这个manifest
文件呢, 我们一起来做一些配置
// webpack.config.js
const webpack = require("webpack");
module.exports = {
...
plugins: [
....
// 这个就是让webpack去读那个manifest文件, 并在适当的时候使用文件进行模块匹配
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
...
]
...
}
这个时候我们再使用npx webpack
打包一次, 会发现两个入口打包后的代码都不存在jquery
的那么几千行的代码了, 因为他们都去用了公共模块
自动分包
自动分包需要我们提供一个分包策略, 然后webpack
会帮助我们将符合该分包策略的代码都提取出来单独成为一个模块, 同样我们来看看自动分包的一个流程图
从上图我们大概可以看出一些步骤:
- 我们无需自己单独打包公共模块, 而是给
webpack
设定一个分包策略 - 当分包策略起作用的时候, 会去找到符合策略的模块, 提取公共代码
- 公共代码生成文件, 而应用到公共代码的原始模块则会消除这些公共代码
分包策略
那么我们应该怎么去提供这个分包策略呢, 需要通过webpack
的optimization
字段来配置优化信息, 而optimization
字段中的splitChunks
字段就是用来配置分包策略的
webpack官网splitChunks配置: webpack.js.org/plugins/spl…
该splitChunks
字段我们需要关注其中比较重要的属性如下:
-
chunks 该配置项用于配置需要应用分包策略的chunk, chunk是什么我相信大家都不用说了,
webpack
最后输出的文件其实就是chunk
生成的,chunk
本身含义是webpack
由一个入口找到的多个模块, 这么多模块统称为一个chunk
, 回到正题,chunks
有三个取值- async(默认): 只对异步的chunk使用分包策略
- all: 对所有chunk都使用分包策略
- initial: 只对普通chunk应用分包策略
由于
async
是默认值, 所以webpack
只会对异步的chunk应用分包策略,我们将其改为all
, 就是所有的chunk都可以应用分包策略了, 异步chunk其实主要就是懒加载的chunk, 懒加载我们后面再说 -
cacheGroups: 字面意思是缓存组, 这哥们才是真正书写分包策略的地方, 该哥们有两个默认值(这也意味着
webpack
有默认的分包策略)cacheGroups: { defaultVendors: { test: /[\\/]node_modules[\\/]/, // 代表只要匹配到了正则表达式的模块就应用该分包策略, 不写的话就是所有模块 priority: -10, // 代表策略优先级, 优先级越高, 该策略先进行处理 reuseExistingChunk: true, // 代表是否重用已经分离出去的chunk }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true, }, // 一般情况下, 上面的两个分包策略已经足够我们使用了, 但是只要你想, 你还可以配置其他的分包策略, 比如你想给css也配置分包策略 styles: { test: /\.css$/ } }
这个时候我们敲下npx webpack
, 会发现我们的公共代码被自动提取出去了, 这就是自动分包
无论是自动分包还是手动分包, 其实我们做的都是策略, 真正分包的事其实都是webpack去做的, 我们只是告诉webpack应该怎样去分
总结
其实webpack
自己在内部已经帮我们做了很多优化, 比如代码压缩, tree shaking
等, 而通过上面我提到的这些优化项, 则可以在不同的场景和情况下进一步的优化webpack
的构建速度, 传输性能, 只要你在大企业或者接触了大项目, 性能一定都是你抛不开的话题, 所以希望这篇博客能够帮助到大家, 也希望可以得到大佬指教
写到这, 突然又想起来一些东西: 所以打算下一节跟大家一起聊聊
- webpack cdn
- thread-loader
- splitChunk细节
- treeshaking细节
- 懒加载细节
那我们下周见~