# 十一.优化前端资源加载

# 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
              },
          },
        ],
      },
    ],
  },
}
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

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 处理
            },
          },
        ],
      },
    ],
  },
}
1
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 代码
      },
    }),
  ],
}
1
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 的压缩功能
            },
          },
        ],
      },
    ],
  },
}
1
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 代码公共的部分分离出来成为一个单独的文件
    },
  },
}
1
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>
1
2

如果你使用了 html-webpack-plugin,那么对应需要的 JS 文件都会在 HTML 文件中正确引用,不用担心。如果没有使用,那么你需要从 statsentrypoints 属性来获取入口应该引用哪些 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,
        },
      },
    },
  },
}
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

上述第一种做法是显示指定哪些类库作为公共部分,第二种做法实现的功能差不多,只是利用了 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
})
1
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' // 指定分离出来的代码文件的名称
},
1
2
3
4
5

这样就可以把分离出来的文件名称用 lodash 标识了,如下图:

dynamic-import

如果没有添加注释 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))
1
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 }]]
}
1
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)
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

打包压缩后还是可以发现,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))
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"

// ... 其他
1
2
3
4

最终 webpack 不会把 lodash-es 所有的代码内容打包进来,只是打包了你用到的那两个方法,这便是 sideEff