webpack 快速入门 系列 —— 性能
阅读原文时间:2021年07月19日阅读:1

其他章节请看:

webpack 快速入门 系列

本篇主要介绍 webpack 中的一些常用性能,包括热模块替换、source map、oneOf、缓存、tree shaking、代码分割、懒加载、渐进式网络应用程序、多进程打包、外部扩展(externals)和动态链接(dll)。

准备本篇的环境

虽然可以仅展示核心代码,但笔者认为在一个完整的环境中边看边做,举一反三,效果更佳。

这里的环境其实就是实战一一文完整的示例,包含打包样式、打包图片、以及打包javascript

项目结果如下:

webpack-example3
  - src                 // 项目源码
    - index.html        // 页面模板
    - index.js          // 入口
  - package.json        // 存放了项目依赖的包
  - webpack.config.js   // webpack配置文件

代码如下:

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=`, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <p>请查看控制台</p>
    <span class='m-box img-from-less'></span>
</body>
</html>


// index.js
console.log('hello');


// package.json
{
  "name": "webpack-example3",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack",
    "dev": "webpack-dev-server"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/preset-env": "^7.14.2",
    "babel-loader": "^8.2.2",
    "core-js": "3.11",
    "css-loader": "^5.2.4",
    "eslint": "^7.26.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-webpack-plugin": "^2.5.4",
    "file-loader": "^6.2.0",
    "html-loader": "^1.3.2",
    "html-webpack-plugin": "^4.5.2",
    "less-loader": "^7.3.0",
    "mini-css-extract-plugin": "^1.6.0",
    "optimize-css-assets-webpack-plugin": "^5.0.4",
    "postcss-loader": "^4.3.0",
    "postcss-preset-env": "^6.7.0",
    "url-loader": "^4.1.1",
    "webpack": "^4.46.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.2"
  }
}


// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');

process.env.NODE_ENV = 'development'

const postcssLoader = {
  loader: 'postcss-loader',
  options: {
    // postcss 只是个平台,具体功能需要使用插件
    // Set PostCSS options and plugins
    postcssOptions:{
      plugins:[
        // 配置插件 postcss-preset-env
        [
          "postcss-preset-env",
          {
            // browsers: 'chrome > 10',
            // stage:
          },
        ],
      ]
    }
  }
}

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        // 将 style-loader 改为 MiniCssExtractPlugin.loader
        use: [MiniCssExtractPlugin.loader, "css-loader", postcssLoader],
      },
      {
        test: /\.less$/i,
        loader: [
          // 将 style-loader 改为 MiniCssExtractPlugin.loader
          MiniCssExtractPlugin.loader,
          "css-loader",
          postcssLoader,
          "less-loader",
        ],
      },
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              // 指定文件的最大大小(以字节为单位)
              limit: 1024*6,
            },
          },
        ],
      },
      // +
      {
        test: /\.html$/i,
        loader: 'html-loader',
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                // +
                {
                  // 配置处理polyfill的方式
                  useBuiltIns: "usage",
                  // 版本与我们下载的版本保持一致
                  corejs: { version: "3.11"},
                  "targets": "> 0.25%, not dead"
                }
              ]
            ]
          }
        }
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin(),
    new OptimizeCssAssetsPlugin(),
    new HtmlWebpackPlugin({
        template: 'src/index.html'
    }),
    // new ESLintPlugin({
    //   // 将启用ESLint自动修复功能。此选项将更改源文件
    //   fix: true
    // })
  ],
  mode: 'development',
  devServer: {
    open: true,
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    port: 9000,
  },
};

Tip: 由于本篇不需要 eslint,为避免影响,所以先注释。

在 webpack-example3 目录下运行项目:

// 安装项目依赖的包
> npm i
// 启动服务
> npm run dev

浏览器会自动打开页面,如果看到”请查看控制台“,控制台也输出了“hello”,说明环境准备就绪。

:笔者运行 npm i 时出现了一些问题,在公司运行 npm i 验证此文是否正确,结果下载得很慢(好似卡住了),于是改为淘宝镜像 cnpm i,这次仅花少许时间就执行完毕,接着运行 npm run dev 却在终端报错。于是根据错误提示安装 babel-loader@7 ,再次重启服务,问题仍旧没有解决。回家后,运行 npm i,依赖安装成功,可能环境也很重要。

// 终端报错
...
 babel-loader@8 requires Babel 7.x (the package '@babel/core'). If you'd like to use Babel 6.x ('babel-core'), you should install 'babel-loader@7'.

热模块替换

模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。

Tip: HMR 不适用于生产环境,这意味着它应当用于开发环境

下面我们就从 html、css 和 js 三个角度来体验热模块替换。

启用 hmr

此功能可以很大程度提高生产效率。我们要做的就是更新 webpack-dev-server 配置, 然后使用 webpack 内置的 HMR 插件。

配置 hot: true 就能启用 hmr。

// webpack.config.js
module.exports = {
  devServer: {
    // 开启热模块替换
    hot: true
  }
}

css 使用 hmr

新建一个 css 文件,通过 index.js 引入:

// a.css
p{color:blue;}


// index.js
import './a.css'

首先我们先不开启 hmr,重启服务(npm run dev),浏览器文字显示蓝色。如果改为红色(color:red;),你会发现整个页面都刷新了,文字变为红色。

接着开启hmr(hot: true),重启服务,再次修改颜色,文字的颜色会改变,但整个页面不会刷新。

Tip:如果觉得每次重启服务,都会自动打开浏览器页面,你可以注释掉 open: true 来关闭这个特征。

这里 css 热模块之所以生效,除了在 dev-server 中开启了 hmr,另一个是借助了 mini-css-extract-plugin 这个包;而借助 style-loader 使用模块热替换来加载 CSS 也这么简单。

html 使用 hmr

没有开启热模块替换之前,修改 index.html 中的文字,浏览器页面会自动刷新;而开启之后,修改 html 中的文字,浏览器页面就不会自动刷新。

将 index.html 也配置到入口(entry)中:

// webpack.config.js
module.exports = {
  - entry: './src/index.js',
  // 将 index.html 也作为入口文件
  + entry: ['./src/index.js', './src/index.html'],
}

重启服务,再次修改 index.html,浏览器页面自动刷新,热模块替换对 html 没生效。

// index.html

- <p>请查看控制台</p>
+ <p>请查看控制台2</p>

Tip:热模块替换,就是一个模块发生了变化,只变更这一个,其他模块无需变化;而 index.html 不像 index.js 会有多个模块,index.html 只有一个模块,就是它自己,所以也就不需要热模块替换。

js 使用 hmr

首先在 dev-server 中开启 hmr,然后创建一个 js 模块,接着在 index.js 中引入:

// a.js
const i = 1;
console.log(i);


// index.js
// 引入 a.js 模块
import './a';

此刻,你若修改 i 的值(const i = 2;),则会发现浏览器页面会刷新。

要让热模块替换在 js 中生效,我们需要修改代码:

// index.js

// 引入 a.js 模块
import './a';

if (module.hot) {
  module.hot.accept('./a', () => {
    console.log('Accepting the updated printMe module!');
  });
}

再次修改 i 的值,控制台会输出新的值,但浏览器页面不会再刷新。

此时,如果你尝试给入口文件(index.js)底部增加一条语句 console.log('a');,你会发现浏览器还是会刷新。

所以这种方式对入口文件无效,只能处理非入口 js。

:如果一个 js 模块没有 HMR 处理函数,更新就会冒泡(bubble up)。

小结

模块热替换比较难以掌握。

社区还提供许多其他 loader,使 HMR 与各种框架和库平滑地进行交互:

  • Vue Loader: 此 loader 支持 vue 组件的 HMR,提供开箱即用体验。
  • React Hot Loader: 实时调整 react 组件。

source map

source map,提供一种源代码到构建后代码的映射,如果构建后代码出错了,通过映射可以方便的找到源代码出错的地方。

初步体验

我们先故意弄一个语法错误,看浏览器的控制台如何提示:

// a.js
const i = 1;
// 下一行语法错误
console.log(i)();


// 控制台提示 a.js 第3行出错
Uncaught TypeError: console.log(...) is not a function         a.js:3

点击“a.js:3”,显示内容为:

var i = 1; // 下一行语法错误

console.log(i)();

定位到了源码,很清晰。

假如换成 es6 的语法,点击进入的错误提示就没这么清晰了。请看示例:

// a.js
class Dog {
    constructor(name) {
        this.name = name;
    }

    say() {
        console.log(this.name)();
    }
}

new Dog('xiaole').say();


...
var Dog = /*#__PURE__*/function () {
  function Dog(name) {
    _classCallCheck(this, Dog);

    this.name = name;
  }

  _createClass(Dog, [{
    key: "say",
    value: function say() {
      console.log(this.name)(); // {1}
    }
  }]);

  return Dog;
}();

new Dog('xiaole').say();

错误提示会定位了行{1},我们看到的不在是自己编写的源码,而是通过 babel 编译后的代码。

接下来我们通过配置 devtool,选择一种 source map 格式来增强调试过程。不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。

Tip:Devtool 控制是否生成,以及如何生成 source map。

// webpack.config.js
module.exports = {
  devtool: 'source-map'
}

重启服务,通过错误提示点击进去,则会看到如下代码:

class Dog {
  constructor(name) {
    this.name = name;
  }

  say() {
    console.log(this.name)(); // {1}
  }
}

new Dog('xiaole').say();

不在是编译后的代码,而是我们的源码,而且在行{1}处,对错误也有清晰的提示。

不同的值

source map 格式有多种不同的值,以下是笔者对其中几种值的研究结论:

  • devtool: 'source-map'

    npm run build

    1. 会生成一个 dist/main.js.map 文件
    2. 在 dist/main.js 最后一行,有如下一行代码:
      //# sourceMappingURL=main.js.map
    3. 上文我们知道,调试能看到源码,官网文档的描述是 quality 是 original
    4. 构建(build)速度和重建(rebuild)速度都是最慢(slowest)
    5. 官网推荐其可作为生产的选择
  • devtool: inline-source-map

    npm run build

    1. 没生成一个 dist/main.js.map 文件
    2. 在 dist/main.js 最后一行,有如下一行代码:
      //# sourceMappingURL=data:application/json;charset=
    3. 调试能看到源码
    4. 构建(build)速度和重建(rebuild)速度都是最慢(slowest)
  • devtool: eval-source-map

    npm run build

    1. 没生成一个 dist/main.js.map 文件
    2. 在 dist/main.js 中有 15 处 sourceMappingURL。而 inline-source-map 只有一处。
    3. 调试能看到源码
    4. 构建(build)速度最慢(slowest),但重建(rebuild)速度正常(ok)
    5. 官网推荐其可作为开发的选择
  • devtool: hidden-source-map

    npm run build

    1. 生成一个 dist/main.js.map 文件
    2. 点击错误提示,看到的是编译后的代码
      Uncaught TypeError: console.log(…) is not a function main.js:11508
    3. 构建(build)速度和重建(rebuild)速度都是最慢(slowest)

:官网说 hidden-source-map 的品质是 original,但笔者这里却是编译后的!

如何选择

source map 有很多不同的值,我们该如何选择?

幸好官网给出了建议。

开发环境,我们要求构建速度要快,方便调试:

  • eval-source-map,每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。

生成环境,考虑到代码是否要隐藏,是否需要方便调试:

  • source-map,整个 source map 作为一个单独的文件生成。它为 bundle 添加了一个引用注释,以便开发工具知道在哪里可以找到它。官网推荐其可作为生产的选择。
  • (none)(省略 devtool 选项),不生成 source map,也是一个不错的选择

Tip:若你还有一些特别的需求,就去官网寻找答案

oneOf

oneof 与下面程序的 break 作用类似:

let count = 1
for(; count < 10; count++){
  if(count === 3){
    break;
  }
}
console.log(`匹配了${count}次`) // 匹配了3次

这段代码,只要 count 等于 3,就会被 break 中断退出循环。

通常,我们会这样定义多个规则:

module: {
    rules: [{
        test: /\.css$/i,
        loader: ...
      },
      {
        test: /\.css$/i,
        loader: ...
      },
      {
        test: /\.less$/i,
        loader: ...
      },
      {
        test: /\.(png|jpg|gif)$/i,
        loader: ...
      }
      ...
    ]

当 a.css 匹配了第一个规则,还会继续尝试匹配剩余的规则。而我希望提高一下性能,只要匹配上,就不在匹配剩余规则。则可以使用 Rule.oneOf,就像这样:

module: {
    rules: [
      {
        oneOf: [{
            test: /\.css$/i,
            loader: ...
          },
          {
            test: /\.less$/i,
            loader: ...
          },
          {
            test: /\.(png|jpg|gif)$/i,
            loader: ...
          }
          ...
        ]
      }
    ]

如果同一种文件需要执行多个 loader,就像这里 css 有 2 个 loader。我们可以把其中一个 loader 提到 rules 中,就像这样:

module: {
    rules: [
      {
        test: /\.css$/i,
        // 优先执行
        enforce: 'pre'
        loader: ...
      },
      {
        oneOf: [{
            test: /\.css$/i,
            loader: ...
          },
          ...
       ]
      }
    ]

Tip: 可以通过配置 enforce 指定优先执行该loader

缓存

babel 缓存

让第二次构建速度更快。

配置很简单,就是给 babel-loader 添加一个选项:

{
  loader: 'babel-loader',
  options: {
    presets: [
      ...
    ],
    // 开启缓存
    cacheDirectory: true
  }
}

Tip:因为要经过 babel-loader 编译,如果代码量太少,就不太准确,建议找大量的 es6 代码自行测试。

静态资源的缓存

Tip: 本小节讲的其实就是 hash、chunkhash和conenthash。

通常我们将代码编译到 dist 目录中,然后发布到服务器上,对于一些静态资源,我们会设置其缓存。

具体做法如下:

通过命令 npm run build 将代码编译到 dist 目录;

接着通过 express 启动服务,该服务会读取 dist 中的内容,相当于把代码发布到服务器上:

// 安装依赖
> npm i -D express@4


// 在项目根目录下创建一个服务:server.js
const express = require('express')
const app = express()
const port = 3001

app.use(express.static('dist'));

// 监听服务
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})


> nodemon server.js
[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node server.js`
Example app listening at http://localhost:3001

