【万字】优化Webpack?肘,跟我进屋聊聊
大家好我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师。
咱们webpack大白话系列的第一篇文章《在?大白话跟你唠明白Webpack(基础篇)》带大家快速入门了webpack,今天我来填坑了。
本次给大家带来的是webpack的进阶玩法——优化webpack。本篇结束后,就只剩四个坑啦!
-
原理篇——剖析plugin从概念到手写到原理
-
原理篇——剖析webpack打包原理,手写小型webpack
一. 前言
webpack官网介绍了很多优化它的方法,虽然量大管饱,但不够体系化。因此我打算将常用的方法归纳起来,从优化开发体验,提升构建速度,减少构建体积,优化应用性能这四个维度逐一介绍webpack的优化。也欢迎大家在评论区查漏补缺,介绍一些自己常用的优化技巧。
还是老样子,案例代码已放在git欢迎自取,点个star。
我们承接基础篇的仓库进行目录的改造(本篇内容未提出需要安装与配置的loader和plugin在上一篇文章中已经安装与配置好。若不清楚基础篇仓库内容的欢迎移步查看上一篇文章,有助于理解本篇内容)。
├─ src
│ ├─ components
│ │ ├─ Header
│ │ │ └── index.js
│ ├─ css
│ │ └── index.scss
│ ├─ index.html
│ └─ index.js
├─ .gitignore
└─ README.md
└─ webpack.config.js
└─ package.json
接下来正式开始进行优化,大家坐稳扶好我们发车。
二. 优化开发体验
本章主要介绍开发者可以通过webpack的哪些配置,实现定向搜索,源代码追踪,模块热更新,TS开发,代码校验以及省略文件后缀名等功能,提升自己在项目中的开发体验。
2.1 定向搜索——alias
我们在src/index.js
中引入自己创建的组件,把它渲染出来以此讲解本案例。
import Header from './components/Header/index.js'
Header()
配置components/Header/index.js
的内容如下:
import '../../css/index.scss'
const Header = () => {
const body = document.body
const div = document.createElement("div")
div.setAttribute("class","cengfan")
div.innerHTML = "<h2>我来组成头部</h2>"
body.append(div)
}
export default Header
src/css/index.scss
填写如下内容:
$baseCls:"cengfan";
.#{$baseCls} {
background: #4285f4;
color: #fff;
}
执行webpack serve
查看结果渲染成功:
现在我们把注意力转移到components/Header/index.js
中的import
语句。这个组件引用了外部的样式,路径回退了2层。看似问题不大,但如果项目结构复杂,要回退更多层级的路径就很麻烦了。
举个例子,如果在Header文件夹下还有a/b/c/index.js
这样的文件层级,我们引用css的方式会变成这样../../../../../css/index.scss
。
当项目结构如此复杂,我们还想达成引用的需求时。有什么方式能优雅地解决这个问题呢?接下来我们请出webpack的resolve
配置。
官方对于resolve
的解释非常简单:配置模块如何解析。下面我们看看如何配置。
- 步骤1: 配置
webpack.config.js
const path = require('path')
const resolvePath = _path => path.resolve(__dirname, _path)
module.exports = {
// ...
resolve:{
alias: {
'@': resolvePath('./src')
},
},
//...
}
alias:创建 import
或 require
的别名,来确保模块引入变得更简单。
这里将@
的寻址路径指定为src
的根目录。
- 步骤2: 更改Header组件的import语句
import '@/css/index.scss'
这里相当于在src
路径下找寻后面的文件。配置好后重启服务,页面正常渲染。
即使项目层级复杂,你也可以指定任何你想寻址的目录。这就是alias
的使用方法,Vue中@
解析符的寻址也是这么回事。
2.2 源代码追踪——sourceMap
本小节开始学习前,我们先搞明白webapck中的module,chunk和bundle分别是什么。
名称 | 定义 |
---|---|
module | 开发者手写的代码模块 |
chunk | 输入module代码后,交由webpack正在打包的代码 |
bundle | 编译后输出的浏览器最终能直接识别的代码 |
简而言之,这三者是这样婶儿的关系:
module(手写的代码) => chunk(webpack处理中的代码) => bundle(webpack处理后的代码)
。
客户端在执行程序时,读取的是打包后的bundle文件。如果程序执行过程报错,报错信息是bundle的内容。无法溯源到源代码module
中的错误,此时需要借助sourceMap来帮忙。
sourceMap(源代码映射)是一个用来生成源代码与构建后代码对应映射文件的方案。下面我们举个例子来看看它的使用:
我们在components/Header/index.js
中添加依据错误的console信息:
console.lo('Halo Header')
运行webpack server查看页面。
点击第一行的main.js
报错查看报错信息:
点击第二行的index.js
查看报错:
可以看到无论是哪个报错信息,追溯的都是编译后的bundle报错,没有追溯到源码。下面我们给webpack.config.js
配置sourceMap信息:
module.exports = {
// ...
devtool:'cheap-module-source-map',
//...
}
再次查看报错信息,此时错误已经追踪到源码上了:
这就是sourceMap的作用,它的官方配置很多。但总结下就是将错误追踪分成不同的种类。如是否单独生成chunk与bundle相互映射的map文件;是否以内联形式追加sourceUrl信息等排列组合。这里整理了一份表格供参考。
模式 | 含义 |
---|---|
eval | 每个module会被封装到eval内执行,并且会在末尾追加注释 //sourceURL |
source-map | 额外生成sourceMap文件 |
hidden-source-map | 上同,但是不会在bundle末尾追加注释 |
inline-source-map | 生成一个DataUrl形式的sourceMap文件,map文件不会被单独打包 |
eval-source-map | 上同,另外每个module会被封装到eval内执行 |
cheap-module-source-map | 生成一个没有列信息的sourceMap文件,map文件会被单独打包 |
但是我们的文章宗旨是通篇大白话,不讲八股文。这么些区别谁也懒得记。实际开发过程中我们只需要关注开发(development)
和生产(production)
两个环境如何配置sourceMap信息即可,直接记结论如下表:
mode | devtools | 优点 | 缺点 |
---|---|---|---|
development | cheap-module-source-map | 打包编译速度快 | 只包含行映射 |
production | source-map/不配置 | 包含行,列映射 | 打包速度慢 |
提示:演示完毕后把代码改回不报错的状态,避免影响接下来的演示。
2.3 模块热更新-HMR
模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:
- 保留在完全重新加载页面期间丢失的应用程序状态。
- 只更新变更内容,以节省宝贵的开发时间。
- 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。
在webpack.config.js
中的devServer
配置hot: true
即可开启:
module.exports = {
// ...
devServer: {
host: 'localhost',
port: 8080,
open: true,
hot: true,
},
mode: 'development'
}
2.4 TypeScript开发-babel-loader
开发ts需在安装对应依赖,并额外做一些配置,我们先做好前置工作,调整src
目录,新增一个ts
文件:
├─ src
│ ├─ components
│ │ ├─ Add
│ │ │ └── index.ts
│ │ ├─ Header
│ │ │ └── index.js
│ ├─ css
│ │ └── index.scss
│ ├─ index.html
│ └─ index.js
Add/index.ts
文件中填充的内容如下:
const add = (a: number, b: number):number => a + b
const Hello = () => {
const result = add(2,3)
console.log(result)
}
export default Hello
src/index.js
引入该组件:
import Hello from './components/Add/index.ts'
Hello()
到这里前置工作完成,接下来我们正式开始配置webpack使其能正常编译ts文件。
- 步骤1: 安装bebal预设
@babel/preset-typescript
yarn add @babel/preset-typescript -D
- 步骤2: 配置
webpack.config.js
。
//...
module.exports = {
//...
module: {
rules: [
//...
{
test: /\.(js|ts)$/,
use: 'babel-loader',
}]
},
//...
}
- 步骤3: 项目根目录新建
babel.config.json
文件。
├─ src
├─ .gitignore
└─ babel.config.json
└─ README.md
└─ webpack.config.js
└─ package.json
- 步骤4: 配置
babel.config.json
文件。
{
"presets": ["@babel/preset-env","@babel/preset-typescript"]
}
到这里ts的前置工作就做完了,运行下webpack serve
查看结果,Hello
函数正确打印了结果。
2.5 校验代码
校验代码分常规校验
和ts校验
两块内容讲解。
原因是ts校验涉及的历史背景较为复杂。早期涉及ts的编译,需要使用到ts-loader + babel-loader
,先将ts代码编译成es代码,再通过babel-loader
编译成es5代码。后来ts团队与babel团队合作带来了全新的babel 7
。使得babel-loader
能直接编译ts代码,但是babel官网有如下提示:
务必牢记 Babel 不做类型检查,你仍然需要安装 Flow 或 TypeScript 来执行类型检查的工作。
也就是说虽然配置和编译变得更快更简单,但由于不依赖tsc
编译代码,编译过程中丢失了ts语法的校验功能。
那如何去做ts语法的校验呢,后续我们进行详细地讲解,让我们先关注如何使用eslint在webpack中进行代码的常规校验。
2.5.1 常规校验-eslint
常规校验es代码需要在webpack中引入eslint并做好配置,配置步骤如下:
- 步骤1: 安装eslint依赖
yarn add eslint eslint-webpack-plugin -D
- 步骤2: 配置
webpack.config.js
。
// ...
const ESLintWebpackPlugin = require("eslint-webpack-plugin");
const resolvePath = _path => path.resolve(__dirname, _path)
module.exports = {
// ...
plugins: [
new ESLintWebpackPlugin({
// 指定检查文件的根目录
context: resolvePath('./src'),
}),
// ...
]
// ...
}
- 步骤3: 根目录新增
.eslintrc.js
,.eslintignore
文件。
├─ src
├─ .gitignore
└─ README.md
└─ .eslintignore
└─ .eslintrc.js
└─ webpack.config.js
└─ package.json
这里讲解下这两个文件的作用:
-
.eslintrc.js: 配置eslint校验规则的文件。
-
.eslintignore: 哪些文件需要忽略校验规则。
eslint的使用,本质上是对.eslintrc.js
文件进行相关配置。
有时候我们不希望eslint校验所有文件。类似git提交时,我们不希望所有文件都被git识别并提交,此时就有了.gitignore
文件去指定git应该忽略的文件。.eslintignore
的作用也是如此,指定eslint应该忽略哪些文件去做代码校验。
- 步骤4: 配置
.eslintignore
文件。
dist
node_modules
webpack.config.js
- 步骤5: 配置
.eslintrc.js
文件。
module.exports = {
root: true,
env: {
node: true, // 启用node中全局变量
browser: true, // 启用浏览器中全局变量
},
parserOptions: {
ecmaVersion: 6,
sourceType: "module",
},
rules: {
"no-var": 2, // 不能使用 var 定义变量
},
extends: ["eslint:recommended"], // 继承 Eslint 规则
};
字段 | 含义 |
---|---|
root | 是否为根目录 |
env | 指定环境,使用 env 关键字指定你想启用的环境,并设置它们为 true |
parserOptions | 解析配置选项 |
rules | 可以使用注释或配置文件修改你项目中要使用的规则,修改对应规则的值即可;"off"或0关闭规则,"warn"或1为开启规则,使用警告级别的错误,"error"或2开启规则,使用错误级别的错误 |
extends | 可以让eslint继承已经配置好的规则。 |
eslint的配置项很多,这里列举了一些字段的含义,详细的配置推荐大家去官网查阅。
在这里我们配置了eslint并设置了不允许使用var来定义变量
这一规则,一旦使用var定义变量,eslint会报error级别的错误,并中止程序。
我们在src/index.js
中用var
定义一个变量,运行webpack查看结果
eslint的校验符合预期,大功告成。接下来我们进行ts校验的相关配置。
2.5.2 校验ts-tsc
校验ts有2种方式,使用eslint
或tsc
eslint的校验笔者在参考其他资料后做出的配置仅能校验js文件,不能校验ts文件。下面贴出配置,欢迎大家讨论,给出解决方案。
- 步骤1: 配置
.eslintrc.js
文件。
module.exports = {
// 继承 Eslint 规则
root: true,
env: {
node: true, // 启用node中全局变量
browser: true, // 启用浏览器中全局变量
},
parserOptions: {
ecmaVersion: 2021,
sourceType: "module",
},
rules: {
// 禁止使用 var
'no-var': 2,
// 优先使用 interface 而不是 type
'@typescript-eslint/consistent-type-definitions': [
"error",
"interface"
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
};
- 步骤2: 更改
components/Add/index.ts
文件。
const add = (a: number, b: number):number => a + b
const Hello = () => {
const result = add(2,'o')
console.log(result)
}
export default Hello
- 步骤3: 配置
package.json
文件的script脚本。
"scripts": {
"lint:es": "eslint --ext .ts src/",
"lint:tsc": "tsc"
},
这里我们把调用add函数的入参改成了number
和string
类型的参数。这不符合定义函数时需要的入参类型,理论上校验时需要报错,但执行yarn lint:es
的时候,编译并未报错。如果在这个ts文件里用var定义一个变量它会报错(遵循了no-var
的原则)。
这里欢迎各位尝试,找出原因给出解决方案,接下来我们讲解tsc
校验。
tsc校验的步骤如下:
- 步骤1: 根目录新增
tsconfig.json
文件。
├─ src
├─ .gitignore
└─ README.md
└─ .eslintignore
└─ .eslintrc.js
└─ tsconfig.json
└─ webpack.config.js
└─ package.json
- 步骤2: 配置
tsconfig.json
文件。
{
"compilerOptions": {
"target": "es5",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
},
"include": [
"src"
]
}
tsconfig.json
指定ts的一些解析规则和方式,如果没有这个json文件,需要在命令行中做大量配置,这样会非常麻烦。
配置完成后执行yarn lint:tsc
查看结果:
符合预期正常报错。
注意,这里配置的scripts报错不会阻碍程序的正常运行;脚本命令的校验也是一次性的;我们更改下scripts脚本进行功能上的优化。
"scripts": {
"lint:es": "eslint --ext .ts src/",
"lint:tsc": "tsc --watch",
"start": "tsc && webpack serve"
},
-
lint:tsc: 保持ts校验时刻生效。
-
start: 运行webpack serve前进行ts校验,如果有报错,本次服务不会被开启。
每当我们yarn start
成功的时候,再额外开启一个终端执行yarn lint:tsc
就能保持ts的语法校验时刻生效了。
测试完校验功能后,记得把错误的代码改回去,避免影响接下来的演示。
2.6 省略引入文件后缀名-resolve
我们看看开发中“省略引入文件后缀名”这个问题发生的场景,查看src/index.js
的内容:
import Header from './components/Header/index.js'
import Hello from './components/Add/index.ts'
Header()
Hello()
日常开发中我们有很多import的使用,为了方便开发我们希望引用时能省略文件后缀名如.js .ts
。如果直接在import语句中去掉文件后缀名,编译会报错:
此时可以配置resolve
的extensions
属性,它会按顺序解析这些后缀名。
- 步骤1: 修改
webpack.config.js
。
module.exports = {
//...
resolve:{
// ...
extensions: [".js", ".ts"]
},
// ...
}
- 步骤2: 修改
src/index.js
的import。
import Header from './components/Header/index'
import Hello from './components/Add/index'
虽然我们省略了2个index文件的后缀名,但extensions
会按顺序解析省略的后缀,编译仍然成功。
这下明白在Vue/React的官方脚手架中,为什么我们引入文件时省略后缀也能正常运行了吧,就是配置了resolve
的extensions
属性的缘故。
到这里,“优化开发体验”这一章节的内容就全部讲解完毕了,我们进入下一个章节的讲解。
三. 提升构建速度
本章节我们从规则匹配,排除/包含文件,babel缓存,缓存其他资源,多进程打包等方面讲解如何提升webpack的构建速度。
3.1 规则匹配-oneOf
webpack打包时每个文件都会经过所有 loader 处理,虽然loader并不会处理与test
不匹配的文件。但文件还是会遍历所有的loader,导致匹配变慢。
解决方式使用oneOf
,让文件匹配上对应的loader后,就不与其他loader做匹配。修改webpack.config.js
配置:
module.exports = {
// ...
module: {
rules: [{
oneOf: [{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}, {
// 匹配less文件
test: /\.less$/,
// loader的使用顺序 less-loader,css-loader,style-loader
use: [
'style-loader',
'css-loader',
'less-loader'
]
}
// ...
]
}]
},
// ...
}
3.2 排除/包含文件-exclude,include
我们使用loader处理js ts
文件时,要排除node_modules
下面的文件,或直接指定只处理src
目录下的文件,通过配置babel-loader
达成效果,注意exclude/include
为互斥关系,二者只能开启其中的一种。
{
test: /\.(js|ts)$/,
// exclude: /node_modules/,
include: resolvePath('./src'),
use: 'babel-loader',
}
3.3 babel缓存
每次处理js ts
文件都要经过eslint校验和babel编译,速度较慢。我们可以开启缓存,将上次编译的结果缓存起来,提升构建速度。
- 步骤1: 修改
babel-loader
配置开启babel缓存。
{
test: /\.(js|ts)$/,
exclude: /node_modules/,
// include: resolvePath('./src'),
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启babel编译缓存
cacheCompression: false, // 缓存文件不压缩
}
}
这里解释一下为何需要关闭缓存压缩。
生产环境的代码无需用上这些压缩缓存文件。如果在开发模式中开启缓存压缩,执行压缩的过程需要耗费一定的时间。为了更快的构建速度,这里选择关闭该功能。
- 步骤2: 修改plugins中
ESLintWebpackPlugin
的配置开启eslint缓存。
new ESLintWebpackPlugin({
// 指定检查文件的根目录
context: resolvePath('./src'),
exclude: "node_modules", // 默认值
cache: true, // 开启缓存
// 缓存目录
cacheLocation: cacheLocation: resolvePath('../node_modules/.cache/.eslintcache')
})
运行查看结果,cache文件缓存了babel的解析。
3.4 缓存其他资源-cache-loader
如果想缓存编译的其他资源我们需要使用cache-loader,现在尝试缓存sass-loader的编译结果:
- 步骤1: 安装cache-loader。
yarn add cache-loader -D
- 步骤2: 修改sass-loader的配置,
cache-loader
只能配置在MiniCssExtractPlugin.loader
与css-loader
之间做缓存。
{
test: /\.s[ac]ss$/,
use: [
MiniCssExtractPlugin.loader,
'cache-loader',
'css-loader',
'sass-loader'
]
}
yarn start查看结果,cache-loader生效。
3.5 多进程打包-thread-loader
当项目过大,打包时间过长时,我们使用多进程的打包方式加快打包速度。需要注意:每个进程启动时长大约600ms,因此务必要在项目足够大,打包时间过长的时候才开启此功能。 开启步骤如下:
- 步骤1: 安装thread-loader。
yarn add thread-loader -D
- 步骤2: 在
webpack.config.js
中引入thread-loader。
const os = require('os')
// cpu逻辑处理器个数
const threads = os.cpus().length
- 步骤3: 修改
webpack.config.js
中的babel-loader配置。
{
test: /\.(js|ts)$/,
exclude: /node_modules/,
// include: resolvePath('./src'),
use: [{
loader: "thread-loader", // 开启多进程
options: {
workers: threads, // 数量
},
}, {
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启babel编译缓存
cacheCompression: false, // 缓存文件不压缩
}
}]
}
这样就开启了多进程打包模式。
至此提升构建速度的常用优化手段就讲解完毕,我们进入下一章的讲解。
四. 减少构建体积
本章我们将从treeShaking,资源压缩等方面讲解如何减少webpack的构建体积。
4.1 treeShaking
treeShakig可以筛去在JS上下文中未被引用的代码,它依赖ES Module
,默认是开启的状态无需其他配置。
将webpack的mode改为production正常打包项目时,打包结果如下:
修改src/index.js
注释掉Hello的引用,然后看看它是如何作用的。
import Header from './components/Header/index'
import Hello from './components/Add/index'
Header()
// Hello()
打包查看结果。
可以看到Hello这个chunk的结果console.log(5)
,未被打包到bundle中,这就是treeShaking,那么treeShaking是100%生效吗?不一定,我们先来了解下treeShaking的机制。
treeShaking会筛除以下两种代码:
-
未被引用的代码
-
无副作用的代码
未被引用很好理解,就像上述的例子,Hello
模块仅仅被import,但是未被调用。打包时自然删除了这个模块的代码。
副作用(sideEffects) 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export,如会影响到全局应用的一些文件。
webpack4中默认将所有代码视为有副作用,避免打包时删除一些必要文件。所以webpack4中的默认行为不支持treeShaking,webpack5则是默认支持的。
无论是否默认支持,我们都可以通过package.json
中的sideEffects
字段,指定哪些文件有副作用。
我们做个测试,更改package.json
的配置:
"sideEffects": false,
"sideEffects": false
配置了所有文件都无副作用,所以在打包后样式被筛除了。
当然如果我们设置"sideEffects": true,
或者直接拿掉这个属性(webpack5默认开启)。样式就回来了,如下所示:
只能指定全部文件是否有副作用这不够灵活,所以sideEffects
还支持以数组的形式配置哪些文件有副作用,此数组支持简单的 glob 模式匹配相关文件。其内部使用了 glob-to-regexp(支持:*
,**
,{a,b}
,[a-z]
)。
比如:"sideEffects": ["*.css","*.common.js"]
会指定所有的css
文件和后缀为.common.js
的文件有副作用。treeShaking的时候,即时它们未被使用也不会被筛除了。
4.2 资源压缩
资源压缩从css,js,图片
三个层面跟大家展开讲解。
4.2.1 css压缩
css压缩需要用到css-minimizer-webpack-plugin
,使用步骤如下:
- 步骤1: 安装
css-minimizer-webpack-plugin
。
yarn add css-minimizer-webpack-plugin -D
- 步骤2: 配置
webpack.config.js
。
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'
module.exports = {
// ...
plugins: [
//...
new CssMinimizerPlugin()
],
// ...
}
也可以通过配置webpack.config.js
的optimization
属性开启css压缩,需要注意开启后会影响到下文要讲解的js压缩,开启方式如下:
module.exports = {
optimization: {
minimizer: [
// 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
// `...`,
new CssMinimizerPlugin(),
],
},
}
4.2.2 js压缩
webpack5的生产模式默认开启了js和html的压缩。但是在webpack.config.js
中,如果单独配置了optimization
会导致默认的js压缩失效,此时需要我们手动去配置压缩功能。
上个小节我们自己配置了optimization
属性,我们来看看打包后生产环境代码的样子:
显然这是未压缩的代码。接下来我们手动配置下js的压缩功能。
压缩js的plugin是terser-webpack-plugin
,这个plugin在webpack5中已被内置,开箱即用,我们无需下载只需要做好配置即可。
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
// ...
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin()
],
},
// ...
}
配置完成后,打包查看结果,js已被压缩:
4.2.3 图片资源压缩
将小于指定体积的图片转化成data URI
的Base64形式的资源。
Base64
是一种基于 64 个可打印字符来表示二进制数据的表示方法。它常用于在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。
图片的Base64
编码就是可以将一幅图片数据编码成一串字符串,使用该字符串代替图片地址,从而不需要使用图片的 URL 地址,这有利于减少请求的数量。
搞明白这个东西后,我们开始实践,在demo中引入几张图片查看效果:
接下来我们将最左边的图片进行Base64转换,配置webpack.config.js
让小于60kb的图片变成base64格式,找到之前处理图片资源的loader进行如下配置即可:
{
test: /\.(jpe?g|png|gif|webp|svg)$/,
type: 'asset',
generator: {
filename: 'assets/img/[hash:10][ext]'
},
parser: {
dataUrlCondition: {
maxSize: 60 * 1024 // 小于60kb的图片会被base64处理
}
}
},
重新执行yarn start
查看结果,发现最左边的图片已经变成了Base64格式,不再多做网络请求。
即使我们将项目打包,左边的图片也不会被打入生产环境。
这就是Base64的处理方式。至此我们常用的减少构建体积的方法就介绍完毕了,我们进入下一个章节的讲解。
五. 优化应用性能
本章我们将通过代码分割,动态导入模块,缓存文件,CDN加载资源
等方面讲解如何优化webpack的应用性能。
5.1 代码分割
代码分割起到“化整为零”的作用,它可以把代码分到不同的bundle中,以减小单个bundle的体积。之后按照使用需求,将这些代码按需加载或并行加载。如果使用合理,它能极大减少应用程序的加载时间以提升应用性能。
举个例子,假设我们的应用程序在打包后生成了一个10M的bundle文件,用户在进入应用首页时就要加载这个10M的JS脚本。如果网速不快,加载时间会变得极为漫长,这显然拖垮了应用的整体性能。
理想情况是webpack帮我们把应用程序的代码做了分割,每个模块的代码体积都很小。我们进入哪个模块,就加载对应模块的文件,程序空闲时它会异步或并行加载其他资源,这样就能提升我们应用的性能。
分割代码的方式有2种,entry入口分离,splitChunks
下面我们看看二者如何使用。
在正式开始前我们将之前的src文件做个备份,并调整src目录结如下(同名文件不做改动,有其他改动在下方已经说明):
├─ src
│ ├─ components
│ │ ├─ Add
│ │ │ └── index.ts
│ │ ├─ Header
│ │ │ └── index.js
│ ├─ css
│ │ └── index.scss
│ ├─ index.html
│ └─ index.js
│ └─ main.js
src/index.js
内容如下:
import Header from './components/Header/index'
import Hello from './components/Add/index.ts'
console.log('Hello Index!')
Header()
Hello()
src/main.js
内容如下:
import Hello from './components/Add/index.ts'
console.log('Hello Main!')
Hello()
5.1.1 entry入口分离
- 步骤1: 配置
webpack.config.js
,关闭js压缩功能,并重新配置entry。
entry: {
index: './src/index.js',
main: './src/main.js',
}
- 步骤2: 打包查看结果,两个入口js文件引用的
Add
下的Hello
模块都能正常运行。
打包后的dist结构如下,可以看到2个js文件都被单独打包了出来:
查看编译后的index.js
。
编译后的main.js
。
可以看到二者共同引用的模块Hello
,被重复地分别打包进了index
和main
这2个bundle中。这代码分离了,又好像没分离。我们希望通用的代码只被打包一次然后引用即可,此时就需要使用到splitChunks
。
5.1.2 splitChunks
splitChunks
的配置方式较为复杂,详细介绍请查看官方文档。这里我们挑取最简单的配置展示给大家看。
// ...
module.exports = {
// ...
optimization: {
// ...
splitChunks:{
// 对所有模块进行分割
chunks:'all',
cacheGroups: {
default: {
// chunks需达到一定体积才能被分割,我们定义的chunk体积太小,所以更改生成 chunk 的最小体积(以 bytes 为单位)。
minSize: 0,
minChunks: 2,
priority: -20,
// 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块
reuseExistingChunk: true,
}
}
}
},
// ...
}
更改分割策略后,打包代码查看结果,发现新生成了一个名为749
的bundle文件:
这个bundle的内容,就是我们Add
文件里的内容
接下来查看src/index.js
的bundle内容,发现它引入了749
这个bundle,原先Add
中的Hello
模块的内容并没有被重新定义。
再看看src/main.js
的bundle,发现也是如此。
这就是splitChunks,它帮助我们分割出通用的代码避免重复打包。
5.2 动态导入模块
应用在初始化时不会一次加载全部资源,当它需要使用某个功能时加载对应功能的模块,这就是模块的动态导入。动态导入模块的方式有懒加载,预获取/预加载
,它的使用方式是用import
引入需要懒加载的模块,下面我们进行详细讲解。
5.2.1 懒加载
懒加载就是上文提到的“用到谁加载谁”的出处。在使用懒加载前,我们调整目录结构,新增一个Minus
组件。
├─ src
│ ├─ components
│ │ ├─ Add
│ │ │ └── index.ts
│ │ ├─ Header
│ │ │ └── index.js
│ │ ├─ Minus
│ │ │ └── index.js
│ ├─ css
│ │ └── index.scss
│ ├─ index.html
│ └─ index.js
│ └─ main.js
组件内容如下:
const Minus = (a, b) => {
return a - b
}
export default Minus
接着更新src/index.js
的内容,使用import
引入需要懒加载的Minus
组件,并将它打印出来:
import Header from './components/Header/index'
import Hello from './components/Add/index.ts'
console.log('Hello Index!')
Header()
Hello()
const body = document.body
const btn = document.createElement("button")
btn.innerText = '点击加载Minus组件'
body.append(btn)
const btnDom = document.querySelector('button')
btnDom.addEventListener('click',() => {
console.log(import("./components/Minus/index"))
})
运行yarn start
查看内容:
点击按钮查看加载资源的变化:
前后对比发现多了个375.js
的bundle文件,点开文件查看内容,发现这就是Minus组件的bundle:
接下来再去控制台看看console打印了什么内容:
从打印的结果可以看出,Minus
组件以ES Module
为形式,Promise
为结果被引入到index.js中。如果想在index.js中使用Minus组件我们更改下面的内容:
btnDom.addEventListener('click',() => {
import("./components/Minus/index").then((res) => {
// 模块暴露的方式为默认暴露所以调用default方法使用
const result = res.default(5,3)
console.log(res)
console.log(result)
})
})
点击按钮查看结果,模块已被正常加载并使用:
如果想给懒加载的bundle命名,可以在import时添加对应的魔法注释:
import(/* webpackChunkName:"Minus" */ "./components/Minus/index").then((res) => {
// 模块暴露的方式为默认暴露所以调用default方法使用
const result = res.default(5,3)
console.log(res)
console.log(result)
})
再次点击按钮查看加载的资源文件,此时这个bundle名称由原来的375
变成了我们更改的命名Minus
:
懒加载的使用方法是不是看着格外眼熟?在Vue或React中,路由的懒加载方式也是如此。
懒加载很好用,但它仍然有一些不足之处,当我们需要懒加载的资源过大,加载时间过长,会导致应用的体验变差。此时我们需要一个更好的解决方法——预加载。
5.2.2 预加载
预获取/预加载的使用很简单,扩展一下魔法注释即可,写法如下:
import(/* webpackChunkName:"Minus", webpackPrefetch: true */ "./components/Minus/index").then((res) => {
// 模块暴露的方式为默认暴露所以调用default方法使用
const result = res.default(5,3)
console.log(res)
console.log(result)
})
启动服务查看结果,在点击按钮前Minus
组件就被加载了进来:
应用的head标签内通过link
标签将Minus
以prefetch的方式加载了下来。
点击按钮后,查看结果,Minus
正常运行:
这就是预加载,它能在浏览器空闲的时候,去加载我们指定的资源。它只会加载资源,并不执行。
5.3 缓存文件
浏览器的缓存技术能极大减少客户端访问应用的时间,提升用户体验。但如果新打包的bundle文件名称未被修改,会导致浏览器申请资源时总是触发缓存机制,使客户端无法获取到最新的资源。
本小节我们通过配置output
属性,确保编译的bundle能被客户端缓存,在资源发生变动时也能请求到新的文件。配置方式如下:
output: {
// ...
filename: 'scripts/[name].[contenthash:10].js',
}
这里获取了对应bundle的文件内容,取文件内容的hash值前10位为扩展后缀名。在文件发生更改时,hash后缀会产生变化,反之则无变化。 打包后dist内容如下:
此时bundle文件都加上了hash后缀,这里我们给Minus
组件新增一条console语句再打包看看前后变化:
console.log('我是Minus组件')
const Minus = (a, b) => {
return a - b
}
export default Minus
可以看出Minus, index
对应的bundle和map文件命名发生改变,main, 749
命名未被改变。
Minus
因为内容改变,所以contenthash被改变,可index
的contenthash为什么也会被改变呢?我们看看index
的bundle文件。
这里可以看出index
依赖Minus
,在index
的bundle中保存了Minus
的hash值。如果Minus
发生变化,index
的bundle中记录Minus
hash值的这部分也会发生变化。导致index
的文件内容发生改变,从而引起index
的contenthash被更改。
这里很好理解,假设某个bundle发生了改变,这个bundle和依赖该模块的bundle的contenthash都会发生改变。这种“依赖性改变”非常合情合理对吧?......对吗?
显然这是不合理的,我们希望webpack
只专注于产生了实质性改变的文件,关联文件不受影响,从而让缓存更加持久。处理这种问题就需要配置runtimeChunk
属性。
runtimeChunk
会把文件之间依赖的映射关系提取成单独的文件保管,这个文件就叫runtime文件。如果Minus
发生改变只有它和依赖它的runtime文件会被改变,index
不被改变。具体配置如下:
optimization: {
// ...
runtimeChunk: {
name: entryChunk => `runtime-${entryChunk.name}.js`
}
},
这里我们给runtime文件加上了runtime-
的前缀,重新打包后如下所示,新增了index
和main
的依赖runtime文件:
我们更改Minus
组件,去掉刚刚添加的console语句,再次打包查看结果:
可以看出只有Minus, runtime-index
文件以及它们的map文件发生了变化,index
文件本身并没有发生变化。这样就保证了缓存的持久性,让用户体验更好。
5.4 CDN加载资源——externals
好了到了最后一个环节,如何让webpack
使用CDN加载资源。这里需要配置externals
属性,它的作用是防止将某些import
的资源打包到bundle中,在运行时,再去外部获得这些扩展依赖。
这里我们以Vue
为例,进行CDN的加载并使用。
- 步骤1:
src/index.html
中引入CDN资源:
<body>
<script src="https://unpkg.com/vue@next"></script>
</body>
- 步骤2: 配置
webpack.config.js
:
// ...
module.exports = {
// ...
externals: {
vue: 'Vue',
},
mode: 'production'
}
- 步骤3:
main.js
引入Vue并使用:
import Hello from './components/Add/index.ts'
import {ref} from 'vue'
console.log('Hello Main!')
Hello()
const a = ref('cengfan')
console.log(a)
执行yarn start
查看打印结果:
可以看到用ref
声明的变量被正确打印了出来。到这里我们就成功引入并使用了Vue
的CDN资源。
从引入到使用,这一套流程是如何走通的呢?下面我们在main.js
中打印一下window对象跟大家进行全流程的讲解。
-
步骤1:
index.html
中script
引入的Vue
,被挂载在了window对象上。 -
步骤2: 因为
externals
的配置,main.js
或整个项目中,import *** from 'vue'
中的vue
不再去node_modules
中寻找,而是从全局对象window中寻找。那有人就有疑问了,全局对象是Vue
,import的是vue
这是如何匹配上的呢,我们来看下一个步骤。 -
步骤3:
import *** from 'vue'
中的vue
与externals
的key匹配,window
中的Vue
与externals
的value匹配。
externals
中的key-value映射关系如下表:
key-vue | value-Vue |
---|---|
import *** from 'vue' | window中的Vue |
再举个例子帮助理解,修改main.js
的import
语句将from 'vue'
变成from 'cengfan'
:
import Hello from './components/Add/index.ts'
import {ref} from 'cengfan'
console.log('Hello Main!')
Hello()
const a = ref('cengfan')
console.log(window)
console.log(a)
修改webpack.config.js
的externals
属性:
externals: {
cengfan: 'Vue',
},
启动服务后,仍能正常打印结果。
这下理解externals
的作用了吧,到这里它的使用就介绍完毕了,“优化应用性能”这一章节也讲解完毕。
至此,webpack常见的优化手段已介绍完毕,看到这里恭喜大家都能学成下山了。当然如开头所说,笔者介绍的方法肯定是不够全面的,如有补充欢迎各位读者列在评论区供大家一起参考学习。
六. 尾巴
webpack系列的第二个坑正式填完,这也是我在掘金的第一篇万字文章,马一下留个纪念。
本篇文章万字,45张图,希望体系化的总结,大量的讲解和案例对你有所帮助。
前段时间工作量较大,距离上次更新也过去了将近2个月,不过好饭不怕晚,祝大家用餐愉快。本系列的下一篇实战篇——“使用webpack从0到1搭建React的开发环境”即将新建文件夹,有缘我们下次再见!
我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师,希望本次的分享对你有帮助。

最后贴上本文的参考资料链接:
转载自:https://juejin.cn/post/7129747165794009101