前言:
自从前端抛弃石器时代进入工业时代以后, 构建工具就已经跟我们前端开发紧密联系在一起了, 而其中脱颖而出的
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细节
- 懒加载细节
那我们下周见~