通过浏览器访问 http://localhost:3001,多刷新几次,在网络中会看见 main.js 的状态是 304,笔者这里的时间在2ms或5ms之间。

Tip:304 仍然会发送请求,通常请求头中 If-Modified-Since 的值和响应头中 Last-Modified 的值是相同的。

If-Modified-Since: Sat, 17 Jul 2021 02:34:06 GMT

Last-Modified: Sat, 17 Jul 2021 02:34:06 GMT

接下来我给静态资源增加缓存,这里就增加一个 10 秒的缓存:

// server.js

- app.use(express.static('dist'));
+ app.use(express.static('dist', { maxAge: 1000 * 10 }));

再次请求,发现 main.js 首先是 304,接下来10秒内状态码则是200,大小则指示来自内存,时间也变为 0 ms。过10秒后再次请求,又是 304。

现在有一个问题,在强缓存期间,如果出现了bug,我们哪怕修复了,用户使用却还是缓存中有问题的代码。

我们模拟一下这个过程图:先将缓存改长一点,比如 1 天,用户访问先输出 1,让浏览器缓存后,我们再修改代码让其输出 2,用户再次访问会输出什么?

// server.js
app.use(express.static('dist', { maxAge: '1d' }));


// index.js
console.log('1');

重新打包生成 dist,接着用户通过浏览器访问,控制台输出 1。

