# 十一.优化前端资源加载
# 2.图片压缩
在一般的项目中,图片资源会占前端资源的很大一部分,既然代码都进行压缩了,占大头的图片就更不用说了。
我们之前提及使用 file-loader 来处理图片文件,在此基础上,我们再添加一个 image-webpack-loader (opens new window) 来压缩图片文件。简单的配置如下:
module.exports = {
// ...
module: {
rules: [
{
test: /.*\.(gif|png|jpe?g|svg|webp)$/i,
use: [
{
loader: 'file-loader',
options: {}
},
{
loader: 'image-webpack-loader',
options: {
mozjpeg: { // 压缩 jpeg 的配置
progressive: true,
quality: 65
},
optipng: { // 使用 imagemin-optipng 压缩 png,enable: false 为关闭
enabled: false,
},
pngquant: { // 使用 imagemin-pngquant 压缩 png
quality: '65-90',
speed: 4
},
gifsicle: { // 压缩 gif 的配置
interlaced: false,
},
webp: { // 开启 webp,会把 jpg 和 png 图片压缩为 webp 格式
quality: 75
},
},
],
},
],
},
}
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
image-webpack-loader 的压缩是使用 imagemin (opens new window) 提供的一系列图片压缩类库来处理的,如果需要进一步了解详细的配置,可以查看对应类库的官方文档 usage of image-webpack-loader (opens new window)。
# 3.使用 DataURL
有的时候我们的项目中会有一些很小的图片,因为某些缘故并不想使用 CSS Sprites 的方式来处理(譬如小图片不多,因此引入 CSS Sprites 感觉麻烦),那么我们可以在 webpack 中使用 url-loader 来处理这些很小的图片。
url-loader (opens new window) 和 file-loader (opens new window) 的功能类似,但是在处理文件的时候,可以通过配置指定一个大小,当文件小于这个配置值时,url-loader 会将其转换为一个 base64 编码的 DataURL,配置如下:
module.exports = {
// ...
module: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: "url-loader",
options: {
limit: 8192, // 单位是 Byte,当文件小于 8KB 时作为 DataURL 处理
},
},
],
},
],
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
更多关于 url-loader 的配置可以参考官方文档 url-loader (opens new window),一般情况仅使用 limit
即可。
# 4.代码压缩
webpack 4.x 版本运行时,mode 为 production 即会启动压缩 JS 代码的插件,而对于 webpack 3.x,使用压缩 JS 代码插件的方式也已经介绍过了。在生产环境中,压缩 JS 代码基本是一个必不可少的步骤,这样可以大大减小 JavaScript 的体积,相关内容这里不再赘述。
除了 JS 代码之外,我们一般还需要 HTML 和 CSS 文件,这两种文件也都是可以压缩的,虽然不像 JS 的压缩那么彻底(替换掉长变量等),只能移除空格换行等无用字符,但也能在一定程度上减小文件大小。在 webpack 中的配置使用也不是特别麻烦,所以我们通常也会使用。
对于 HTML 文件,之前介绍的 html-webpack-plugin 插件可以帮助我们生成需要的 HTML 并对其进行压缩:
module.exports = {
// ...
plugins: [
new HtmlWebpackPlugin({
filename: "index.html", // 配置输出文件名和路径
template: "assets/index.html", // 配置文件模板
minify: {
// 压缩 HTML 的配置
minifyCSS: true, // 压缩 HTML 中出现的 CSS 代码
minifyJS: true, // 压缩 HTML 中出现的 JS 代码
},
}),
],
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如上,使用 minify
字段配置就可以使用 HTML 压缩,这个插件是使用 html-minifier (opens new window) 来实现 HTML 代码压缩的,minify
下的配置项直接透传给 html-minifier,配置项参考 html-minifier 文档即可。
对于 CSS 文件,我们之前介绍过用来处理 CSS 文件的 css-loader,也提供了压缩 CSS 代码的功能:
module.exports = {
module: {
rules: [
// ...
{
test: /\.css/,
include: [path.resolve(__dirname, "src")],
use: [
"style-loader",
{
loader: "css-loader",
options: {
minimize: true, // 使用 css 的压缩功能
},
},
],
},
],
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在 css-loader 的选项中配置 minimize
字段为 true
来使用 CSS 压缩代码的功能。css-loader 是使用 cssnano (opens new window) 来压缩代码的,minimize
字段也可以配置为一个对象,来将相关配置传递给 cssnano。更多详细内容请参考 cssnano (opens new window) 官方文档。
# 5.分离代码文件
关于分离 CSS 文件这个主题,之前在介绍如何搭建基本的前端开发环境时有提及,在 webpack 中使用 extract-text-webpack-plugin (opens new window) 插件即可。
先简单解释一下为何要把 CSS 文件分离出来,而不是直接一起打包在 JS 中。最主要的原因是我们希望更好地利用缓存。
假设我们原本页面的静态资源都打包成一个 JS 文件,加载页面时虽然只需要加载一个 JS 文件,但是我们的代码一旦改变了,用户访问新的页面时就需要重新加载一个新的 JS 文件。有些情况下,我们只是单独修改了样式,这样也要重新加载整个应用的 JS 文件,相当不划算。
还有一种情况是我们有多个页面,它们都可以共用一部分样式(这是很常见的,CSS Reset、基础组件样式等基本都是跨页面通用),如果每个页面都单独打包一个 JS 文件,那么每次访问页面都会重复加载原本可以共享的那些 CSS 代码。如果分离开来,第二个页面就有了 CSS 文件的缓存,访问速度自然会加快。虽然对第一个页面来说多了一个请求,但是对随后的页面来说,缓存带来的速度提升相对更加可观。
因此当我们考虑更好地利用缓存来加速静态资源访问时,会尝试把一些公共资源单独分离开来,利用缓存加速,以避免重复的加载。除了公共的 CSS 文件或者图片资源等,当我们的 JS 代码文件过大的时候,也可以用代码文件拆分的办法来进行优化。
那么,如何使用 webpack 来把代码中公共使用的部分分离成为独立的文件呢?由于 webpack 4.x 和 webpack 3.x 在代码分离这一块的内容差别比较大,因而我们分别都介绍一下。
3.x 以前的版本是使用 CommonsChunkPlugin 来做代码分离的,而 webpack 4.x 则是把相关的功能包到了 optimize.splitChunks
中,直接使用该配置就可以实现代码分离。
我们先介绍在 webpack 4.x 中如何使用这个配置来实现代码分离。
# 6.optimization
webpack 的作者推荐直接这样简单地配置:
module.exports = {
// ... webpack 配置
optimization: {
splitChunks: {
chunks: "all", // 所有的 chunks 代码公共的部分分离出来成为一个单独的文件
},
},
}
2
3
4
5
6
7
8
9
我们需要在 HTML 中引用两个构建出来的 JS 文件,并且 commons.js 需要在入口代码之前。下面是个简单的例子:
<script src="commons.js" charset="utf-8"></script>
<script src="entry.bundle.js" charset="utf-8"></script>
2
如果你使用了 html-webpack-plugin,那么对应需要的 JS 文件都会在 HTML 文件中正确引用,不用担心。如果没有使用,那么你需要从 stats
的 entrypoints
属性来获取入口应该引用哪些 JS 文件,可以参考 Node API (opens new window) 了解如何从 stats
中获取信息,或者开发一个 plugin 来处理正确引用 JS 文件这个问题。第 15 小节会介绍如何开发 webpack plugin,plugin 提供的 API 也可以正确获取到 stats
中的数据。
之前我们提到拆分文件是为了更好地利用缓存,分离公共类库很大程度上是为了让多页面利用缓存,从而减少下载的代码量,同时,也有代码变更时可以利用缓存减少下载代码量的好处。从这个角度出发,笔者建议将公共使用的第三方类库显式地配置为公共的部分,而不是 webpack 自己去判断处理。因为公共的第三方类库通常升级频率相对低一些,这样可以避免因公共 chunk 的频繁变更而导致缓存失效。
显式配置共享类库可以这么操作:
module.exports = {
entry: {
vendor: ["react", "lodash", "angular", ...], // 指定公共使用的第三方类库
},
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
chunks: "initial",
test: "vendor",
name: "vendor", // 使用 vendor 入口作为公共部分
enforce: true,
},
},
},
},
// ... 其他配置
}
// 或者
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /react|angluar|lodash/, // 直接使用 test 来做路径匹配
chunks: "initial",
name: "vendor",
enforce: true,
},
},
},
},
}
// 或者
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
chunks: "initial",
test: path.resolve(__dirname, "node_modules") // 路径在 node_modules 目录下的都作为公共部分
name: "vendor", // 使用 vendor 入口作为公共部分
enforce: true,
},
},
},
},
}
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
上述第一种做法是显示指定哪些类库作为公共部分,第二种做法实现的功能差不多,只是利用了 test
来做模块路径的匹配,第三种做法是把所有在 node_modules 下的模块,即作为依赖安装的,都作为公共部分。你可以针对项目情况,选择最合适的做法。
# 8.按需加载模块
前面讲述了如何把大的代码文件进行拆分,抽离出多个页面共享的部分,但是当你的 Web 应用是单个页面,并且极其复杂的时候,你会发现有一些代码并不是每一个用户都需要用到的。你可能希望将这一部分代码抽离出去,仅当用户真正需要用到时才加载,这个时候就需要用到 webpack 提供的一个优化功能 —— 按需加载代码模块。
在 webpack 的构建环境中,要按需加载代码模块很简单,遵循 ES 标准的动态加载语法 dynamic-import (opens new window) 来编写代码即可,webpack 会自动处理使用该语法编写的模块:
// import 作为一个方法使用,传入模块名即可,返回一个 promise 来获取模块暴露的对象
// 注释 webpackChunkName: "lodash" 可以用于指定 chunk 的名称,在输出文件时有用
import(/* webpackChunkName: "lodash" */ "lodash").then((_) => {
console.log(_.lash([1, 2, 3])) // 打印 3
})
2
3
4
5
注意一下,如果你使用了 Babel (opens new window) 的话,还需要 Syntax Dynamic Import (opens new window) 这个 Babel 插件来处理 import()
这种语法。
由于动态加载代码模块的语法依赖于 promise,对于低版本的浏览器,需要添加 promise 的 polyfill (opens new window) 后才能使用。
如上的代码,webpack 构建时会自动把 lodash 模块分离出来,并且在代码内部实现动态加载 lodash 的功能。动态加载代码时依赖于网络,其模块内容会异步返回,所以 import
方法是返回一个 promise 来获取动态加载的模块内容。
import
后面的注释 webpackChunkName: "lodash"
用于告知 webpack 所要动态加载模块的名称。我们在 webpack 配置中添加一个 output.chunkFilename
的配置:
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[hash:8].js',
chunkFilename: '[name].[hash:8].js' // 指定分离出来的代码文件的名称
},
2
3
4
5
这样就可以把分离出来的文件名称用 lodash 标识了,如下图:
如果没有添加注释 webpackChunkName: "lodash"
以及 output.chunkFilename
配置,那么分离出来的文件名称会以简单数字的方式标识,不便于识别。
# 9.Tree shaking
Tree shaking 这个术语起源于 ES2015 模块打包工具 rollup (opens new window),依赖于 ES2015 模块系统中的静态结构特性 (opens new window),可以移除 JavaScript 上下文中的未引用代码,删掉用不着的代码,能够有效减少 JS 代码文件的大小。拿官方文档的例子来说明一下。
// src/math.js
export function square(x) {
return x * x
}
export function cube(x) {
return x * x * x
}
// src/index.js
import { cube } from "./math.js" // 在这里只是引用了 cube 这个方法
console.log(cube(3))
2
3
4
5
6
7
8
9
10
11
12
13
如果整个项目代码只是上述两个文件,那么很明显,square
这个方法是未被引用的代码,是可以删掉的。在 webpack 中,只有启动了 JS 代码压缩功能(即使用 uglify)时,会做 Tree shaking 的优化。webpack 4.x 需要指定 mode 为 production,而 webpack 3.x 的话需要配置 UglifyJsPlugin。启动了之后,构建出来的结果就会移除 square
的那一部分代码了。
如果你在项目中使用了 Babel (opens new window) 的话,要把 Babel 解析模块语法的功能关掉,在 .babelrc
配置中增加 "modules": false
这个配置:
{
"presets": [["env", { "modules": false }]]
}
2
3
这样可以把 import/export
的这一部分模块语法交由 webpack 处理,否则没法使用 Tree shaking 的优化。
有的时候你启用了 Tree shaking 功能,但是发现好像并没有什么用,例如这样一个例子:
// src/component.js
export class Person {
constructor({ name }) {
this.name = name
}
getName() {
return this.name
}
}
export class Apple {
constructor({ model }) {
this.model = model
}
getModel() {
return this.model
}
}
// src/index.js
import { Apple } from "./components"
const appleModel = new Apple({
model: "X",
}).getModel()
console.log(appleModel)
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
打包压缩后还是可以发现,Person
这一块看起来没用到的代码出现在文件中。关于这个问题,详细讲解的话篇幅太长了,建议自行阅读这一篇文章:你的 Tree-Shaking 并没什么卵用 (opens new window)。
这篇文章最近没有更新,但是 uglify 的相关 issue Class declaration in IIFE considered as side effect (opens new window) 是有进展的,现在如果你在 Babel 配置中增加 "loose": true
配置的话,Person
这一块代码就可以在构建时移除掉了。
# 10.sideEffects
这是 webpack 4.x 才具备的特性,暂时官方还没有比较全面的介绍文档,笔者从 webpack 的 examples 里找到一个东西:side-effects/README.md (opens new window)。
我们拿 lodash (opens new window) 举个例子。有些同学可能对 lodash (opens new window) 已经蛮熟悉了,它是一个工具库,提供了大量的对字符串、数组、对象等常见数据类型的处理函数,但是有的时候我们只是使用了其中的几个函数,全部函数的实现都打包到我们的应用代码中,其实很浪费。
webpack 的 sideEffects 可以帮助解决这个问题。现在 lodash 的 ES 版本 (opens new window) 的 package.json
文件中已经有 sideEffects: false
这个声明了,当某个模块的 package.json
文件中有了这个声明之后,webpack 会认为这个模块没有任何副作用,只是单纯用来对外暴露模块使用,那么在打包的时候就会做一些额外的处理。
例如你这么使用 lodash
:
import { forEach, includes } from "lodash-es"
forEach([1, 2], (item) => {
console.log(item)
})
console.log(includes([1, 2, 3], 1))
2
3
4
5
6
7
由于 lodash-es 这个模块的 package.json
文件有 sideEffects: false
的声明,所以 webpack 会将上述的代码转换为以下的代码去处理:
import { default as forEach } from "lodash-es/forEach"
import { default as includes } from "lodash-es/includes"
// ... 其他
2
3
4
最终 webpack 不会把 lodash-es 所有的代码内容打包进来,只是打包了你用到的那两个方法,这便是 sideEff