修改 js,重新打包生成 dist,再次访问,控制台还是输入 1。

// index.js
console.log('2');

:不要强刷,因为用户不知道强刷,也不会去管。

于是我们打算从文件名入手来解决此问题,我们依次来看看 hash、chunkhash和conenthash。

hash

核心代码如下:

// index.js
import './a.css'
console.log('1');


// a.css
p{color:red;}


// webpack.config.js

module.exports = {
  output: {
    filename: 'main.[hash:10].js',
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].[hash:10].css",
    })
  ]
}

重新打包:

> npm run build

> webpack-example3@1.0.0 build
> webpack

Hash: b2e057d598ca9092abd3
Version: webpack 4.46.0
Time: 4837ms
Built at: 2021-07-14 8:17:54 ├F10: PM┤
                 Asset       Size  Chunks                         Chunk Names
            index.html  417 bytes          [emitted]
   main.b2e057d598.css   12 bytes    main  [emitted] [immutable]  main
    main.b2e057d598.js   5.22 KiB    main  [emitted] [immutable]  main
Entrypoint main = main.b2e057d598.css main.b2e057d598.js main.b2e057d598.js.map

主要看生成的 css 和 js 文件,名字中都带有相同的值 b2e057d598,取的是生成的 Hash 的前10位。index.html 中也会自动引入对应的文件名。

现在浏览器访问,文字是红色,控制台输出1。

接着模拟修复缺陷,将文字改为蓝色,再次打包。

p{color:blue;}


> npm run build

> webpack-example3@1.0.0 build
> webpack

Hash: ed2cd907a36536276d20
Version: webpack 4.46.0
Time: 4771ms
Built at: 2021-07-14 8:29:14 ├F10: PM┤
                 Asset       Size  Chunks                         Chunk Names
            index.html  417 bytes          [emitted]
   main.ed2cd907a3.css   13 bytes    main  [emitted] [immutable]  main
    main.ed2cd907a3.js   5.22 KiB    main  [emitted] [immutable]  main

浏览器访问,文字确实变为蓝色。但 js 和 css 都重新请求了,再看打包生成的文件,js 和 css 也都重新生成了新的文件名。这个会导致一个问题,只修改一个文件,其他的所有缓存都会失效。

Tip:这里修复的是 css,如果修复 js 也同样会导致所有缓存失效。

chunkhash

hash 会导致所有缓存失效,我们将其改为 chunkhash,还是存在相同的问题。请看示例:

将 hash 改为 chunkhash:

// webpack.config.js

module.exports = {
  output: {
    filename: 'main.[chunkhash:10].js',
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].[chunkhash:10].css",
    })
  ]
}

修改 css,然后重新打包,发现 js 和 css 文件也都重新生成了,虽然 chunkhash 与 hash 值不相同,但 main.js 和 main.css 中的 chunkhash 是一样的:

> npm run build

> webpack-example3@1.0.0 build
> webpack

Hash: 8c1c035175aae3d36fea
Version: webpack 4.46.0
Time: 5000ms
Built at: 2021-07-14 9:16:46 ├F10: PM┤
                 Asset       Size  Chunks                         Chunk Names
            index.html  417 bytes          [emitted]
   main.619734f520.css   13 bytes    main  [emitted] [immutable]  main
    main.619734f520.js   5.22 KiB    main  [emitted] [immutable]  main

Tip: 通过入口文件引入的模块都属于一个 chunk。这里 css 是通过入口文件(index.js)引入的,所以 main.js 和 main.css 的 chunkhash 值相同。

contenthash

contenthash 是根据文件内容来的,可以较好的解决以上问题。请看示例:

将 chunkhash 改为 contenthash,然后打包:

// webpack.config.js

module.exports = {
  output: {
    filename: 'main.[contenthash:10].js',
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].[contenthash:10].css",
    })
  ]
}


> npm run build

> webpack-example3@1.0.0 build
> webpack

Hash: 12994324788654e2ffc4
Version: webpack 4.46.0
Time: 5115ms
Built at: 2021-07-14 9:26:59 ├F10: PM┤
                 Asset       Size  Chunks                         Chunk Names
            index.html  417 bytes          [emitted]
   main.21668176f0.css   12 bytes    main  [emitted] [immutable]  main
    main.8983191438.js   5.22 KiB    main  [emitted] [immutable]  main

这次,js 和 css 的 hash 值不在相同。通过浏览器访问多次后,main.js 和 main.css 也都被强缓存。

修改css:

p{color:yellow;}

打包发现 js(main.8983191438.js) 没有变,只有 css 变了:

> npm run build

> webpack-example3@1.0.0 build
> webpack

Hash: 1598c3794090ebc6964c
Version: webpack 4.46.0
Time: 4905ms
Built at: 2021-07-14 9:31:14 ├F10: PM┤
                 Asset       Size  Chunks                         Chunk Names
            index.html  417 bytes          [emitted]
   main.0241bb73c4.css   13 bytes    main  [emitted] [immutable]  main
    main.8983191438.js   5.22 KiB    main  [emitted] [immutable]  main

再次通过浏览器访问,发现 css 请求了新的文件,而 js 还是来自缓存。

Tip: 是否要将 hash 清除?

注:此刻运行 npm run build 会报错,为了不影响下面的介绍,所以将 hash 去除,source map 也不需要,一并删除。

ERROR in chunk main [entry]
Cannot use [chunkhash] or [contenthash] for chunk in 'main.[contenthash:10].js' (use [hash] instead)

tree shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。

使用树摇非常简单,只需要满足两个条件:

  • 使用 es6 模块化
  • 模式(mode)开启production

直接演示,请看:

a.js 中导出 a 和 b,但在index.js 中只使用了a:

// a.js
export let a = 'hello'
export let b = 'jack'


// index.js
import { a } from './a.js'
console.log(a);

首先在开发模式下测试,发现 a.js 中的”hello“和”jack“都打包进去了,请看示例:

module.exports = {
  mode: 'development',
}


// dist/main.js
// a 和 b 都被打包进来,尽管 b 没有被用到

var a = 'hello';
var b = 'jack';

而在生成模式下,只有用到的 a 才被打包进去,请看示例:

module.exports = {
  mode: 'production',
}


// dist/main.js
// 只找到 hello,没有找到 jack

console.log("hello")

将文件标记为 side-effect-free(无副作用)

在一个纯粹的 ESM 模块世界中,很容易识别出哪些文件有副作用。然而,我们的项目无法达到这种纯度,所以,此时有必要提示 webpack compiler 哪些代码是“纯粹部分”。

通过 package.json 的 "sideEffects" 属性,来实现这种方式。

{
  "sideEffects": false
}

如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false,来告知 webpack 它可以安全地删除未用到的 export。

Tip:"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。

我们通过一个例子说明下:

在入口文件引入 css 文件:

// index.js
import './a.css'
import { a } from './a.js'
console.log(a);


// a.css
p{color:yellow;}


// webapck.config.js
mode: 'production'

打包会生成 css:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html  342 bytes          [emitted]
  main.css   13 bytes       0  [emitted]  main
   main.js    1.3 KiB       0  [emitted]  main

在 package.json 添加 "sideEffects": false,标注所有代码都不包含副作用:

{
  "sideEffects": false
}

再次打包,则不会生成 css:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html  303 bytes          [emitted]
   main.js    1.3 KiB       0  [emitted]  main

:所有导入文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并 import 一个 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:

// package.json
{
  "sideEffects": [
    "*.css",
    "*.less"
  ]
}

代码分割

将一个文件分割成多个,加载速度可能会更快,而且分割成多个文件后,还可以实现按需加载。

optimization.splitChunks

对于动态导入模块,默认使用 webpack v4+ 提供的全新的通用分块策略(common chunk strategy) —— SplitChunksPlugin。

开箱即用的 SplitChunksPlugin 对于大部分用户来说非常友好。

webpack 将根据以下条件自动拆分 chunks:

  • 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
  • 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
  • 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
  • 当加载初始化页面时,并发请求的最大数量小于或等于 30

Tip: SplitChunksPlugin的默认配置如下:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

默认配置很多,如果我们不需要修改,则不用管它们,下面我们来体验一下 splitChunks.chunks:

Tip:splitChunks.chunks,表明将选择哪些 chunk 进行优化。当提供一个字符串,有效值为 all,async 和 initial。设置为 all 可能特别强大,因为这意味着 chunk 可以在异步和非异步 chunk 之间共享。

> npm i lodash@4


// index.js
import _ from 'lodash';

console.log(_);

打包只生成一个 js:

> npm run build

     Asset       Size  Chunks             Chunk Names
index.html  303 bytes          [emitted]
   main.js   72.7 KiB       0  [emitted]  main

配置splitChunks.chunks:

// webapck.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

再次打包,这次生成两个 js,其中Chunk Names 是 vendors~main 对应的就是 loadsh:

> npm run build

     Asset       Size  Chunks             Chunk Names
 1.main.js   71.5 KiB       1  [emitted]  vendors~main
index.html  336 bytes          [emitted]
   main.js    1.9 KiB       0  [emitted]  main

同一个 chunk 中,如果 index.js 和 a.js 都引入 loadash,会如何打包?请看示例:

// index.js
import {a} from './a.js'
import _ from 'lodash';
console.log(a)
console.log(_);


// a.js
export let a = 'hello'
export let b = 'jack'


> npm run build

     Asset       Size  Chunks             Chunk Names
 1.main.js   71.5 KiB       1  [emitted]  vendors~main
index.html  336 bytes          [emitted]
   main.js   1.92 KiB       0  [emitted]  main

同样是两个 js,而且 loadash 应该是公用了,因为 main.js 较上次只增加了 0.02 kb。

动态导入

使用动态导入可以分离出 chunk。

请看示例:

上文我们知道,这段代码打包会生成两个 js,其中 main.js 包含了 a.js。

// index.js
import {a} from './a.js'
import _ from 'lodash';
console.log(a)
console.log(_);

将其中的 a.js 改为动态导入的方式:

// index.js

import _ from 'lodash';
// 动态导入
import(/* webpackChunkName: 'a' */'./a').then((aModule) => {
    console.log(aModule.a);
});
console.log(_);

打包:

> npm run build

     Asset       Size  Chunks             Chunk Names
 0.main.js  192 bytes       0  [emitted]  a
 2.main.js   94.6 KiB       2  [emitted]  vendors~main
index.html  336 bytes          [emitted]
   main.js   2.75 KiB       1  [emitted]  main

其中 a.js 被单独打包成一个js(从 Chunk Names 为 a 可以得知)

懒加载

懒加载就是用到的时候在加载。

请看示例:

我们在入口文件注册一个点击事件,只有点击时才加载 a.js。

// index.js
document.body.onclick = function () {
    // 动态导入
    import(/* webpackChunkName: 'a' */'./a').then((aModule) => {
        console.log(aModule.a);
    });
};


// a.js
console.log('moduleA');
export let a = 'hello'
export let b = 'jack'

启动服务,测试:

> npm run dev

第一次点击:moduleA hello

第二次点击:hello

只有第一次点击,才会请求 a.js 模块。

Tip:懒加载其实用到的就是上文介绍的动态导入

预获取

思路可能是这样:

  1. 首先使用普通模式
  2. 普通模式下,一次性加载太多,而 a.js 这个文件又有点大,于是就使用懒加载,需要使用的时候在加载 a.js
  3. 触发点击事件,懒加载 a.js,但 a.js 很大,需要等待好几秒中才触发,于是我想预获取来减少等待的时间

将懒加载改为预获取:

// index.js
document.body.onclick = function () {
    // 动态导入
    import(/* webpackChunkName: 'a', webpackPrefetch: true*/'./a').then((aModule) => {
        console.log(aModule.a);
    });
};

刷新浏览器,发现 a.js 被加载了;触发点击事件,输出 moduleA hello,再次点击,输出 hello。

Tip:浏览器中有如下一段代码:

// 指示着浏览器在闲置时间预取 0.main.a3f7d94cb1.js
<link rel="prefetch" as="script" href="0.main.a3f7d94cb1.js">

预获取和懒加载的不同是,预获取会在空闲的时候先加载。

渐进式网络应用程序

渐进式网络应用程序(progressive web application - PWA),是一种可以提供类似于 native app(原生应用程序) 体验的 web app(网络应用程序)。PWA 可以用来做很多事。其中最重要的是,在离线(offline)时应用程序能够继续运行功能。这是通过使用名为 Service Workers 的 web 技术来实现的。

我们首先通过一个包来启动服务:

> npm i -D http-server@0


// package.json
{
  "scripts": {
    "start": "http-server dist"
  },
}


> npm run build

启动服务:

> npm run start

> webpack-example3@1.0.0 start
> http-server dist

Starting up http-server, serving dist
Available on:
  http://192.168.85.1:8080
  http://192.168.75.1:8080
  http://192.168.0.103:8080
  http://127.0.0.1:8080
Hit CTRL-C to stop the server

:多个 url 与适配器有关:

> ipconfig

以太网适配器 VMware Network Adapter VMnet1:
   IPv4 地址 . . . . . . . . . . . . : 192.168.85.1

以太网适配器 VMware Network Adapter VMnet8:
   IPv4 地址 . . . . . . . . . . . . : 192.168.75.1

无线局域网适配器 WLAN:
   IPv4 地址 . . . . . . . . . . . . : 192.168.0.103

通过浏览器访问 http://127.0.0.1:8080。如果我们将服务器关闭,再次刷新页面,则不能再访问。

接下来我们要做的事:通过离线技术让网页再服务器关闭时还能访问。

请看示例:

添加 workbox-webpack-plugin 插件,然后调整 webpack.config.js 文件:

> npm i -D workbox-webpack-plugin@6


// webapck.config.js
  const WorkboxPlugin = require('workbox-webpack-plugin');
  module.exports = {
    plugins: [
     new WorkboxPlugin.GenerateSW({
       // 这些选项帮助快速启用 ServiceWorkers
       // 不允许遗留任何“旧的” ServiceWorkers
       clientsClaim: true,
       skipWaiting: true,
     }),
    ],
  };

完成这些设置,再次打包,看下会发生什么:

> npm run build

              Asset       Size  Chunks             Chunk Names
          0.main.js  192 bytes       0  [emitted]  a
          2.main.js   94.6 KiB       2  [emitted]  vendors~main
         index.html  336 bytes          [emitted]
            main.js   2.75 KiB       1  [emitted]  main
  service-worker.js   1.11 KiB          [emitted]
workbox-15dd0bab.js   13.6 KiB          [emitted]

生成了两个额外的文件:service-worker.js 和 workbox-15dd0bab.js。service-worker.js 是 Service Worker 文件。

值得高兴的是,我们现在已经创建出一个 Service Worker。接下来我们注册 Service Worker。

// index.js
document.body.onclick = function () {
    // 动态导入
    import(/* webpackChunkName: 'a', webpackPrefetch: true*/'./a').then((aModule) => {
        console.log(aModule.a);
    });
};

if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js').then(registration => {
            console.log('SW registered: ', registration);
        }).catch(registrationError => {
            console.log('SW registration failed: ', registrationError);
        });
    });
}

再次运行 npm run build 来构建包含注册代码版本的应用程序。然后用 npm start 启动服务。访问 http://127.0.0.1:8080/ 并查看 console 控制台。在那里你应该看到:

SW registered

Tip:如果没有看见 SW registered,可以尝试强刷

现在来进行测试。停止 server 并刷新页面。如果浏览器能够支持 Service Worker,应该可以看到你的应用程序还在正常运行。然而,server 已经停止 serve 整个 dist 文件夹,此刻是 Service Worker 在进行 serve。

Tip:更过 pwa 可以参考 "mdn 渐进式应用程序";淘宝(taobao.com)以前有 pwa,现在却没有了。

多进程打包

通过多进程打包,用的好可以加快打包的速度,用得不好甚至会更慢。

这里使用一个名为 thread-loader 包来做多进程打包。每个 worker 是一个单独的 node.js 进程,开销约 600 毫秒,还有一个进程间通信的开销。

:仅将此加载器用于昂贵的操作!比如 babel

我们演示一下:

未使用多进程打包时间是 3122ms:

// index.js
import _ from 'lodash'
console.log(_);


> npm run build
Hash: a4868f457d7ce754335b
Version: webpack 4.46.0
Time: 3031ms

加入多线程:

> npm i -D thread-loader@3


// webpack.config.js -> module.exports -> module.rules
{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [
    'thread-loader',
    {
      loader: 'babel-loader',
      ...
    }
  ]
}


> npm run build

Hash: a4868f457d7ce754335b
Version: webpack 4.46.0
Time: 3401ms

构建时间更长。

Tip: 可能是代码中需要 babel 的 js 代码太少,所以导致多线程效果不明显。

外部扩展(externals)

externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。

externals

防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。

例如 jQuery 这个库来自 cdn,则不需要将 jQuery 打包。请看示例:

Tip: 为了测试看得更清晰,注释掉 pwa 和 splitChunks。

> npm i jquery@3


// index.js
import $ from 'jquery';

console.log($);

打包生成一个 js,其中包含了 jquery:

> npm run build

              Asset       Size  Chunks             Chunk Names
          1.main.js     88 KiB       1  [emitted]  vendors~main
         index.html  336 bytes          [emitted]
            main.js    1.9 KiB       0  [emitted]  main

由于开启了 splitChunks,这里 1.main.js 就是 jquery。

使用 external 将 jQuery 排除:

// webpack.config.js
module.exports = {
  externals: {
    // jQuery 是jquery暴露给window的变量名,这里可以将 jQuery 改为 $,但 jquery 却不行
    jquery: 'jQuery'
  }
};

在 index.html 中手动引入 jquery:

// src/index.html

<script src="https://cdn.bootcdn.net/ajax/libs/jquery/1.7.2/jquery.min.js"></script>

Tip: 我们使用 bootstrap cdn。

再次打包,则不在包含 jquery:

> npm run build

              Asset        Size  Chunks             Chunk Names
         index.html   303 bytes          [emitted]
            main.js    1.35 KiB       0  [emitted]  main

Tip:如果你在开发模式(mode: 'development')下打包,你会发现 main.js 中会有如下这段代码:

/***/ "jquery":
/*!*************************!*\
  !*** external "jQuery" ***!
  \*************************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("module.exports = jQuery;\n\n//# sourceURL=webpack:///external_%22jQuery%22?");

/***/ })

这里的 jQuery 来自我们手动通过 <script src=> 引入 jquery 所产生的全局变量。

动态链接(dll)

所谓动态链接,就是把一些经常会共享的代码制作成 DLL 档,当可执行文件调用到 DLL 档内的函数时,Windows 操作系统才会把 DLL 档加载存储器内,DLL 档本身的结构就是可执行档,当程序有需求时函数才进行链接。透过动态链接方式,存储器浪费的情形将可大幅降低。

对于 webpack 就是事先将常用又构建时间长的代码提前打包好,取名为 dll,后面打包时则直接使用 dll,用来提高打包速度

vue-cli 删除了 dll

在 vue-cli 提交记录中发现:remove DLL option。

原因是:dll 选项将被删除。 Webpack 4 应该提供足够好的性能,并且在 Vue CLI 中维护 DLL 模式的成本不再合理。

Tip: 详情请看issue

核心代码

附上项目最终核心文件,方便学习和解惑。

webapck.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');

process.env.NODE_ENV = 'development'

const postcssLoader = {
    loader: 'postcss-loader',
    options: {
        // postcss 只是个平台,具体功能需要使用插件
        // Set PostCSS options and plugins
        postcssOptions: {
            plugins: [
                // 配置插件 postcss-preset-env
                [
                    "postcss-preset-env",
                    {
                        // browsers: 'chrome > 10',
                        // stage:
                    },
                ],
            ]
        }
    }
}

module.exports = {
    entry: './src/index.js',
    entry: ['./src/index.js', './src/index.html'],
    output: {
        filename: 'main.js',
        // filename: 'main.[contenthash:10].js',

        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                // 将 style-loader 改为 MiniCssExtractPlugin.loader
                use: [MiniCssExtractPlugin.loader, "css-loader", postcssLoader],
            },
            {
                test: /\.less$/i,
                loader: [
                    // 将 style-loader 改为 MiniCssExtractPlugin.loader
                    MiniCssExtractPlugin.loader,
                    "css-loader",
                    postcssLoader,
                    "less-loader",
                ],
            },
            {
                test: /\.(png|jpg|gif)$/i,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            // 指定文件的最大大小(以字节为单位)
                            limit: 1024 * 6,
                        },
                    },
                ],
            },
            // +
            {
                test: /\.html$/i,
                loader: 'html-loader',
            },
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: [
                    // 'thread-loader',
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                [
                                    '@babel/preset-env',
                                    // +
                                    {
                                        // 配置处理polyfill的方式
                                        useBuiltIns: "usage",
                                        // 版本与我们下载的版本保持一致
                                        corejs: { version: "3.11" },
                                        "targets": "> 0.25%, not dead"
                                    }
                                ]
                            ],
                            // 开启缓存
                            cacheDirectory: true
                        }
                    }]
            }
        ]
    },
    plugins: [
        // new MiniCssExtractPlugin(),
        new MiniCssExtractPlugin({
            // filename: "[name].[contenthash:10].css",
        }),
        new OptimizeCssAssetsPlugin(),
        new HtmlWebpackPlugin({
            template: 'src/index.html'
        }),
        // new ESLintPlugin({
        //   // 将启用ESLint自动修复功能。此选项将更改源文件
        //   fix: true
        // }),
        new WorkboxPlugin.GenerateSW({
            // 这些选项帮助快速启用 ServiceWorkers
            // 不允许遗留任何“旧的” ServiceWorkers
            clientsClaim: true,
            skipWaiting: true,
        }),
    ],
    mode: 'development',
    // mode: 'production',
    devServer: {
        // open: true,
        contentBase: path.join(__dirname, 'dist'),
        compress: true,
        port: 9000,
    },
    devServer: {
        // 开启热模块替换
        hot: true
    },
    // devtool: 'eval-source-map',
    optimization: {
        splitChunks: {
            chunks: 'all',
        },
    },
    externals: {
        // jQuery 是jquery暴露给window的变量名,这里可以将 jQuery 改为 $,但 jquery 却不行
        jquery: 'jQuery'
    }
};

package.json

{
  "name": "webpack-example3",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack",
    "dev": "webpack-dev-server",
    "start": "http-server dist"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/preset-env": "^7.14.2",
    "babel-loader": "^8.2.2",
    "core-js": "3.11",
    "css-loader": "^5.2.4",
    "eslint": "^7.26.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-webpack-plugin": "^2.5.4",
    "express": "^4.17.1",
    "file-loader": "^6.2.0",
    "html-loader": "^1.3.2",
    "html-webpack-plugin": "^4.5.2",
    "http-server": "^0.12.3",
    "less-loader": "^7.3.0",
    "mini-css-extract-plugin": "^1.6.0",
    "optimize-css-assets-webpack-plugin": "^5.0.4",
    "postcss-loader": "^4.3.0",
    "postcss-preset-env": "^6.7.0",
    "thread-loader": "^3.0.4",
    "url-loader": "^4.1.1",
    "webpack": "^4.46.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.2",
    "workbox-webpack-plugin": "^6.1.5"
  },
  "dependencies": {
    "jquery": "^3.6.0",
    "lodash": "^4.17.21",
    "vue": "^2.6.14"
  },
  "sideEffects": false
}

其他章节请看:

webpack 快速入门 系列