Printable

指南

指南章节用于理解和掌握 webpack 提供的各种工具和特性。首先是带你 起步 的指引。

指南章节会逐步带你由浅入深。本章节更多是作为一个切入点,阅读完成后会更加容易深入 配置 文档中。

起步

webpack 用于编译 JavaScript 模块。一旦完成 安装 就可以通过 webpack CLIAPI 与其配合交互。如果还不熟悉 webpack,请阅读 核心概念对比 了解为什么是使用 webpack 而非社区中的其他工具。

基础设置

首先创建进入一个目录并初始化 npm,然后 在本地安装 webpack,接着安装 webpack-cli(此工具用于在命令行中运行 webpack):

mkdir webpack-demo
cd webpack-demo
npm init -y
npm install webpack webpack-cli --save-dev

整篇指南将使用 diff 代码块展示对目录、文件和代码所做的修改。例如:

+ 这是需要复制到代码的新的一行
- 这是需要从代码中移除的一行
  这是不需要调整的一行

现在创建以下目录结构、文件和内容:

project

  webpack-demo
  |- package.json
  |- package-lock.json
+ |- index.html
+ |- /src
+   |- index.js

src/index.js

function component() {
  const element = document.createElement('div');

  // 执行这一行需要引入 lodash(目前通过 script 脚本引入)
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

  return element;
}

document.body.appendChild(component());

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>起步</title>
    <script src="https://unpkg.com/lodash@4.17.20"></script>
  </head>
  <body>
    <script src="./src/index.js"></script>
  </body>
</html>

还需要调整 package.json 文件以确保安装包是私有的,并移除 main 入口,这可以防止意外发布代码。

package.json

 {
   "name": "webpack-demo",
   "version": "1.0.0",
   "description": "",
-  "main": "index.js",
+  "private": true,
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "keywords": [],
   "author": "",
   "license": "MIT",
   "devDependencies": {
     "webpack": "^5.38.1",
     "webpack-cli": "^4.7.2"
   }
 }

在此示例中,<script> 标签之间存在隐式依赖关系。在 index.js 文件执行之前,还需要在页面中先引入 lodashindex.js 并未显式声明需要 lodash,而是假定推测已经存在一个全局变量 _

使用这种方式管理 JavaScript 项目存在一些问题:

  • 无法直接体现脚本的执行依赖于外部库。
  • 如果依赖不存在,或者引入顺序错误,应用程序将无法正常运行。
  • 如果依赖被引入但是并没有使用,浏览器将被迫下载无用代码。

试试使用 webpack 管理这些脚本。

创建一个 bundle

首先稍微调整下目录结构,创建 ./dist 文件夹用于存放分发(distribution)代码,而 ./src 文件夹仍存放源代码。源代码是指用于书写和编辑的代码;分发代码是指在构建过程中,经过最小化和优化后产生的输出结果,分发代码最终将在浏览器中加载。调整后的目录结构如下:

project

  webpack-demo
  |- package.json
  |- package-lock.json
+ |- /dist
+   |- index.html
- |- index.html
  |- /src
    |- index.js

在本地安装 lodash 以在 index.js 中打包它:

npm install --save lodash

现在在脚本中导入 lodash

src/index.js

+import _ from 'lodash';
+
 function component() {
   const element = document.createElement('div');

-  // 执行这一行需要引入 lodash(目前通过 script 脚本引入)
+  // lodash 现在使用 import 引入
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');

   return element;
 }

 document.body.appendChild(component());

现在将打包所有脚本,因此需要更新 index.html 文件。由于现在是通过 import 引入 lodash,所以应将 lodash <script> 删除;然后修改另一个 <script> 标签来加载 bundle,而不是原始的 ./src 文件:

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
     <title>起步</title>
-    <script src="https://unpkg.com/lodash@4.17.20"></script>
   </head>
   <body>
-    <script src="./src/index.js"></script>
+    <script src="main.js"></script>
   </body>
 </html>

在此设置中 index.js 显式要求引入的 lodash 必须存在,然后将它绑定为 _(没有全局作用域污染)。webpack 能够利用模块生命的依赖信息构建依赖图,然后使用图生成一个优化过的 bundle,并能保证以正确顺序执行。

换言之,执行 npx webpack 会将脚本 src/index.js 作为 入口起点,也会生成 dist/main.js 作为 输出。Node 8.2/npm 5.2.0 及以上版本提供的 npx 命令,可以运行在最初安装的 webpack 包中的 webpack 二进制文件(即 ./node_modules/.bin/webpack):

$ npx webpack
[webpack-cli] Compilation finished
asset main.js 69.3 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 1851 ms

在浏览器中打开 dist 目录下的 index.html,如果一切正常就能看到以下文本:'Hello webpack'

模块

尽管大多数浏览器还无法支持在 ES2015 中已经标准化的 importexport,但 webpack 能够提供开箱即用般的支持。

事实上,webpack 会将代码转译以便可以在旧版本浏览器中执行。检查 dist/main.js 你可以看到 webpack 具体是如何工作的,这是独创精巧的设计!除了 importexport,webpack 还能够很好地支持多种其他模块语法,查看 模块 API 以了解更多相关信息。

注意,webpack 不会更改代码中除 importexport 语句以外的部分。如果正在使用其它 ES2015 特性,请确保 webpack 的 loader 系统 中使用了像 Babel 一样的 转译器

使用配置文件

webpack v4 无须任何配置即可运行,然而大多数项目会需要很复杂的设置,因此 webpack 仍然支持 配置文件,这比在终端中手动输入大量命令更加高效。接下来创建一个 webpack 配置文件:

project

  webpack-demo
  |- package.json
  |- package-lock.json
+ |- webpack.config.js
  |- /dist
    |- index.html
  |- /src
    |- index.js

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

现在,让我们通过新的配置文件再次执行构建:

$ npx webpack --config webpack.config.js
[webpack-cli] Compilation finished
asset main.js 69.3 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 1934 ms

比起 CLI 这种简单直接的使用方式,配置文件更加灵活。开发者可以在配置文件中指定 loader 规则、插件、resolve 选项,以及许多其他增强功能。请查看 配置文档 以了解更多相关信息。

npm scripts

考虑到使用 CLI 这种方式运行本地 webpack 副本不是特别方便,可以在 package.json 文件中添加 npm script 以设置一个快捷方式:

package.json

 {
   "name": "webpack-demo",
   "version": "1.0.0",
   "description": "",
   "private": true,
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "webpack"
   },
   "keywords": [],
   "author": "",
   "license": "ISC",
   "devDependencies": {
     "webpack": "^5.4.0",
     "webpack-cli": "^4.2.0"
   },
   "dependencies": {
     "lodash": "^4.17.20"
   }
 }

现在可以使用 npm run build 命令替代之前使用的 npx 命令。注意,使用 npm scripts 便可以像使用 npx 那样通过模块名引用本地安装的 npm 包。这是大多数基于 npm 的项目遵循的标准,因为它允许所有贡献者使用同一组通用脚本。

运行以下命令看看脚本别名是否正常运行:

$ npm run build

...

[webpack-cli] Compilation finished
asset main.js 69.3 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 1940 ms

总结

现在已经有了一个基础构建配置,可以移至下一指南——资源管理,以了解如何通过 webpack 管理诸如图像、图标等资源。此刻你的项目目录看起来应该如下:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
  |- main.js
  |- index.html
|- /src
  |- index.js
|- /node_modules

如果想要了解 webpack 设计思想,可以查看 基本概念配置 页面。此外也能够在 API 章节深入了解 webpack 提供的各种接口。

管理资源

如果是从一开始就沿用指南的示例,现在应该有一个可以显示“Hello webpack”的小项目。接下来尝试混合一些图像之类的其他资源,看看 webpack 如何处理。

在 webpack 出现之前,前端开发人员会使用 gruntgulp 等工具处理资源,并将它们从 /src 文件夹移动到 /dist/build 目录中。JavaScript 模块也遵循同样的方式。但是,像 webpack 这样的工具将 动态打包 所有依赖并创建所谓的 依赖图。这是极好的创举,因为现在每个模块都可以 明确表述它自身的依赖,以避免打包未使用的模块。

webpack 最出色的功能之一就是除了引入 JavaScript,还可以通过 loader 或内置的 资源模块 引入任何其他类型的文件。换言之,以上列出的那些 JavaScript 的优点(例如显式依赖),同样可以用来构建 web 站点或 web 应用程序中的所有非 JavaScript 内容。让我们从 CSS 开始起步,或许你可能已经熟悉了下面这些设置。

设置

在开始前对项目做一个小的修改:

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
-    <title>起步</title>
+    <title>管理资源</title>
   </head>
   <body>
-    <script src="main.js"></script>
+    <script src="bundle.js"></script>
   </body>
 </html>

webpack.config.js

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
-    filename: 'main.js',
+    filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

加载 CSS

要想在 JavaScript 模块中导入 CSS 文件,需要安装 style-loadercss-loader,并在 module 配置 中添加这些 loader:

npm install --save-dev style-loader css-loader

webpack.config.js

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  module: {
+    rules: [
+      {
+        test: /\.css$/i,
+        use: ['style-loader', 'css-loader'],
+      },
+    ],
+  },
 };

module loader 可以链式调用。链中的每个 loader 都将对资源进行转换,不过链会逆序执行。第一个 loader 将其结果(被转换后的资源)传递给下一个 loader,依此类推。最后,webpack 期望链中的最后的 loader 返回 JavaScript。

请确保 loader 的先后顺序:'style-loader' 在前,而 'css-loader' 在后。如果不遵守此约定,webpack 可能会抛出错误。

这些配置可以帮助在依赖于此样式的 JavaScript 文件中 import './style.css'。现在,在此模块执行过程中,含有 CSS 字符串的 <style> 标签,将被插入到 HTML 文件的 <head> 中。

让我们来试试!现在在项目中添加一个新的 style.css 文件,并将其导入到 index.js 中:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
+   |- style.css
    |- index.js
  |- /node_modules

src/style.css

.hello {
  color: red;
}

src/index.js

 import _ from 'lodash';
+import './style.css';

 function component() {
   const element = document.createElement('div');

   // lodash 现在使用 import 引入。
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+  element.classList.add('hello');

   return element;
 }

 document.body.appendChild(component());

然后运行构建命令:

$ npm run build

...
[webpack-cli] Compilation finished
asset bundle.js 72.6 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
orphan modules 326 bytes [orphan] 1 module
cacheable modules 539 KiB
  modules by path ./node_modules/ 538 KiB
    ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
    ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js 6.67 KiB [built] [code generated]
    ./node_modules/css-loader/dist/runtime/api.js 1.57 KiB [built] [code generated]
  modules by path ./src/ 965 bytes
    ./src/index.js + 1 modules 639 bytes [built] [code generated]
    ./node_modules/css-loader/dist/cjs.js!./src/style.css 326 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 2231 ms

再次在浏览器中打开 dist/index.html,应该看到 Hello webpack 现在的样式是红色。请检查页面(不要查看页面源代码,它不会显示结果,因为 <style> 标签是由 JavaScript 动态创建的)并查看页面的 head 标签以观察 webpack 做了什么。可以发现,head 标签包含了原本不存在的 style 块元素,也就是在 index.js 中导入的 CSS 文件中的样式。

注意,在多数情况下也可以 压缩 CSS 以便在生产环境中节省加载时间。最重要的是,现有的 loader 可以支持任何可以想到的 CSS 样式 —— postcsssassless 等。

加载图像

假如现在正在下载 CSS,但是像背景图片和图标这样的图像应该如何处理呢?webpack5 可以使用内置的 资源模块 轻松地将这些内容混入系统中:

webpack.config.js

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: /\.css$/i,
         use: ['style-loader', 'css-loader'],
       },
+      {
+        test: /\.(png|svg|jpg|jpeg|gif)$/i,
+        type: 'asset/resource',
+      },
     ],
   },
 };

现在 import MyImage from './my-image.png' 将会处理图像,将其添加到 output 目录,并且 MyImage 变量将包含该图像在处理后的最终的 url。如前所示,在使用 css-loader 时,处理 CSS 中的 url('./my-image.png') 也会发生类似过程。loader 会识别这是一个本地文件,并将 './my-image.png' 路径替换为 output 目录中图像的最终路径。而 html-loader 也以相同方式处理 <img src="./my-image.png" />

试试向项目中添加一个图像,并观察它是如何工作的:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
+   |- icon.png
    |- style.css
    |- index.js
  |- /node_modules

src/index.js

 import _ from 'lodash';
 import './style.css';
+import Icon from './icon.png';

 function component() {
   const element = document.createElement('div');

   // lodash 现在使用 import 引入。
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

+  // 将图像添加到已经存在的 div 中。
+  const myIcon = new Image();
+  myIcon.src = Icon;
+
+  element.appendChild(myIcon);
+
   return element;
 }

 document.body.appendChild(component());

src/style.css

 .hello {
   color: red;
+  background: url('./icon.png');
 }

重新构建并再次打开 index.html 文件:

$ npm run build

...
[webpack-cli] Compilation finished
assets by status 9.88 KiB [cached] 1 asset
asset bundle.js 73.4 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 1.82 KiB 6 modules
orphan modules 326 bytes [orphan] 1 module
cacheable modules 540 KiB (javascript) 9.88 KiB (asset)
  modules by path ./node_modules/ 539 KiB
    modules by path ./node_modules/css-loader/dist/runtime/*.js 2.38 KiB
      ./node_modules/css-loader/dist/runtime/api.js 1.57 KiB [built] [code generated]
      ./node_modules/css-loader/dist/runtime/getUrl.js 830 bytes [built] [code generated]
    ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
    ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js 6.67 KiB [built] [code generated]
  modules by path ./src/ 1.45 KiB (javascript) 9.88 KiB (asset)
    ./src/index.js + 1 modules 794 bytes [built] [code generated]
    ./src/icon.png 42 bytes (javascript) 9.88 KiB (asset) [built] [code generated]
    ./node_modules/css-loader/dist/cjs.js!./src/style.css 648 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 1972 ms

如果一切顺利,现在应该看到图标成为了重复的背景图,并且 Hello webpack 文本旁边出现了 img 元素。检查此元素将看到实际的文件名已更改为像 29822eaa871e8eadeaa4.png 一样的名称。这意味着 webpack 在 src 文件夹中找到了我们的文件,并对其进行了处理!

加载字体

使用资源模块可以接收并加载任何文件,然后将其输出到构建目录。换言之,我们可以将它们用于任何类型的文件,也包括字体文件。更新 webpack.config.js 以处理字体文件:

webpack.config.js

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: /\.css$/i,
         use: ['style-loader', 'css-loader'],
       },
       {
         test: /\.(png|svg|jpg|jpeg|gif)$/i,
         type: 'asset/resource',
       },
+      {
+        test: /\.(woff|woff2|eot|ttf|otf)$/i,
+        type: 'asset/resource',
+      },
     ],
   },
 };

在项目中添加一些字体文件:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
+   |- my-font.woff
+   |- my-font.woff2
    |- icon.png
    |- style.css
    |- index.js
  |- /node_modules

配置好 loader 并将字体文件放在合适的位置后,可以通过 @font-face 声明将其混合。本地的 url(...) 指令会被 webpack 获取并处理,就像处理图片一样:

src/style.css

+@font-face {
+  font-family: 'MyFont';
+  src: url('./my-font.woff2') format('woff2'),
+    url('./my-font.woff') format('woff');
+  font-weight: 600;
+  font-style: normal;
+}
+
 .hello {
   color: red;
+  font-family: 'MyFont';
   background: url('./icon.png');
 }

现在重新构建试试,看看 webpack 是否处理了字体:

$ npm run build

...
[webpack-cli] Compilation finished
assets by status 9.88 KiB [cached] 1 asset
assets by info 33.2 KiB [immutable]
  asset 55055dbfc7c6a83f60ba.woff 18.8 KiB [emitted] [immutable] [from: src/my-font.woff] (auxiliary name: main)
  asset 8f717b802eaab4d7fb94.woff2 14.5 KiB [emitted] [immutable] [from: src/my-font.woff2] (auxiliary name: main)
asset bundle.js 73.7 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 1.82 KiB 6 modules
orphan modules 326 bytes [orphan] 1 module
cacheable modules 541 KiB (javascript) 43.1 KiB (asset)
  javascript modules 541 KiB
    modules by path ./node_modules/ 539 KiB
      modules by path ./node_modules/css-loader/dist/runtime/*.js 2.38 KiB 2 modules
      ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
      ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js 6.67 KiB [built] [code generated]
    modules by path ./src/ 1.98 KiB
      ./src/index.js + 1 modules 794 bytes [built] [code generated]
      ./node_modules/css-loader/dist/cjs.js!./src/style.css 1.21 KiB [built] [code generated]
  asset modules 126 bytes (javascript) 43.1 KiB (asset)
    ./src/icon.png 42 bytes (javascript) 9.88 KiB (asset) [built] [code generated]
    ./src/my-font.woff2 42 bytes (javascript) 14.5 KiB (asset) [built] [code generated]
    ./src/my-font.woff 42 bytes (javascript) 18.8 KiB (asset) [built] [code generated]
webpack 5.4.0 compiled successfully in 2142 ms

重新打开 dist/index.html 观察 Hello webpack 文本是否换上了新的字体。如果一切顺利,应该能看到已经发生了变化。

加载数据

此外,可以加载的有用资源还有数据,如 JSON 文件、CSV、TSV 和 XML。与 NodeJS 类似,对 JSON 的支持实际上也是内置的,即 import Data from './data.json' 默认将正常运行。可以使用 csv-loaderxml-loader 导入 CSV、TSV 与 XML。接下来试试加载这三类文件:

npm install --save-dev csv-loader xml-loader

webpack.config.js

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: /\.css$/i,
         use: ['style-loader', 'css-loader'],
       },
       {
         test: /\.(png|svg|jpg|jpeg|gif)$/i,
         type: 'asset/resource',
       },
       {
         test: /\.(woff|woff2|eot|ttf|otf)$/i,
         type: 'asset/resource',
       },
+      {
+        test: /\.(csv|tsv)$/i,
+        use: ['csv-loader'],
+      },
+      {
+        test: /\.xml$/i,
+        use: ['xml-loader'],
+      },
     ],
   },
 };

在项目中添加一些数据文件:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
+   |- data.xml
+   |- data.csv
    |- my-font.woff
    |- my-font.woff2
    |- icon.png
    |- style.css
    |- index.js
  |- /node_modules

src/data.xml

<?xml version="1.0" encoding="UTF-8"?>
<note>
  <to>Mary</to>
  <from>John</from>
  <heading>Reminder</heading>
  <body>Call Cindy on Tuesday</body>
</note>

src/data.csv

to,from,heading,body
Mary,John,Reminder,Call Cindy on Tuesday
Zoe,Bill,Reminder,Buy orange juice
Autumn,Lindsey,Letter,I miss you

现在可以导入这四种类型的数据(JSON, CSV, TSV, XML)中的任何一种了,所导入的 Data 变量,将包含可直接使用的已解析的 JSON:

src/index.js

 import _ from 'lodash';
 import './style.css';
 import Icon from './icon.png';
+import Data from './data.xml';
+import Notes from './data.csv';

 function component() {
   const element = document.createElement('div');

   // lodash 现在使用 import 引入
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

   // 将图像添加到已经存在的 div 中。
   const myIcon = new Image();
   myIcon.src = Icon;

   element.appendChild(myIcon);

+  console.log(Data);
+  console.log(Notes);
+
   return element;
 }

 document.body.appendChild(component());

重新执行 npm run build 命令,然后打开 dist/index.html。在开发者工具中的控制台应该能够看到成功打印了导入的数据!

// 没有警告
import data from './data.json';

// 显示警告,规范不允许这样做。
import { foo } from './data.json';

自定义 JSON 模块解析器

通过使用 自定义解析器 替代特定的 webpack loader,可以将任何 tomlyamljson5 文件作为 JSON 模块导入。

假设 src 文件夹下有 data.tomldata.yaml 以及 data.json5 文件:

src/data.toml

title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
organization = "GitHub"
bio = "GitHub Cofounder & CEO\nLikes tater tots and beer."
dob = 1979-05-27T07:32:00Z

src/data.yaml

title: YAML Example
owner:
  name: Tom Preston-Werner
  organization: GitHub
  bio: |-
    GitHub Cofounder & CEO
    Likes tater tots and beer.
  dob: 1979-05-27T07:32:00.000Z

src/data.json5

{
  // comment
  title: 'JSON5 Example',
  owner: {
    name: 'Tom Preston-Werner',
    organization: 'GitHub',
    bio: 'GitHub Cofounder & CEO\n\
Likes tater tots and beer.',
    dob: '1979-05-27T07:32:00.000Z',
  },
}

首先安装 tomlyamljsjson5 对应的包:

npm install toml yamljs json5 --save-dev

并在 webpack 中配置它们:

webpack.config.js

 const path = require('path');
+const toml = require('toml');
+const yaml = require('yamljs');
+const json5 = require('json5');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: /\.css$/i,
         use: ['style-loader', 'css-loader'],
       },
       {
         test: /\.(png|svg|jpg|jpeg|gif)$/i,
         type: 'asset/resource',
       },
       {
         test: /\.(woff|woff2|eot|ttf|otf)$/i,
         type: 'asset/resource',
       },
       {
         test: /\.(csv|tsv)$/i,
         use: ['csv-loader'],
       },
       {
         test: /\.xml$/i,
         use: ['xml-loader'],
       },
+      {
+        test: /\.toml$/i,
+        type: 'json',
+        parser: {
+          parse: toml.parse,
+        },
+      },
+      {
+        test: /\.yaml$/i,
+        type: 'json',
+        parser: {
+          parse: yaml.parse,
+        },
+      },
+      {
+        test: /\.json5$/i,
+        type: 'json',
+        parser: {
+          parse: json5.parse,
+        },
+      },
     ],
   },
 };

src/index.js

 import _ from 'lodash';
 import './style.css';
 import Icon from './icon.png';
 import Data from './data.xml';
 import Notes from './data.csv';
+import toml from './data.toml';
+import yaml from './data.yaml';
+import json from './data.json5';
+
+console.log(toml.title); // 输出 `TOML Example`
+console.log(toml.owner.name); // 输出 `Tom Preston-Werner`
+
+console.log(yaml.title); // 输出 `YAML Example`
+console.log(yaml.owner.name); // 输出 `Tom Preston-Werner`
+
+console.log(json.title); // 输出 `JSON5 Example`
+console.log(json.owner.name); // 输出 `Tom Preston-Werner`

 function component() {
   const element = document.createElement('div');

   // lodash 现在使用 import 引入。
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

   // 将图像添加到已经存在的 div 中。
   const myIcon = new Image();
   myIcon.src = Icon;

   element.appendChild(myIcon);

   console.log(Data);
   console.log(Notes);

   return element;
 }

 document.body.appendChild(component());

重新执行 npm run build 命令,然后打开 dist/index.html 就应该能够看到导入的数据会被打印到控制台上!

全局资源

上述所有内容中最出色之处在于,以这种方式加载资源,可以以更直观的方式将模块和资源组合在一起。无需依赖于含有全部资源的 /assets 目录,而是将资源与代码组合在一起使用。例如,类似这样的结构会非常有用:

- |- /assets
+ |– /components
+ |  |– /my-component
+ |  |  |– index.jsx
+ |  |  |– index.css
+ |  |  |– icon.svg
+ |  |  |– img.png

这种配置方式会使代码更具备可移植性,因为现有的集中放置的方式会让紧密耦合所有资源。假如想在另一个项目中使用 /my-component,只需将其复制或移动到 /components 目录下。只要你已经安装过全部 外部依赖 ,并且 已经在配置中定义过相同的 loader ,那么项目应该能够良好运行。

但是,假如只能被局限在旧有开发方式,或者有一些在多个组件(视图、模板、模块等)之间共享的资源,仍然可以将这些资源存储在一个基本目录中,甚至配合 alias 可以更方便使用 import 导入。

回退处理

在下篇指南中无需使用本指南中所有用到的资源,因此需要进行一些清理工作,以便为下篇指南 管理输出 做好准备:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
-   |- data.csv
-   |- data.json5
-   |- data.toml
-   |- data.xml
-   |- data.yaml
-   |- icon.png
-   |- my-font.woff
-   |- my-font.woff2
-   |- style.css
    |- index.js
  |- /node_modules

webpack.config.js

 const path = require('path');
-const toml = require('toml');
-const yaml = require('yamljs');
-const json5 = require('json5');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
-  module: {
-    rules: [
-      {
-        test: /\.css$/i,
-        use: ['style-loader', 'css-loader'],
-      },
-      {
-        test: /\.(png|svg|jpg|jpeg|gif)$/i,
-        type: 'asset/resource',
-      },
-      {
-        test: /\.(woff|woff2|eot|ttf|otf)$/i,
-        type: 'asset/resource',
-      },
-      {
-        test: /\.(csv|tsv)$/i,
-        use: ['csv-loader'],
-      },
-      {
-        test: /\.xml$/i,
-        use: ['xml-loader'],
-      },
-      {
-        test: /\.toml$/i,
-        type: 'json',
-        parser: {
-          parse: toml.parse,
-        },
-      },
-      {
-        test: /\.yaml$/i,
-        type: 'json',
-        parser: {
-          parse: yaml.parse,
-        },
-      },
-      {
-        test: /\.json5$/i,
-        type: 'json',
-        parser: {
-          parse: json5.parse,
-        },
-      },
-    ],
-  },
 };

src/index.js

 import _ from 'lodash';
-import './style.css';
-import Icon from './icon.png';
-import Data from './data.xml';
-import Notes from './data.csv';
-import toml from './data.toml';
-import yaml from './data.yaml';
-import json from './data.json5';
-
-console.log(toml.title); // 输出 `TOML Example`
-console.log(toml.owner.name); // 输出 `Tom Preston-Werner`
-
-console.log(yaml.title); // 输出 `YAML Example`
-console.log(yaml.owner.name); // 输出 `Tom Preston-Werner`
-
-console.log(json.title); // 输出 `JSON5 Example`
-console.log(json.owner.name); // 输出 `Tom Preston-Werner`

 function component() {
   const element = document.createElement('div');

-  // lodash 现在使用 import 引入。
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-  element.classList.add('hello');
-
-  // 将图像添加到已经存在的 div 中。
-  const myIcon = new Image();
-  myIcon.src = Icon;
-
-  element.appendChild(myIcon);
-
-  console.log(Data);
-  console.log(Notes);

   return element;
 }

 document.body.appendChild(component());

并移除之前添加的依赖:

npm uninstall css-loader csv-loader json5 style-loader toml xml-loader yamljs

下篇指南

我们将继续移步到 管理输出

延伸阅读

管理输出

目前为止,都是在 index.html 文件中手动引入所有资源,然而随着应用程序的不断增长,一旦开始 使用哈希值进行文件命名 并输出 多个 bundle,手动管理 index.html 文件将变得困难。使用一些插件可以更容易管理这个过程。

预先准备

首先调整一下项目结构:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
  |- /src
    |- index.js
+   |- print.js
  |- /node_modules

src/print.js 文件中添加一些逻辑:

src/print.js

export default function printMe() {
  console.log('I get called from print.js!');
}

并在 src/index.js 文件中使用这个函数:

src/index.js

 import _ from 'lodash';
+import printMe from './print.js';

 function component() {
   const element = document.createElement('div');
+  const btn = document.createElement('button');

   element.innerHTML = _.join(['Hello', 'webpack'], ' ');

+  btn.innerHTML = 'Click me and check the console!';
+  btn.onclick = printMe;
+
+  element.appendChild(btn);
+
   return element;
 }

 document.body.appendChild(component());

更新 dist/index.html 文件,为 webpack 分离入口文件做准备:

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
-    <title>管理资源</title>
+    <title>管理输出</title>
+    <script src="./print.bundle.js"></script>
   </head>
   <body>
-    <script src="bundle.js"></script>
+    <script src="./index.bundle.js"></script>
   </body>
 </html>

接下来调整配置:在 entry 添加 src/print.js 作为新的入口起点(命名为 print),然后修改 output 使得能够根据入口起点定义的名称动态生成 bundle 名称:

webpack.config.js

 const path = require('path');

 module.exports = {
-  entry: './src/index.js',
+  entry: {
+    index: './src/index.js',
+    print: './src/print.js',
+  },
   output: {
-    filename: 'bundle.js',
+    filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

执行 npm run build 将看到输出如下:

...
[webpack-cli] Compilation finished
asset index.bundle.js 69.5 KiB [emitted] [minimized] (name: index) 1 related asset
asset print.bundle.js 316 bytes [emitted] [minimized] (name: print)
runtime modules 1.36 KiB 7 modules
cacheable modules 530 KiB
  ./src/index.js 406 bytes [built] [code generated]
  ./src/print.js 83 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 1996 ms

可以看到,webpack 将会生成 print.bundle.jsindex.bundle.js 文件,即与在 index.html 文件中指定的文件名称相对应。试试在浏览器中打开 index.html,看看点击按钮时会发生什么。

如果更改入口起点的名称,或者添加一个新的入口起点,那么会在构建时重新命名生成的 bundle,而 index.html 仍然在引用旧的名称。使用 HtmlWebpackPlugin 插件可以解决这个问题。

设置 HtmlWebpackPlugin

安装插件并且调整 webpack.config.js 文件:

npm install --save-dev html-webpack-plugin

webpack.config.js

 const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
+  plugins: [
+    new HtmlWebpackPlugin({
+      title: '管理输出',
+    }),
+  ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

需要注意,在执行构建之前,虽然在 dist/ 文件夹已经有了自定义的 index.html 文件,但是 HtmlWebpackPlugin 插件仍然会默认生成 index.html 文件,即使用新生成的 index.html 文件替换原有文件。观察执行 npm run build 后会发生什么:

...
[webpack-cli] Compilation finished
asset index.bundle.js 69.5 KiB [compared for emit] [minimized] (name: index) 1 related asset
asset print.bundle.js 316 bytes [compared for emit] [minimized] (name: print)
asset index.html 253 bytes [emitted]
runtime modules 1.36 KiB 7 modules
cacheable modules 530 KiB
  ./src/index.js 406 bytes [built] [code generated]
  ./src/print.js 83 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 2189 ms

代码编辑器中打开 index.html 会发现 HtmlWebpackPlugin 创建了一个全新的文件,而所有的 bundle 都已自动添加到其中。

参阅 HtmlWebpackPlugin 仓库源码以了解 HtmlWebpackPlugin 插件提供的全部功能。

清理 /dist 文件夹

可能已经注意到,由于遗留了之前的指南的代码示例,/dist 文件夹已经变得相当杂乱。webpack 生成文件并将其默认放置在 /dist 文件夹中,但是它不会追踪哪些文件是实际在项目中需要的。

通常比较推荐的做法是在每次构建前清理 /dist 文件夹,那么构建后就只会存在将要用到的文件。可以使用 output.clean 配置选项实现这个需求。

webpack.config.js

 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
   plugins: [
     new HtmlWebpackPlugin({
       title: 'Output Management',
     }),
   ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
+    clean: true,
   },
 };

再次执行 npm run build 后检查 /dist 文件夹。如果一切顺利,现在只会看到构建后生成的文件,而没有旧文件!

manifest

你可能会对 webpack 和 webpack 插件似乎“知道”应该生成哪些文件感兴趣。webpack 通过 manifest 追踪所有模块到输出的 bundle 之间的映射。了解 manifest 将会帮助了解如何以其他方式控制 webpack 输出

WebpackManifestPlugin 插件可以将 manifest 数据提取为 json 文件。

参阅 manifest 概念页面以深入了解如何在项目中使用此插件,同时 缓存 指南介绍了其与长效缓存之间的关系。

总结

现在已经了解如何向 HTML 动态添加 bundle,接下来继续深入 开发环境 指南。如果想要深入更多相关高级话题,那么推荐前往 代码分离 指南。

开发环境

如果已经阅读过之前的指南,那么应该对 webpack 的基础知识有了扎实的理解。在我们继续之前,先来看看如何设置开发环境,使我们的开发体验变得更轻松一些。

首先将 mode 设置为 'development',并将 title 修改为 'Development'

webpack.config.js

 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
+  mode: 'development',
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
   plugins: [
     new HtmlWebpackPlugin({
-      title: 'Output Management',
+      title: 'Development',
     }),
   ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
     clean: true,
   },
 };

使用 source map

当 webpack 打包源代码时,可能会很难追踪到错误和警告在源代码中的原始位置。例如,如果将三个源文件(a.jsb.jsc.js)打包到一个 bundle(bundle.js)中,而其中一个源文件包含错误,那么堆栈跟踪就会直接指向到 bundle.js,却无法准确知道错误来自于哪个源文件,所以这种提示通常无法提供太多帮助。

为了更容易地追踪错误与警告在源代码中的原始位置,JavaScript 提供了 source map 功能,可以帮助将编译后的代码映射回原始源代码。source map 会直接告诉开发者错误来源于哪一个源代码。

source map 有许多 可用选项,请务必仔细阅读它们,以便根据需要进行配置。

本指南将使用有助于解释说明示例意图的 inline-source-map 选项(不要在生产环境中使用它):

webpack.config.js

 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
   mode: 'development',
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
+  devtool: 'inline-source-map',
   plugins: [
     new HtmlWebpackPlugin({
       title: 'Development',
     }),
   ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
     clean: true,
   },
 };

现在让我们来试试:在 print.js 文件中故意编写有问题的代码:

src/print.js

 export default function printMe() {
-  console.log('我被 print.js 调用了!');
+  cosnole.log('我被 print.js 调用了!');
 }

运行 npm run build,编译如下:

...
[webpack-cli] Compilation finished
asset index.bundle.js 1.38 MiB [emitted] (name: index)
asset print.bundle.js 6.25 KiB [emitted] (name: print)
asset index.html 272 bytes [emitted]
runtime modules 1.9 KiB 9 modules
cacheable modules 530 KiB
  ./src/index.js 406 bytes [built] [code generated]
  ./src/print.js 83 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 706 ms

现在,在浏览器中打开生成的 index.html 文件,点击按钮后控制台将会报错。错误信息应该如下:

Uncaught ReferenceError: cosnole is not defined
   at HTMLButtonElement.printMe (print.js:2)

可以看到,此错误包含有发生错误的文件(print.js)和行号(2)的引用。这将帮助确切知道所要解决问题的位置。

选择一个开发工具

每次编译代码都需要手动运行 npm run build 会显得很麻烦。

webpack 提供了几种可选方式帮助在代码发生变化后自动编译代码:

  1. webpack 的 观察模式
  2. webpack-dev-server
  3. webpack-dev-middleware

在多数场景中可能会使用 webpack-dev-server,但是不妨探讨一下以上的所有选项。

使用观察模式

可以指示 webpack “观察”依赖图中所有文件的更改。如果其中一个文件被更新,代码将被自动重新编译,所以不必再去手动运行整个构建。

像下面这样添加一个用于启动 webpack 观察模式的 npm scripts:

package.json

 {
   "name": "webpack-demo",
   "version": "1.0.0",
   "description": "",
   "private": true,
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
+    "watch": "webpack --watch",
     "build": "webpack"
   },
   "keywords": [],
   "author": "",
   "license": "ISC",
   "devDependencies": {
     "html-webpack-plugin": "^4.5.0",
     "webpack": "^5.4.0",
     "webpack-cli": "^4.2.0"
   },
   "dependencies": {
     "lodash": "^4.17.20"
   }
 }

在命令行中运行 npm run watch,然后就会看到 webpack 是如何编译代码的。 编译完成后会发现它并没有退出命令行,这是因为该脚本当前还在观察你的文件。

现在,在 webpack 仍在观察文件的同时,移除之前故意加入的错误:

src/print.js

 export default function printMe() {
-  cosnole.log('我被 print.js 调用了!');
+  console.log('我被 print.js 调用了!');
 }

现在,保存文件并检查窗口,应该可以看到 webpack 已经自动地重新编译修改后的模块!

观察模式唯一的缺点是需要手动刷新浏览器才能看到修改后的实际效果。实现 webpack-dev-server 将能够自动刷新浏览器!

使用 webpack-dev-server

webpack-dev-server 提供了一个能够实时重新加载的基本的 web server。安装依赖如下:

npm install --save-dev webpack-dev-server

接下来修改配置文件,告诉 dev server 应从什么位置开始查找文件:

webpack.config.js

 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
   mode: 'development',
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
   devtool: 'inline-source-map',
+  devServer: {
+    static: './dist',
+  },
   plugins: [
     new HtmlWebpackPlugin({
       title: 'Development',
     }),
   ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
     clean: true,
   },
+  optimization: {
+    runtimeChunk: 'single',
+  },
 };

以上配置告知 webpack-dev-serverdist 目录下的文件作为可访问资源部署在 localhost:8080。。

添加一个可以直接运行 dev server 的 script:

package.json

 {
   "name": "webpack-demo",
   "version": "1.0.0",
   "description": "",
   "private": true,
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "watch": "webpack --watch",
+    "start": "webpack serve --open",
     "build": "webpack"
   },
   "keywords": [],
   "author": "",
   "license": "ISC",
   "devDependencies": {
     "html-webpack-plugin": "^4.5.0",
     "webpack": "^5.4.0",
     "webpack-cli": "^4.2.0",
     "webpack-dev-server": "^3.11.0"
   },
   "dependencies": {
     "lodash": "^4.17.20"
   }
 }

现在,在命令行中运行 npm start,会看到浏览器自动加载页面。更改任何源文件并保存它们,web server 将在编译代码后自动重新加载。试试看!

webpack-dev-server 具有许多可配置的选项。参阅 配置文档 以了解更多配置选项。

使用 webpack-dev-middleware

webpack-dev-middleware 是一个包装器,它可以把 webpack 处理过的文件发送到 server。这是 webpack-dev-server 内部的原理,但是它也可以作为一个单独的包使用,以便根据需求进行更多自定义设置。下面是一个 webpack-dev-middleware 配合 express server 的示例。

首先,安装 express 和  webpack-dev-middleware

npm install --save-dev express webpack-dev-middleware

现在调整 webpack 配置文件,以确保能够正确启用中间件:

webpack.config.js

 const path = require('path');
 const HtmlWebpackPlugin = require('html-webpack-plugin');

 module.exports = {
   mode: 'development',
   entry: {
     index: './src/index.js',
     print: './src/print.js',
   },
   devtool: 'inline-source-map',
   devServer: {
     static: './dist',
   },
   plugins: [
     new HtmlWebpackPlugin({
       title: 'Development',
     }),
   ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
     clean: true,
+    publicPath: '/',
   },
 };

在 server 脚本使用 publicPath,以确保文件资源能够作为可访问资源正确部署在 http://localhost:3000 下,稍后我们会指定端口号。接下来是设置自定义 express server:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
+ |- server.js
  |- /dist
  |- /src
    |- index.js
    |- print.js
  |- /node_modules

server.js

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);

// 告知 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置。
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

// 将文件 serve 到 port 3000。
app.listen(3000, function () {
  console.log('Example app listening on port 3000!\n');
});

现在添加 npm script 以便更方便地运行 server:

package.json

 {
   "name": "webpack-demo",
   "version": "1.0.0",
   "description": "",
   "private": true,
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "watch": "webpack --watch",
     "start": "webpack serve --open",
+    "server": "node server.js",
     "build": "webpack"
   },
   "keywords": [],
   "author": "",
   "license": "ISC",
   "devDependencies": {
     "express": "^4.17.1",
     "html-webpack-plugin": "^4.5.0",
     "webpack": "^5.4.0",
     "webpack-cli": "^4.2.0",
     "webpack-dev-middleware": "^4.0.2",
     "webpack-dev-server": "^3.11.0"
   },
   "dependencies": {
     "lodash": "^4.17.20"
   }
 }

在终端执行 npm run server,将会有类似如下信息输出:

Example app listening on port 3000!
...
<i> [webpack-dev-middleware] asset index.bundle.js 1.38 MiB [emitted] (name: index)
<i> asset print.bundle.js 6.25 KiB [emitted] (name: print)
<i> asset index.html 274 bytes [emitted]
<i> runtime modules 1.9 KiB 9 modules
<i> cacheable modules 530 KiB
<i>   ./src/index.js 406 bytes [built] [code generated]
<i>   ./src/print.js 83 bytes [built] [code generated]
<i>   ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
<i> webpack 5.4.0 compiled successfully in 709 ms
<i> [webpack-dev-middleware] Compiled successfully.
<i> [webpack-dev-middleware] Compiling...
<i> [webpack-dev-middleware] assets by status 1.38 MiB [cached] 2 assets
<i> cached modules 530 KiB (javascript) 1.9 KiB (runtime) [cached] 12 modules
<i> webpack 5.4.0 compiled successfully in 19 ms
<i> [webpack-dev-middleware] Compiled successfully.

打开浏览器,访问 http://localhost:3000,应该看到 webpack 应用程序已经运行!

调整文本编辑器

使用自动编译代码时,可能会在保存文件时遇到一些问题。某些编辑器具有安全写入功能,会影响重新编译。

查看以下列表以在常见编辑器中禁用此功能:

  • Sublime Text 3:在用户偏好中添加 atomic_save: 'false'
  • JetBrains IDE(如 WebStorm):在 Preferences > Appearance & Behavior > System Settings 中取消选中使用安全写入。
  • Vim:在设置中增加 :set backupcopy=yes

总结

现在已经学会了如何自动编译代码,并运行一个简单的开发环境 server。查看下一个指南学习 代码分离 吧!

代码分离

代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后便能按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle、控制资源加载优先级,如果使用合理,会极大减小加载时间。

常用的代码分离方法有三种:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 入口依赖 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用分离代码。

入口起点

这是迄今为止最简单直观的实现代码分离的方式。不过,这种方式手动配置较多,并有一些隐患。不过,我们将会介绍如何解决这些隐患。先来看看如何从 main bundle 中分离另一个模块:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- another-module.js
|- /node_modules

another-module.js

import _ from 'lodash';

console.log(_.join(['Another', 'module', 'loaded!'], ' '));

webpack.config.js

 const path = require('path');

 module.exports = {
-  entry: './src/index.js',
+  mode: 'development',
+  entry: {
+    index: './src/index.js',
+    another: './src/another-module.js',
+  },
   output: {
-    filename: 'main.js',
+    filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

构建后结果如下:

...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 245 ms

正如前面所提及,这种方式存在一些隐患:

  • 如果入口 chunk 之间包含一些重复的模块,那么这些重复模块会被引入到各个 bundle 中。
  • 这种方法不够灵活,并且不能动态地拆分应用程序逻辑中的核心代码。

以上两点中,第一点所对应的问题已经在我们上面的实例中体现出来了。除了 ./src/another-module.js,我们也曾在 ./src/index.js 中引入过 lodash,这就导致了重复引用。下一章节会介绍如何移除重复的模块。

防止重复

入口依赖

在配置文件中配置 dependOn 选项,以在多个 chunk 之间共享模块:

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
-    index: './src/index.js',
-    another: './src/another-module.js',
+    index: {
+      import: './src/index.js',
+      dependOn: 'shared',
+    },
+    another: {
+      import: './src/another-module.js',
+      dependOn: 'shared',
+    },
+    shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

如果想要在一个 HTML 页面上使用多个入口起点,还需设置 optimization.runtimeChunk: 'single',否则会遇到 此处 所述的麻烦。

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
     index: {
       import: './src/index.js',
       dependOn: 'shared',
     },
     another: {
       import: './src/another-module.js',
       dependOn: 'shared',
     },
     shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  optimization: {
+    runtimeChunk: 'single',
+  },
 };

构建结果如下:

...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./src/index.js 257 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 249 ms

可以看到,除了 shared.bundle.jsindex.bundle.jsanother.bundle.js 之外,还生成了一个 runtime.bundle.js 文件。

尽管 webpack 允许每个页面使用多个入口起点,但在可能的情况下,应该避免使用多个入口起点,而使用具有多个导入的单个入口起点:entry: { page: ['./analytics', './app'] }。这样可以获得更好的优化效果,并在使用异步脚本标签时保证执行顺序一致。

SplitChunksPlugin

SplitChunksPlugin 插件可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件去除之前示例中重复的 lodash 模块:

webpack.config.js

  const path = require('path');

  module.exports = {
    mode: 'development',
    entry: {
      index: './src/index.js',
      another: './src/another-module.js',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
+   optimization: {
+     splitChunks: {
+       chunks: 'all',
+     },
+   },
  };

使用 optimization.splitChunks 配置选项后构建,将会发现 index.bundle.jsanother.bundle.js 已经移除了重复的依赖模块。从插件将 lodash 分离到单独的 chunk,并且将其从 main bundle 中移除,减轻了 bundle 大小。执行 npm run build 查看效果:

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 241 ms

以下是由社区提供,对代码分离很有帮助的 plugin 和 loader:

动态导入

webpack 提供了两个类似的技术实现动态代码分离。第一种,也是推荐选择的方式,是使用符合 ECMAScript 提案import() 语法 实现动态导入。第二种则是 webpack 的遗留功能,使用 webpack 特定的 require.ensure。让我们先尝试使用第一种。

在我们开始之前,先从上述示例的配置中移除多余的 entryoptimization.splitChunks,因为接下来的演示中并不需要它们:

webpack.config.js

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
     index: './src/index.js',
-    another: './src/another-module.js',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
-  optimization: {
-    splitChunks: {
-      chunks: 'all',
-    },
-  },
 };

我们将更新我们的项目,移除现在未使用的文件:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
- |- another-module.js
|- /node_modules

现在,我们不再静态导入 lodash,而是通过动态导入来分离出一个 chunk:

src/index.js

-import _ from 'lodash';
-
-function component() {
+function getComponent() {
-  const element = document.createElement('div');

-  // lodash 现在使用 import 引入
-  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+  return import('lodash')
+    .then(({ default: _ }) => {
+      const element = document.createElement('div');
+
+      element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-  return element;
+      return element;
+    })
+    .catch((error) => 'An error occurred while loading the component');
 }

-document.body.appendChild(component());
+getComponent().then((component) => {
+  document.body.appendChild(component);
+});

需要 default 的原因是自 webpack 4 之后,在导入 CommonJS 模块时,将不再解析为 module.exports 的值,而是创建一个人工命名空间对象来表示此 CommonJS 模块。参阅 webpack 4: import() and CommonJs 以了解更多有关信息。

试试构建最新的代码,看看 lodash 是否会分离到一个单独的 bundle:

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
  ./src/index.js 434 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 268 ms

由于 import() 会返回 promise,因此它可以和 async 函数 一起使用。下面是使用 async 简化后的代码:

src/index.js

-function getComponent() {
+async function getComponent() {
+  const element = document.createElement('div');
+  const { default: _ } = await import('lodash');

-  return import('lodash')
-    .then(({ default: _ }) => {
-      const element = document.createElement('div');
+  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-      element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
-      return element;
-    })
-    .catch((error) => 'An error occurred while loading the component');
+  return element;
 }

 getComponent().then((component) => {
   document.body.appendChild(component);
 });

预获取/预加载模块

Webpack v4.6.0+ 增加了对预获取和预加载的支持。

声明 import 时使用下列内置指令可以让 webpack 输出“Resource Hint”告知浏览器:

  • 预获取(prefetch):将来某些导航下可能需要的资源
  • 预加载(preload):当前导航下可能需要资源

试想一下下面的场景:现在有一个 HomePage 组件,该组件内部渲染了一个 LoginButton 组件,点击后按钮后可以按需加载 LoginModal 组件。

LoginButton.js

//...
import(/* webpackPrefetch: true */ './path/to/LoginModal.js');

上面的代码在构建时会生成 <link rel="prefetch" href="login-modal-chunk.js"> 并追加到页面头部,指示浏览器在闲置时间预获取 login-modal-chunk.js 文件。

与预获取指令相比,预加载指令有许多不同之处:

  • 预加载 chunk 会在父 chunk 加载时以并行方式开始加载;而预获取 chunk 会在父 chunk 加载结束后开始加载。
  • 预加载 chunk 具有中等优先级,并会立即下载;而预获取 chunk 则在浏览器闲置时下载。
  • 预加载 chunk 会在父 chunk 中立即请求,用于当下时刻;而预获取 chunk 则用于未来的某个时刻。
  • 浏览器支持程度不同。

下面这个简单的预加载示例中,有一个 Component,依赖于一个较大的库,所以应该将其分离到一个独立的 chunk 中。

假想这里的图表组件 ChartComponent 组件需要依赖一个体积巨大的 ChartingLibrary 库。它会在渲染时显示一个 LoadingIndicator 组件,然后立即按需导入 ChartingLibrary

ChartComponent.js

//...
import(/* webpackPreload: true */ 'ChartingLibrary');

在页面中使用 ChartComponent 时会在请求 ChartComponent.js 的同时通过 <link rel="preload"> 请求 charting-library-chunk。假定 page-chunk 体积比 charting-library-chunk 更小,也更快地被加载完成,那么当 charting-library-chunk 加载完成后,页面会首先显示 LoadingIndicator;当 charting-library-chunk 请求完成后,LoadingIndicator 组件才会消失。这将会使得加载时间能够更短一点,因为只进行单次往返,而不是两次往返,尤其是在高延迟环境下。

有时需要自己控制预加载。例如,任何动态导入的预加载都可以通过异步脚本完成。这在流式服务器端渲染的情况下很有用。

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // 在发生错误时做一些处理
    // 例如可以在网络错误的情况下重试请求
  });

如果在 webpack 开始加载该脚本之前脚本加载失败(如果该脚本不在页面上,webpack 只是创建一个 script 标签来加载其代码),则该 catch 处理程序将不会启动,直到 chunkLoadTimeout 未通过。此行为可能是意料之外的。但这是可以解释的 —— webpack 不能抛出任何错误,因为 webpack 不知道那个脚本失败了。webpack 将在错误发生后立即将 onerror 处理脚本添加到 script 中。

可以通过添加自己的 onerror 处理脚本避免上述问题,这能够帮助在错误发生时移除该脚本

<script
  src="https://example.com/dist/dynamicComponent.js"
  async
  onerror="this.remove()"
></script>

这种情况下错误脚本将被删除,而 webpack 将创建自己的脚本,并且任何错误都将被处理而没有任何超时。

分析 bundle

一旦开始分离代码,一件很有帮助的事情是,分析输出结果来检查模块在何处结束。官方分析工具 是一个不错的开始。还有一些其他社区支持的可选项:

  • webpack-chart:webpack stats 可交互饼图。
  • webpack-visualizer:分析并可视化 bundle,检查哪些模块占用空间,哪些可能是重复使用的。
  • webpack-bundle-analyzer:一个 plugin 和 CLI 工具,它将 bundle 内容展示为一个便捷的、交互式、可缩放的树状图形式。
  • webpack bundle optimize helper:这个工具会分析 bundle,并提供可操作的改进措施,以减少 bundle 的大小。
  • bundle-stats:生成一个 bundle 报告(bundle 大小、资源、模块),并比较不同构建之间的结果。

下一步

接下来,参阅 懒加载 了解如何在真实的应用程序中使用 import(),以及参阅 缓存 了解如何有效地进行代码分离。

缓存

接下来继续使用 webpack 打包模块化应用程序。webpack 会在打包后生成可部署的 /dist 目录,并将打包后的内容放在此目录。一旦 /dist 目录中的内容部署到服务器上,客户端(通常是浏览器)就能够访问此服务器以获取站点及其资源。由于获取服务器资源是比较耗费时间的操作,因此浏览器使用了一种名为 缓存 的技术。命中缓存可以降低网络流量,使网站加载速度更快。然而,如果在部署资源的最新版本时没有更改资源的文件名,浏览器可能会认为它没有被更新,从而使用它的缓存版本。由于缓存的存在,当需要获取新的代码时,就会显得很棘手。

这篇指南的重点在于通过必要配置确保 webpack 编译生成的文件能够被客户端缓存;当文件内容变化后,客户端又能够请求到新的文件。

输出文件的文件名

更改 output.filename 中的 substitutions 以定义输出文件的名称。webpack 提供了一种称为 可替换模板字符串(substitution) 的方式,通过带括号字符串来模板化文件名。其中,[contenthash] 将根据资源内容创建唯一哈希值。当资源内容发生变化时,[contenthash] 也会发生变化。

这里使用 起步 中的示例和 管理输出 中的 plugins 插件作为项目基础,所以不必手动维护 index.html 文件:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
|- /node_modules

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
-       title: 'Output Management',
+       title: 'Caching',
      }),
    ],
    output: {
-     filename: 'bundle.js',
+     filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

调整配置后构建脚本,将会看到像下面一样的输出:

...
                       Asset       Size  Chunks                    Chunk Names
main.7e2c49a622975ebd9b7e.js     544 kB       0  [emitted]  [big]  main
                  index.html  197 bytes          [emitted]
...

可以发现 bundle 的名称是其内容通过哈希的映射。也许会认为,如果不修改原始文件直接再次运行构建,文件名将保持不变。然而事实并非如此,试试再次构建脚本:

...
                       Asset       Size  Chunks                    Chunk Names
main.205199ab45963f6a62ec.js     544 kB       0  [emitted]  [big]  main
                  index.html  197 bytes          [emitted]
...

再次执行构建后发现,尽管没有修改原始文件,bundle 的名称仍然发生了修改。这是因为 webpack 在入口 chunk 中包含了某些引导模板(boilerplate),特别是 runtime 和 manifest。

提取引导模板

正如在 代码分离 中所学到的,SplitChunksPlugin 插件可以用于将模块分离到单独的 bundle 中。webpack 还提供了一个优化功能,可以使用 optimization.runtimeChunk 选项将 runtime 代码拆分为一个单独的 chunk。将其设置为 single 以便为所有 chunk 创建一个 runtime bundle:

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
      title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
+   optimization: {
+     runtimeChunk: 'single',
+   },
  };

更改配置后再次构建,查看提取出来的 runtime bundle:

Hash: 82c9c385607b2150fab2
Version: webpack 4.12.0
Time: 3027ms
                          Asset       Size  Chunks             Chunk Names
runtime.cc17ae2a94ec771e9221.js   1.42 KiB       0  [emitted]  runtime
   main.e81de2cf758ada72f306.js   69.5 KiB       1  [emitted]  main
                     index.html  275 bytes          [emitted]
[1] (webpack)/buildin/module.js 497 bytes {1} [built]
[2] (webpack)/buildin/global.js 489 bytes {1} [built]
[3] ./src/index.js 309 bytes {1} [built]
    + 1 hidden module

由于像 lodashreact 这样的第三方库很少像本地源代码一样频繁修改,因此通常推荐将第三方库提取到单独的 vendor chunk 中。这一步将减少客户端对服务器的请求,同时保证自身代码与服务器一致。可以通过使用 SplitChunksPlugin 示例 2 中演示的 SplitChunksPlugin 插件的 cacheGroups 选项来实现。试试在 optimization.splitChunks 添加如下 cacheGroups 参数并执行构建:

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
      title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
    optimization: {
      runtimeChunk: 'single',
+     splitChunks: {
+       cacheGroups: {
+         vendor: {
+           test: /[\\/]node_modules[\\/]/,
+           name: 'vendors',
+           chunks: 'all',
+         },
+       },
+     },
    },
  };

再次构建,然后查看新的 vendor bundle:

...
                          Asset       Size  Chunks             Chunk Names
runtime.cc17ae2a94ec771e9221.js   1.42 KiB       0  [emitted]  runtime
vendors.a42c3ca0d742766d7a28.js   69.4 KiB       1  [emitted]  vendors
   main.abf44fedb7d11d4312d7.js  240 bytes       2  [emitted]  main
                     index.html  353 bytes          [emitted]
...

现在,可以看到 main 不再含有来自 node_modules 目录的 vendor 代码,并且体积减少到 240 bytes

模块标识符

在项目中再添加一个模块 print.js

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- print.js
|- /node_modules

print.js

+ export default function print(text) {
+   console.log(text);
+ };

src/index.js

  import _ from 'lodash';
+ import Print from './print';

  function component() {
    const element = document.createElement('div');

    // lodash 现在使用 import 引入
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.onclick = Print.bind(null, 'Hello webpack!');

    return element;
  }

  document.body.appendChild(component());

通常我们会期望,当再次执行构建后,只有 main bundle 的哈希值会发生变化,然而……

...
                           Asset       Size  Chunks                    Chunk Names
  runtime.1400d5af64fc1b7b3a45.js    5.85 kB      0  [emitted]         runtime
  vendor.a7561fb0e9a071baadb9.js     541 kB       1  [emitted]  [big]  vendor
    main.b746e3eb72875af2caa9.js    1.22 kB       2  [emitted]         main
                      index.html  352 bytes          [emitted]
...

可以发现,三个文件的哈希值都发生了变化。这是因为每个 module.id 会默认基于解析顺序增加。换言之,当解析顺序发生变化,ID 也会随之改变。简要概括便是:

  • main bundle 会随着自身的新增内容的修改而发生变化。
  • vendor bundle 会随着自身的 module.id 的变化而发生变化。
  • manifest runtime 会因为现在包含一个新模块的引用而发生变化。

上面的第一点与最后一点都是符合预期的行为,而 vendor 的哈希值发生变化是我们要修复的。试试将 optimization.moduleIds 设置为 'deterministic'

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
    optimization: {
+     moduleIds: 'deterministic',
      runtimeChunk: 'single',
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
    },
  };

现在,不论是否添加任何新的本地依赖,对于前后两次构建,vendor 的哈希值都应保持一致:

...
                          Asset       Size  Chunks             Chunk Names
   main.216e852f60c8829c2289.js  340 bytes       0  [emitted]  main
vendors.55e79e5927a639d21a1b.js   69.5 KiB       1  [emitted]  vendors
runtime.725a1a51ede5ae0cfde0.js   1.42 KiB       2  [emitted]  runtime
                     index.html  353 bytes          [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.216e852f60c8829c2289.js
...

接下来修改 src/index.js 临时移除额外的依赖:

src/index.js

  import _ from 'lodash';
- import Print from './print';
+ // import Print from './print';

  function component() {
    const element = document.createElement('div');

    // lodash 现在使用 import 引入
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-   element.onclick = Print.bind(null, 'Hello webpack!');
+   // element.onclick = Print.bind(null, 'Hello webpack!');

    return element;
  }

  document.body.appendChild(component());

最后,再次执行构建:

...
                          Asset       Size  Chunks             Chunk Names
   main.ad717f2466ce655fff5c.js  274 bytes       0  [emitted]  main
vendors.55e79e5927a639d21a1b.js   69.5 KiB       1  [emitted]  vendors
runtime.725a1a51ede5ae0cfde0.js   1.42 KiB       2  [emitted]  runtime
                     index.html  353 bytes          [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.ad717f2466ce655fff5c.js
...

可以看到,在这两次构建中,vendor bundle 的文件名称都是 55e79e5927a639d21a1b

总结

缓存可能很复杂,但是从应用程序或站点用户可以获得的收益来看,这值得付出努力。想要了解更多信息,请查看下面 延伸阅读 部分。

创建库

除了打包应用程序,webpack 还可以打包 JavaScript 库。以下指南适用于希望简化打包策略的库作者。

创建一个库

假设正在编写一个名为 webpack-numbers 的库,用于将数字 1 到 5 转换为文本表示,反之亦然,例如将 2 转换为 'two'。

基本的项目结构应该与下面类似:

project

+  |- webpack.config.js
+  |- package.json
+  |- /src
+    |- index.js
+    |- ref.json

使用 npm 初始化项目,然后安装 webpackwebpack-clilodash

npm init -y
npm install --save-dev webpack webpack-cli lodash

注意:由于不需要将 lodash 一同打包到库中,因此应该将其安装为 devDependencies 而非 dependencies,否则库体积会变得很大。

src/ref.json

[
  {
    "num": 1,
    "word": "One"
  },
  {
    "num": 2,
    "word": "Two"
  },
  {
    "num": 3,
    "word": "Three"
  },
  {
    "num": 4,
    "word": "Four"
  },
  {
    "num": 5,
    "word": "Five"
  },
  {
    "num": 0,
    "word": "Zero"
  }
]

src/index.js

import _ from 'lodash';
import numRef from './ref.json';

export function numToWord(num) {
  return _.reduce(
    numRef,
    (accum, ref) => {
      return ref.num === num ? ref.word : accum;
    },
    ''
  );
}

export function wordToNum(word) {
  return _.reduce(
    numRef,
    (accum, ref) => {
      return ref.word === word && word.toLowerCase() ? ref.num : accum;
    },
    -1
  );
}

webpack 配置

从如下 webpack 基本配置开始:

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'webpack-numbers.js',
  },
};

上述配置告诉 webpack 将 src/index.js 打包到 dist/webpack-numbers.js 中。

暴露库

到目前为止,一切都应该与打包应用程序一样,但是打包库有一个不同的地方——需要通过 output.library 配置项暴露从入口起点导出的内容。

webpack.config.js

  const path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'webpack-numbers.js',
+     library: "webpackNumbers",
    },
  };

我们将入口暴露为 webpackNumbers,这样用户就可以通过脚本标签使用它:

<script src="https://example.org/webpack-numbers.js"></script>
<script>
  window.webpackNumbers.wordToNum('Five');
</script>

然而它只能通过被脚本标签引用而发挥作用,而不能运行在 CommonJS、AMD、Node.js 等环境中。

作为一个库作者,我们希望它能够兼容不同的环境。换言之,用户应该能够通过以下方式使用打包后的库:

  • 在 CommonJS 模块中导入

    const webpackNumbers = require('webpack-numbers');
    // ……
    webpackNumbers.wordToNum('Two');
  • 在 AMD 模块中导入

    require(['webpackNumbers'], function (webpackNumbers) {
      // ……
      webpackNumbers.wordToNum('Two');
    });
  • 使用脚本标签

    <!DOCTYPE html>
    <html>
      ...
      <script src="https://example.org/webpack-numbers.js"></script>
      <script>
        // ……
        // 全局变量
        webpackNumbers.wordToNum('Five');
        // 属性处于 window 对象中
        window.webpackNumbers.wordToNum('Five');
        // ……
      </script>
    </html>

接下来更新 output.library 配置项,将其 type 设置为 'umd'

 const path = require('path');

 module.exports = {
   entry: './src/index.js',
   output: {
     path: path.resolve(__dirname, 'dist'),
     filename: 'webpack-numbers.js',
-    library: 'webpackNumbers',
+    library: {
+      name: 'webpackNumbers',
+      type: 'umd',
+    },
   },
 };

现在 webpack 将打包它,使其可以通过 CommonJS、AMD 模块以及脚本标签使用。

外部化 lodash

现在,如果执行 webpack,你会发现创建了一个体积相当大的文件。查看文件可以发现 lodash 也被打包到代码中。在这种场景中,我们更倾向于把 lodash 当作 peerDependency,即使用者应该已经自行安装过 lodash,这样便可以放弃控制此外部库,将控制权让给使用此库的开发者。

使用 externals 配置即可实现上述目标:

webpack.config.js

  const path = require('path');

  module.exports = {
    entry: './src/index.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'webpack-numbers.js',
      library: {
        name: "webpackNumbers",
        type: "umd"
      },
    },
+   externals: {
+     lodash: {
+       commonjs: 'lodash',
+       commonjs2: 'lodash',
+       amd: 'lodash',
+       root: '_',
+     },
+   },
  };

上面的配置意味着这个库需要一个名为 lodash 的依赖,这个依赖在开发者环境中必须存在且可用。

外部化的限制

对于想要实现从一个依赖中调用多个文件的那些库:

import A from 'library/one';
import B from 'library/two';

// ...

无法通过在 externals 中指定整个 library 的方式将它们从 bundle 中排除,而是需要逐个或者使用正则表达式排除它们。

module.exports = {
  //...
  externals: [
    'library/one',
    'library/two',
    // 匹配以 "library/" 开始的所有依赖
    /^library\/.+$/,
  ],
};

最终步骤

遵循 生产环境 指南中提到的步骤优化生产环境下的输出结果。那么此时还需要生成 bundle 的文件路径,并将其添加到 package.json 中的 main 字段中。

package.json

{
  ...
  "main": "dist/webpack-numbers.js",
  ...
}

或者也可以按照这个 指南 将其添加为标准模块:

{
  ...
  "module": "src/index.js",
  ...
}

此处的键 main 是参照 package.json 标准,而 module 是参照 这个 提案,此提案允许 JavaScript 生态系统升级使用 ES2015 模块,而不会破坏向后兼容性。

现在便可以 将其发布为一个 npm 包,并且在 unpkg.com 找到它,并分发给用户。

环境变量

想要消除 webpack.config.js开发环境生产环境 之间的差异,你可能需要环境变量(environment variable)。

webpack 命令行 环境配置--env 参数,可以允许你传入任意数量的环境变量。而在 webpack.config.js 中可以访问到这些环境变量。例如,--env production--env goal=local

npx webpack --env goal=local --env production --progress

对于我们的 webpack 配置,有一个必须要修改之处。通常,module.exports 指向配置对象。要使用 env 变量,你必须将 module.exports 转换成一个函数:

webpack.config.js

const path = require('path');

module.exports = (env) => {
  // Use env.<YOUR VARIABLE> here:
  console.log('Goal: ', env.goal); // 'local'
  console.log('Production: ', env.production); // true

  return {
    entry: './src/index.js',
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
  };
};

构建性能

本指南包含一些改进构建/编译性能的实用技巧。


通用环境

无论是在 开发环境 还是在 生产环境 下运行构建脚本,以下最佳实践都会有所帮助。

更新到最新版本

使用最新的 webpack 版本。我们会一直坚持进行性能优化。webpack 的最新稳定版本是:

latest webpack version

Node.js 与 package 管理工具(例如 npm 或者 yarn)更新到最新版本均有助于提高性能。较新的版本能够建立更高效的模块树并提高解析速度。

loader

将 loader 应用于最少数量的必要模块。反例:

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
      },
    ],
  },
};

使用 include 字段将 loader 应用在实际需要将其转换的模块:

const path = require('path');

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve(__dirname, 'src'),
        loader: 'babel-loader',
      },
    ],
  },
};

引导(bootstrap)

每个额外的 loader/plugin 都有其启动时间。尽量少地使用工具。

解析

以下步骤可以提高解析速度:

  • 减少 resolve.modules, resolve.extensions, resolve.mainFiles, resolve.descriptionFiles 中条目数量,因为他们会增加文件系统调用的次数。
  • 如果不使用 symlinks(例如 npm link 或者 yarn link),可以设置 resolve.symlinks: false
  • 如果使用自定义解析插件规则,并且没有指定上下文,可以设置 resolve.cacheWithContext: false

dll

使用 DllPlugin 为更改不频繁的代码生成单独的编译结果。尽管这增加了构建过程的复杂度,但是可以提高应用程序的编译速度。

小即是快

减少编译结果的整体大小以提高构建性能。尽量保持 chunk 体积小。

  • 使用数量更少/体积更小的库
  • 在多页面应用程序中使用 SplitChunksPlugin
  • 在多页面应用程序中使用 SplitChunksPlugin,并开启 async 模式。
  • 移除未使用的代码。
  • 只编译当前正在开发的那些代码。

worker 池

thread-loader 可以将非常消耗资源的 loader 分流给 worker 池。

持久化缓存

在 webpack 配置中使用 cache 选项。使用 package.json 中的 "postinstall" 清除缓存目录。

自定义 plugin/loader

请在使用自定义 plugin/loader 前对其进行概要分析以免在此处引入性能问题。

Progress 插件

ProgressPlugin 从 webpack 中删除可以缩短构建时间。请注意,ProgressPlugin 可能不会为快速构建提供太多价值,因此请权衡利弊再使用。


开发环境

以下步骤在开发环境中特别有帮助。

增量编译

使用 webpack 的观察模式,而非使用其他工具观察文件、调用 webpack。内置的观察模式会记录时间戳并将此信息传递给编译以使缓存失效。

在某些配置环境中,观察模式会回退到轮询模式。监听过量文件会导致 CPU 大量负载。此时可以使用 watchOptions.poll 增加轮询的间隔时间。

在内存中编译

使用下面几个工具实现在内存中(而不是写入磁盘)编译并部署可访问资源以提高性能:

  • webpack-dev-server
  • webpack-hot-middleware
  • webpack-dev-middleware

加速 stats.toJson

webpack 4 默认使用 stats.toJson() 输出大量数据。但是除非在增量步骤中做必要的统计,否则请避免获取 stats 对象的部分内容。webpack-dev-server 在 v3.1.3 以后的版本,包含一个重要的性能修复,即最小化每个增量构建步骤中会从 stats 对象获取的数据量。

devtool

不同的 devtool 设置会导致性能差异。

  • "eval" 具有最好的性能,但并不能帮助转译代码。
  • 如果能接受稍差一些的映射质量,可以使用 cheap-source-map 变体配置提高性能。
  • 使用 eval-source-map 变体配置进行增量编译。

避免使用在生产环境下才会用到的工具

某些工具、插件与 loader 都只用于生产环境。例如,在开发环境下使用 TerserPlugin 压缩和破坏代码是没有意义的。通常应该在开发环境下排除以下工具:

  • TerserPlugin
  • [fullhash]/[chunkhash]/[contenthash]
  • AggressiveSplittingPlugin
  • AggressiveMergingPlugin
  • ModuleConcatenationPlugin

最小化入口 chunk

webpack 只会在文件系统中输出已经更新的 chunk。对于某些配置选项(HMR, output.chunkFilename 中的 [name]/[chunkhash]/[contenthash][fullhash])而言,除了已更新的 chunk 之外,入口 chunk 也会失效。

尽量在生成入口 chunk 时减小其体积以提高性能。下面的配置为运行时代码创建了一个额外的 chunk,所以它的生成代价较低:

module.exports = {
  // ...
  optimization: {
    runtimeChunk: true,
  },
};

避免额外的优化步骤

webpack 通过执行额外的算法任务优化输出结果的体积和加载的性能。这些优化适用于小型代码库,但是在大型代码库中却非常耗费性能:

module.exports = {
  // ...
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
  },
};

输出结果不携带路径信息

webpack 会在输出的 bundle 中生成路径信息。然而,在打包数千个模块的项目中,这会带来垃圾回收性能的压力。在 options.output.pathinfo 设置中关闭它:

module.exports = {
  // ...
  output: {
    pathinfo: false,
  },
};

Node.js 版本 8.9.10-9.11.1

Node.js v8.9.10 - v9.11.1 中的 ES2015 MapSet 实现,存在 性能回退。webpack 大量地使用这些数据结构,因此这些回退也会影响编译时间。

之前和之后的 Node.js 版本不受影响。

TypeScript loader

向 loader 传入 transpileOnly 选项,以缩短使用 ts-loader 时的构建时间。使用此选项会关闭类型检查。如果要再次开启类型检查,请使用 ForkTsCheckerWebpackPlugin。使用此插件会将检查过程移至单独的进程,这样可以加快 TypeScript 的类型检查和 ESLint 插入的速度。

module.exports = {
  // ...
  test: /\.tsx?$/,
  use: [
    {
      loader: 'ts-loader',
      options: {
        transpileOnly: true,
      },
    },
  ],
};

生产环境

以下步骤在生产环境中特别有帮助。

source map

source map 相当消耗资源,请确保真的需要它们。


工具相关问题

下列工具存在某些可能会降低构建性能的问题:

babel

  • 最小化项目中的 preset/plugin 数量。

TypeScript

  • 在单独的进程中使用 fork-ts-checker-webpack-plugin 进行类型检查。
  • 配置 loader 跳过类型检查。
  • 使用 ts-loader 时,设置 happyPackMode: truetranspileOnly: true

sass

  • node-sass 中存在 bug,会阻塞 Node.js 线程池中的线程。当使用 thread-loader 时,需要设置 workerParallelJobs: 2

内容安全策略(CSP)

webpack 能够为其加载的所有脚本添加 nonce,即一次性随机数。在入口文件中设置一个 __webpack_nonce__ 变量以激活此功能。然后为每个唯一的页面视图生成和提供一个唯一的基于哈希的 nonce。这就是为什么应该在入口文件中指定 __webpack_nonce__ 而非在配置中指定的原因。请注意,__webpack_nonce__ 应该是一个 base64 编码的字符串。

示例

在入口文件中:

// ...
__webpack_nonce__ = 'c29tZSBjb29sIHN0cmluZyB3aWxsIHBvcCB1cCAxMjM=';
// ...

启用 CSP

注意,默认情况下不启用 CSP。需要与文档一同发送相应的 CSP 响应头 Content-Security-Policy 或元标签 <meta http-equiv="Content-Security-Policy" ...> 以告知浏览器需要启用 CSP。以下是一个包含 CDN 白名单 URL 的 CSP 头部示例:

Content-Security-Policy: default-src 'self'; script-src 'self'
https://trusted.cdn.com;

参阅页面底部的 延伸阅读 以了解更多关于 CSP 与 nonce 属性的信息。

信任类型

只需遵守 CSP require-trusted-types-for 指令的限制,webpack 便能够使用信任类型加载动态构建的脚本。参阅 output.trustedTypes 配置项了解更多。

使用 Vagrant 在虚拟机运行开发环境

如果正在开发一个更加高级的项目,并且需要使用 Vagrant 在虚拟机上运行开发环境,那可能会需要在虚拟机中运行 webpack。

配置项目

首先,确保 Vagrantfile 拥有一个静态 IP。

Vagrant.configure("2") do |config|
  config.vm.network :private_network, ip: "10.10.10.61"
end

然后,在项目中安装 webpackwebpack-cli@webpack-cli/servewebpack-dev-server

npm install --save-dev webpack webpack-cli @webpack-cli/serve webpack-dev-server

创建 webpack.config.js 配置文件,下面的示例代码可以作为起步的最简配置:

module.exports = {
  context: __dirname,
  entry: './app.js',
};

然后,创建一个 index.html 文件。其中的 script 标签应当指向 bundle。如果没有在配置中指定 output.filename,其默认值为 bundle.js

<!DOCTYPE html>
<html>
  <head>
    <script src="/bundle.js" charset="utf-8"></script>
  </head>
  <body>
    <h2>Hey!</h2>
  </body>
</html>

注意,还需要创建一个 app.js 文件。

启动服务器

现在启动服务器:

webpack serve --host 0.0.0.0 --client-web-socket-url ws://10.10.10.61:8080/ws --watch-options-poll

由于默认只允许从 localhost 访问服务器,所以需要修改 --host 参数,以允许在我们的宿主 PC 上访问。

webpack-dev-server 会在包中引入一个脚本,此脚本连接到 WebSocket,这样可以在任何文件修改时,触发重新加载应用程序。 --client-web-socket-url 标志可以确保脚本知道从哪里查找 WebSocket。默认情况下,服务器会使用 8080 端口,因此也需要在这里指定。

--watch-options-poll 可以确保 webpack 能够检测到文件更改。webpack 默认会监听文件系统触发的相关事件,但是 VirtualBox 使用默认配置会有许多问题。

现在服务器应该能够通过 http://10.10.10.61:8080 访问了。修改 app.js,应用程序就会实时重新加载。

配合 nginx 的高级用法

为了更好的模拟类生产环境,可以使用 nginx 来代理 webpack-dev-server

在 nginx 配置文件中,加入下面代码:

server {
  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    error_page 502 @start-webpack-dev-server;
  }

  location @start-webpack-dev-server {
    default_type text/plain;
    return 502 "Please start the webpack-dev-server first.";
  }
}

proxy_set_header 这几行配置很重要,因为它们能够使 WebSocket 正常运行。

然后 webpack-dev-server 启动命令可以修改为:

webpack serve --client-web-socket-url ws://10.10.10.61:8080/ws --watch-options-poll

现在只能通过 127.0.0.1 访问服务,不过这并不影响,因为 ngnix 已经能够支持 PC 电脑访问到服务器。

总结

我们能够从静态 IP 访问 VagrantBox,然后使 webpack-dev-server 可以公开访问,以便浏览器可以访问到它。最后解决了 VirtualBox 不发送到文件系统事件的常见问题,此问题会导致服务器无法重新加载文件更改。

依赖管理

es6 modules

commonjs

amd

带表达式的 require 语句

如果 require 中含有表达式,由于编译时并不清楚 具体 导入了哪个模块,因此会创建一个上下文。

参考接下来的例子:假设现在有包含 .ejs 文件的如下目录结构:

example_directory
│
└───template
│   │   table.ejs
│   │   table-row.ejs
│   │
│   └───directory
│       │   another.ejs

当下面的 require() 调用被评估解析时:

require('./template/' + name + '.ejs');

webpack 解析会 require() 调用,然后提取出如下一些信息:

Directory: ./template
Regular expression: /^.*\.ejs$/

上下文模块

并且会创建一个上下文模块。它包含 对该目录下所有模块 的引用,可以使用匹配正则表达式的请求来导入这些模块。上下文模块中存在一个映射,该映射用于将请求转换为模块 ID。

示例映射:

{
  "./table.ejs": 42,
  "./table-row.ejs": 43,
  "./directory/another.ejs": 44
}

此上下文模块还包含一些访问此映射对象的运行时逻辑。

这意味着 webpack 能够支持动态导入,但会导致所有可能用到的模块都包含在 bundle 中。

require.context

可以通过 require.context() 函数实现自定义上下文。

可以给这个函数传入三个参数:要搜索的目录、是否还搜索其子目录,匹配文件的正则表达式。

webpack 会在构建中解析代码中的 require.context()

语法如下:

require.context(
  directory,
  (useSubdirectories = true),
  (regExp = /^\.\/.*$/),
  (mode = 'sync')
);

示例:

require.context('./test', false, /\.test\.js$/);
// 创建一个上下文,其中文件直接来自 test 目录,require 包含的表达式以 `.test.js` 结尾。
require.context('../', true, /\.stories\.js$/);
// 创建一个上下文,其中文件来自父文件夹及其所有子级文件夹,require 包含的表达式以 `.stories.js` 结尾。

上下文模块 API

上下文模块会导出一个接收一个参数 request 的 require 函数。

此导出函数有三个属性:resolvekeysid

  • resolve 是一个函数,它返回 request 被解析后得到的模块 id。
  • keys 也是一个函数,它返回一个数组,由所有可能被此上下文模块处理的请求组成。

如果想引入一个文件夹下面的所有文件,或者引入能匹配一个正则表达式的所有文件,这个功能就会很有帮助,例如:

function importAll(r) {
  r.keys().forEach(r);
}

importAll(require.context('../components/', true, /\.js$/));
const cache = {};

function importAll(r) {
  r.keys().forEach((key) => (cache[key] = r(key)));
}

importAll(require.context('../components/', true, /\.js$/));
// 构建时所有被导入的模块都会被填充到缓存对象中。
  • id 是上下文模块的模块 id. 它可能在使用 module.hot.accept 时会用到。

安装

本指南介绍了安装 webpack 的各种方法。

前提条件

在开始之前,请确保安装了 Node.js 的最新版本。使用 Node.js 最新的长期支持版本(LTS - Long Term Support),是理想的起步。 使用旧版本,你可能遇到各种问题,因为它们可能缺少 webpack 功能, 或者缺少相关 package。

本地安装

最新的 webpack 正式版本是:

GitHub release

要安装最新版本或特定版本,请运行以下命令之一:

npm install --save-dev webpack
# 或指定版本
npm install --save-dev webpack@<version>

如果你使用 webpack v4+ 版本,并且想要在命令行中调用 webpack,你还需要安装 CLI

npm install --save-dev webpack-cli

对于大多数项目,我们建议本地安装。这可以在引入重大更新(breaking change)版本时,更容易分别升级项目。 通常会通过运行一个或多个 npm scripts 以在本地 node_modules 目录中查找安装的 webpack, 来运行 webpack:

"scripts": {
  "build": "webpack --config webpack.config.js"
}

全局安装

通过以下 NPM 安装方式,可以使 webpack 在全局环境下可用:

npm install --global webpack

最新体验版本

如果你热衷于使用最新版本的 webpack,你可以使用以下命令安装 beta 版本, 或者直接从 webpack 的仓库中安装:

npm install --save-dev webpack@next
# 或特定的 tag/分支
npm install --save-dev webpack/webpack#<tagname/branchname>

模块热替换

模块热替换(HMR)是 webpack 提供的最有用的功能之一。它能帮助在运行时不完全刷新页面的情况下更新所有类型的模块。本页面重点介绍 如何使用,而 概念 页面提供了更多关于它的工作原理以及为什么它有用的细节。

启用 HMR

此功能可以很大程度提高生产效率。我们要做的就是更新 webpack-dev-server 配置, 然后使用 webpack 内置的 HMR 插件。我们还要删除掉 print.js 的入口起点, 因为现在已经在 index.js 模块中引用了它。

webpack-dev-server v4.0.0 开始,模块热替换是默认开启的。

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: {
       app: './src/index.js',
-      print: './src/print.js',
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
+     hot: true,
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Hot Module Replacement',
      }),
    ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

也可以为 HMR 提供入口起点:

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
+ const webpack = require("webpack");

  module.exports = {
    entry: {
       app: './src/index.js',
-      print: './src/print.js',
+      // 模块热替换的运行时代码
+      hot: 'webpack/hot/dev-server.js',
+      // 用于 web 套接字传输、热重载逻辑的 web server 客户端
+      client: 'webpack-dev-server/client/index.js?hot=true&live-reload=true',
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
+     // 用于 web 套接字传输、热重载逻辑的 web server 客户端
+     hot: false,
+     client: false,
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Hot Module Replacement',
      }),
+     // 模块热替换的插件
+     new webpack.HotModuleReplacementPlugin(),
    ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

现在修改 index.js 文件,以便检测到 print.js 内部发生更改时告诉 webpack 接受更新的模块。

index.js

  import _ from 'lodash';
  import printMe from './print.js';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;

    element.appendChild(btn);

    return element;
  }

  document.body.appendChild(component());
+
+ if (module.hot) {
+   module.hot.accept('./print.js', function() {
+     console.log('Accepting the updated printMe module!');
+     printMe();
+   })
+ }

更改 print.jsconsole.log 的输出内容,你将会在浏览器中看到如下的输出 (不要担心现在 button.onclick = printMe() 的输出,我们稍后也会更新该部分)。

print.js

  export default function printMe() {
-   console.log('我在 print.js 中被调用了!');
+   console.log('正在更新 print.js……');
  }

console

[HMR] Waiting for update signal from WDS...
main.js:4395 [WDS] Hot Module Replacement enabled.
+ 2main.js:4395 [WDS] App updated. Recompiling...
+ main.js:4395 [WDS] App hot update...
+ main.js:4330 [HMR] Checking for updates on the server...
+ main.js:10024 Accepting the updated printMe module!
+ 0.4b8ee77….hot-update.js:10 正在更新 print.js……
+ main.js:4330 [HMR] Updated modules:
+ main.js:4330 [HMR]  - 20

通过 Node.js API 启用 HMR

在 Node.js API 中使用 webpack dev server 时,不要将 dev server 选项放在 webpack 配置对象中。而是在创建时, 将其作为第二个参数传递。例如:

new WebpackDevServer(options, compiler)

想要启用 HMR,还需要修改 webpack 配置对象,使其包含 HMR 入口起点。这是关于如何使用的一个基本示例:

dev-server.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const webpack = require('webpack');
const webpackDevServer = require('webpack-dev-server');

const config = {
  mode: 'development',
  entry: [
    // 模块热替换的运行时代码
    'webpack/hot/dev-server.js',
    // 用于 web 套接字传输、热重载逻辑的 web server 客户端
    'webpack-dev-server/client/index.js?hot=true&live-reload=true',
    // 你的入口起点
    './src/index.js',
  ],
  devtool: 'inline-source-map',
  plugins: [
    // 模块热替换的插件
    new webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      title: 'Hot Module Replacement',
    }),
  ],
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
};
const compiler = webpack(config);

// 由于手动添加了 `hot` 与 `client` 参数,其将被禁用
const server = new webpackDevServer({ hot: false, client: false }, compiler);

(async () => {
  await server.start();
  console.log('dev server 正在运行');
})();

参阅 webpack-dev-server 的 Node.js API 的完整文档 以了解更多。

问题

模块热替换可能比较难以掌握。为了说明这一点,我们回到刚才的示例中。如果继续点击示例页面上的按钮 会发现控制台仍在打印旧的 printMe 函数。

这是因为按钮的 onclick 事件处理函数仍然绑定在旧的 printMe 函数上。

为了让 HMR 正常工作,我们需要更新代码,使用 module.hot.accept 将其绑定到新的 printMe 函数上:

index.js

  import _ from 'lodash';
  import printMe from './print.js';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;  // onclick 事件仍然绑定着原本的 printMe 函数

    element.appendChild(btn);

    return element;
  }

- document.body.appendChild(component());
+ let element = component(); // 存储 element,以在 print.js 修改时重新渲染
+ document.body.appendChild(element);

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('正在接受更新后的 printMe 模块!');
-     printMe();
+     document.body.removeChild(element);
+     element = component(); // 重新渲染 "component",以便更新 click 事件处理函数
+     document.body.appendChild(element);
    })
  }

这仅仅是一个示例,还有很多让人易于犯错的情况。 幸运的是,有很多 loader(下面会提到一些)可以使得模块热替换变得更加容易。

HMR 加载样式

借助 style-loader 后,使用模块热替换加载 CSS 实际上极其简单。此 loader 在幕后使用了 module.hot.accept,在 CSS 依赖模块更新之后,会将其修补到 <style> 标签中。

首先使用以下命令安装两个 loader :

npm install --save-dev style-loader css-loader

然后更新配置文件,使用这两个 loader。

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: {
      app: './src/index.js',
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
      hot: true,
    },
+   module: {
+     rules: [
+       {
+         test: /\.css$/,
+         use: ['style-loader', 'css-loader'],
+       },
+     ],
+   },
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Hot Module Replacement',
      }),
    ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

热加载样式表也可以通过将它们引入一个模块来实现:

project

  webpack-demo
  | - package.json
  | - webpack.config.js
  | - /dist
    | - bundle.js
  | - /src
    | - index.js
    | - print.js
+   | - styles.css

styles.css

body {
  background: blue;
}

index.js

  import _ from 'lodash';
  import printMe from './print.js';
+ import './styles.css';

  function component() {
    const element = document.createElement('div');
    const btn = document.createElement('button');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    btn.innerHTML = 'Click me and check the console!';
    btn.onclick = printMe;  // onclick 事件仍然绑定着原本的 printMe 函数

    element.appendChild(btn);

    return element;
  }

  let element = component();
  document.body.appendChild(element);

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('正在接受更新后的 printMe 模块!');
      document.body.removeChild(element);
      element = component(); // 重新渲染 component 以更新点击事件处理程序
      document.body.appendChild(element);
    })
  }

body 的 style 改为 background: red;,你应该可以立即看到页面的背景颜色随之更改,而无需完全刷新。

styles.css

  body {
-   background: blue;
+   background: red;
  }

其他代码和框架

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

  • React Hot Loader:实时调整 react 组件。
  • Vue Loader:此 loader 支持 vue 组件的 HMR,提供开箱即用体验。
  • Elm Hot webpack Loader:支持 Elm 编程语言的 HMR。
  • Angular HMR:没有必要使用 loader!直接修改 NgModule 主文件就够了,它可以完全控制 HMR API。
  • Svelte Loader:此 loader 开箱即用地支持 Svelte 组件的热更新。

Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的死代码。它依赖于 ES2015 模块语法的 静态结构 特性,例如 importexport。这个术语和概念实际上是由 ES2015 模块捆绑器 rollup 普及起来的。

webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony module)与对未使用模块的检测能力。webpack 4 正式版本扩展了此检测能力:通过 package.json"sideEffects" 属性作为标记,向编译器提供提示,表明项目中的哪些文件是纯正的 ES2015 模块,由此可以安全地删除文件中未使用的部分。

添加一个通用模块

在项目中添加一个新的通用模块文件 src/math.js,并导出两个函数:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
  |- bundle.js
  |- index.html
|- /src
  |- index.js
+ |- math.js
|- /node_modules

src/math.js

export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

需要将 mode 配置设置为 development,以确定 bundle 不会被压缩:

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
+ mode: 'development',
+ optimization: {
+   usedExports: true,
+ },
};

配置完这些后,更新入口脚本,使用其中一个新方法,同时为了简化示例将 lodash 删除:

src/index.js

- import _ from 'lodash';
+ import { cube } from './math.js';

  function component() {
-   const element = document.createElement('div');
+   const element = document.createElement('pre');

-   // lodash 现在使用 import 引入
-   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.innerHTML = [
+     '你好 webpack!',
+     '5 的立方等于 ' + cube(5)
+   ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

注意,我们 没有从 src/math.js 模块中 import 另外一个 square 方法。这个没有引用的函数就是所谓的 死代码,即应当删除掉未被引用的 export。现在运行 npm script npm run build,并查看输出的 bundle:

dist/bundle.js(大约在 90 到 100 行)

/* 1 */
/***/ (function (module, __webpack_exports__, __webpack_require__) {
  'use strict';
  /* unused harmony export square */
  /* harmony export (immutable) */ __webpack_exports__['a'] = cube;
  function square(x) {
    return x * x;
  }

  function cube(x) {
    return x * x * x;
  }
});

注意看上方的 unused harmony export square 注释。仔细观察下面的代码会发现尽管没有引用 square,但它仍然被包含在 bundle 中。我们将在后面的章节解决这个问题。

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

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

通过 package.json 的 "sideEffects" 属性即可实现此目的。

{
  "name": "your-project",
  "sideEffects": false
}

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

如果某些代码确实存在一些副作用,可以将 sideEffects 指定为一个数组:

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

此数组支持简单的 glob 模式匹配相关文件。其内部使用的是 glob-to-regexp(支持:***{a,b}[a-z])。如果匹配模式为 *.css,且不包含 /,将被视为 **/*.css

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

最后,还可以在 module.rules 配置选项 中设置 "sideEffects"

解释 tree shaking 和 sideEffects

sideEffectsusedExports(更多地被称为 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 检测语句中的副作用。它是一个 JavaScript 任务而且不像 sideEffects 一样简单直接。并且由于规范认为副作用需要被评估,因此它不能跳过子树/依赖项。尽管导出函数能正常运行,但 React 的高阶组件在这种情况下会出问题。

让我们来看一个例子:

import { Button } from '@shopify/polaris';

打包前的文件版本看起来是这样的:

import hoistStatics from 'hoist-non-react-statics';

function Button(_ref) {
  // ...
}

function merge() {
  var _final = {};

  for (
    var _len = arguments.length, objs = new Array(_len), _key = 0;
    _key < _len;
    _key++
  ) {
    objs[_key] = arguments[_key];
  }

  for (var _i = 0, _objs = objs; _i < _objs.length; _i++) {
    var obj = _objs[_i];
    mergeRecursively(_final, obj);
  }

  return _final;
}

function withAppProvider() {
  return function addProvider(WrappedComponent) {
    var WithProvider =
      /*#__PURE__*/
      (function (_React$Component) {
        // ...
        return WithProvider;
      })(Component);

    WithProvider.contextTypes = WrappedComponent.contextTypes
      ? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
      : polarisAppProviderContextTypes;
    var FinalComponent = hoistStatics(WithProvider, WrappedComponent);
    return FinalComponent;
  };
}

var Button$1 = withAppProvider()(Button);

export {
  // ...,
  Button$1,
};

Button 没有被使用时,删除 export { Button$1 }; 并保留其余所有代码会让代码变得更加高效。所以问题是:“这段代码是否有任何副作用,是否可以安全删除?”这很难说,尤其是因为这行代码 withAppProvider()(Button)。在这行代码中,withAppProvider 被调用了,并且其返回值(译注:请注意,withAppProvider 的返回值是一个函数)也被调用了。那么当执行 withAppProvider 及其返回值时,调用 mergehoistStatics 会有任何副作用吗?读取 WrappedComponent.contextTypes(Getter)或向 WithProvider.contextTypes(Setter)赋值时会有任何副作用吗?

实际上,usedExports 依赖的 terser 就尝试去解决这些问题,但在许多情况下它仍然不确定函数的调用是否有副作用。但这并不意味着 terser 会由于无法解决这些问题而运作得不好。根本原因在于像 JavaScript 这类动态语言中很难可靠确定这一点。

但我们可以通过 /*#__PURE__*/ 注释来帮助 terser。这个注释的作用是标记此语句没有副作用。这样一个简单的改变就能够 tree-shake 下面的代码了:

var Button$1 = /*#__PURE__*/ withAppProvider()(Button);

这将允许删除这段代码。但是除此之外,引入的内容可能仍然存在副作用的问题,因此需要对其进入评估。

为了解决这个问题,我们需要在 package.json 中添加 "sideEffects" 属性。

它与 /*#__PURE__*/ 类似,但是作用于模块层面,而非代码语句的层面。"sideEffects" 属性的意思是:“如果没有使用被标记为无副作用的模块的直接导出,那么捆绑器会跳过对此模块的副作用评估”。

考虑 Shopify Polaris 的例子,原有的模块如下:

index.js

import './configure';
export * from './types';
export * from './components';

components/index.js

// ...
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
// ...

package.json

// ...
"sideEffects": [
  "**/*.css",
  "**/*.scss",
  "./esnext/index.js",
  "./esnext/configure.js"
],
// ...

代码 import { Button } from "@shopify/polaris"; 存在以下可能:

  • 导入它:导入并包含该模块,分析评估它并继续进行依赖分析
  • 跳过它:不导入它,不分析评估它但会继续进行依赖分析
  • 排除它:不导入它,不评估且不做依赖分析

以下是每个匹配到的资源的情况:

  • index.js:没有直接的导出被使用,但被标记为有副作用 → 导入它
  • configure.js:没有导出被使用,但被标记为有副作用 → 导入它
  • types/index.js:没有导出被使用,没有被标记为有副作用 → 排除它
  • components/index.js:没有导出被使用,没有被标记为有副作用,但重新导出的导出内容被使用了 → 跳过它
  • components/Breadcrumbs.js:没有导出被使用,没有被标记为有副作用 → 排除它。这也会排除所有如同 components/Breadcrumbs.css 的依赖,尽管它们都被标记为有副作用。
  • components/Button.js:直接的导出被使用,没有被标记为有副作用 → 导入它
  • components/Button.css:没有导出被使用,但被标记为有副作用 → 导入它

在这种情况下,只有 4 个模块被导入到 bundle 中:

  • 基本为空的 index.js
  • configure.js
  • components/Button.js
  • components/Button.css

在这次的优化后,其它的优化项目都可以应用。例如:从 Button.js 导出的 buttonFrombuttonsFrom 也没有被使用。usedExports 优化会捡起这些代码而且 terser 能够从 bundle 中将这些语句摘除。

由于模块合并也会生效,所以这 4 个模块与入口模块(也可能有更多的依赖)会被合并。index.js 最终没有生成代码

将函数调用标记为无副作用

通过 /*#__PURE__*/ 注释可以告诉 webpack 某个函数调用无副作用。它可以被放到函数调用之前,用来标记此函数调用是无副作用的。传入函数的参数无法被刚才的注释所标记,需要单独对每一个参数进行标记。如果一个没被使用的变量定义的初始值被认为是无副作用的,它会被标记为死代码,不会被执行且会被压缩工具清除掉。当 optimization.innerGraph 被设置成 true 时这个行为将被启用。

file.js

/*#__PURE__*/ double(55);

压缩输出结果

通过 importexport 语法,我们已经找出需要删除的死代码,然而,不仅仅是要找出,还应在 bundle 中删除它们。为此,我们需要将 mode 配置选项设置为 production

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
- mode: 'development',
- optimization: {
-   usedExports: true,
- }
+ mode: 'production',
};

准备就绪后运行命令 npm run build,看看输出结果有没有发生改变。

你发现 dist/bundle.js 中的差异了吗?现在整个 bundle 都已经被压缩和混淆破坏,但是如果仔细观察,则不会看到引入了 square 函数,但能看到 cube 函数的破坏版本(function r(e){return e*e*e}n.a=r)。现在通过代码压缩与 tree shaking,我们的 bundle 缩小了几个字节!虽然在这个特定示例中,可能看起来没有减少很多,但是,在有着复杂依赖树的大型应用程序上运行 tree shaking 时,会对 bundle 产生显著的体积优化。

总结

我们学到为了利用 tree shaking 的优势,必须:

  • 使用 ES2015 模块语法(即 importexport);
  • 确保没有编译器将 ES2015 模块语法转换为 CommonJS(顺带一提,这是现在常用的 @babel/preset-env 的默认行为,请参阅 文档 以了解更多信息)。
  • 在项目的 package.json 文件中添加 "sideEffects" 属性。
  • 使用 mode"production" 的配置项以启用 更多优化项,包括压缩代码与 tree shaking。

你可以将应用程序想象成一棵树。绿色表示实际用到的源码和库,是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动(shake)这棵树,使它们落下。

如果你对优化输出很感兴趣,请进入到下个指南,来了解 生产环境 构建的详细细节。

生产环境

在本指南中,我们将深入一些最佳实践和工具,将站点或应用程序构建到生产环境中。

配置

development(开发环境)production(生产环境) 这两个环境下的构建目标存在着巨大差异。在开发环境中,我们需要:强大的 source map 和一个有着 live reloading(实时重新加载) 或 hot module replacement(模块热替换) 能力的 localhost server。而生产环境目标则转移至其他方面,关注点在于压缩 bundle、更轻量的 source map、资源优化等,通过这些优化方式改善加载时间。由于要遵循逻辑分离,我们通常建议为每个环境编写彼此独立的 webpack 配置

虽然,以上我们将 生产环境开发环境 做了细微区分,但是,请注意,我们还是会遵循不重复原则(Don't repeat yourself - DRY),保留一个 "common(通用)" 配置。为了将这些配置合并在一起,我们将使用一个名为 webpack-merge 的工具。此工具会引用 "common" 配置,因此我们不必再在环境特定(environment-specific)的配置中编写重复代码。

我们先从安装 webpack-merge 开始,并将之前指南中已经成型的那些代码进行分离:

npm install --save-dev webpack-merge

project

  webpack-demo
  |- package.json
  |- package-lock.json
- |- webpack.config.js
+ |- webpack.common.js
+ |- webpack.dev.js
+ |- webpack.prod.js
  |- /dist
  |- /src
    |- index.js
    |- math.js
  |- /node_modules

webpack.common.js

+ const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
+
+ module.exports = {
+   entry: {
+     app: './src/index.js',
+   },
+   plugins: [
+     new HtmlWebpackPlugin({
+       title: 'Production',
+     }),
+   ],
+   output: {
+     filename: '[name].bundle.js',
+     path: path.resolve(__dirname, 'dist'),
+     clean: true,
+   },
+ };

webpack.dev.js

+ const { merge } = require('webpack-merge');
+ const common = require('./webpack.common.js');
+
+ module.exports = merge(common, {
+   mode: 'development',
+   devtool: 'inline-source-map',
+   devServer: {
+     static: './dist',
+   },
+ });

webpack.prod.js

+ const { merge } = require('webpack-merge');
+ const common = require('./webpack.common.js');
+
+ module.exports = merge(common, {
+   mode: 'production',
+ });

现在,在 webpack.common.js 中,我们设置了 entryoutput 配置,并且在其中引入这两个环境公用的全部插件。在 webpack.dev.js 中,我们将 mode 设置为 development,并且为此环境添加了推荐的 devtool(强大的 source map)和 devServer 配置。最后,在 webpack.prod.js 中,我们将 mode 设置为 production,其中会引入之前在 tree shaking 指南中介绍过的 TerserPlugin

注意,在环境特定的配置中使用 merge() 功能,可以很方便地引用 webpack.dev.jswebpack.prod.js 中公用的 common 配置。webpack-merge 工具提供了各种 merge(合并) 高级功能,但是在我们的用例中,无需用到这些功能。

NPM Scripts

现在,我们把 scripts 重新指向到新配置。让 npm start script 中 webpack-dev-server, 使用 webpack.dev.js, 而让 npm run build script 使用 webpack.prod.js:

package.json

  {
    "name": "development",
    "version": "1.0.0",
    "description": "",
    "main": "src/index.js",
    "scripts": {
-     "start": "webpack serve --open",
+     "start": "webpack serve --open --config webpack.dev.js",
-     "build": "webpack"
+     "build": "webpack --config webpack.prod.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "devDependencies": {
      "css-loader": "^0.28.4",
      "csv-loader": "^2.1.1",
      "express": "^4.15.3",
      "file-loader": "^0.11.2",
      "html-webpack-plugin": "^2.29.0",
      "style-loader": "^0.18.2",
      "webpack": "^4.30.0",
      "webpack-dev-middleware": "^1.12.0",
      "webpack-dev-server": "^2.9.1",
      "webpack-merge": "^4.1.0",
      "xml-loader": "^1.2.1"
    }
  }

随便运行下这些脚本,然后查看输出结果的变化,然后我们会继续添加一些生产环境配置。

指定 mode

许多 library 通过与 process.env.NODE_ENV 环境变量关联,以决定 library 中应该引用哪些内容。例如,当process.env.NODE_ENV 没有被设置为 'production' 时,某些 library 为了使调试变得容易,可能会添加额外的 log(日志记录) 和 test(测试) 功能。并且,在使用 process.env.NODE_ENV === 'production' 时,一些 library 可能针对具体用户的环境,删除或添加一些重要代码,以进行代码执行方面的优化。从 webpack v4 开始, 指定 mode 会自动地配置 DefinePlugin

webpack.prod.js

  const { merge } = require('webpack-merge');
  const common = require('./webpack.common.js');

  module.exports = merge(common, {
    mode: 'production',
  });

如果你正在使用像 react 这样的 library,那么在添加此 DefinePlugin 插件后,你应该看到 bundle 大小显著下降。还要注意,任何位于 /src 的本地代码都可以关联到 process.env.NODE_ENV 环境变量,所以以下检查也是有效的:

src/index.js

  import { cube } from './math.js';
+
+ if (process.env.NODE_ENV !== 'production') {
+   console.log('Looks like we are in development mode!');
+ }

  function component() {
    const element = document.createElement('pre');

    element.innerHTML = [
      'Hello webpack!',
      '5 cubed is equal to ' + cube(5)
    ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

压缩(Minification)

Webpack v4+ will minify your code by default in production mode.

注意,虽然生产环境下默认使用 TerserPlugin ,并且也是代码压缩方面比较好的选择,但是还有一些其他可选择项。以下有几个同样很受欢迎的插件:

如果决定尝试一些其他压缩插件,确保新插件也会按照 tree shake 指南中所陈述的具有删除未引用代码(dead code)的能力,并将它作为 optimization.minimizer

源码映射(Source Mapping)

我们鼓励你在生产环境中启用 source map,因为它们对 debug(调试源码) 和运行 benchmark tests(基准测试) 很有帮助。虽然有着如此强大的功能,然而还是应该针对生产环境用途,选择一个可以快速构建的推荐配置(更多选项请查看 devtool)。对于本指南,我们将在 生产环境 中使用 source-map 选项,而不是我们在开发环境中用到的 inline-source-map

webpack.prod.js

  const { merge } = require('webpack-merge');
  const common = require('./webpack.common.js');

  module.exports = merge(common, {
    mode: 'production',
+   devtool: 'source-map',
  });

压缩 CSS

将生产环境下的 CSS 进行压缩会非常重要,请查看 在生产环境下压缩 章节。

CLI 替代选项

上述许多选项都可以通过命令行参数进行设置。例如,optimize-minimize 可以使用 --optimization-minimize 进行设置,mode 可以使用 --mode 进行设置。运行 npx webpack --help=verbose 可以查看所有关于 CLI 的可用参数。

虽然这种简写方式很有用处,但我们还是建议通过 webpack 配置文件的方式进行使用,这样可以提高可配置能力。

懒加载

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

示例

我们在代码分离中的例子基础上,进一步做些调整来说明这个概念。那里的代码确实会在脚本运行的时候产生一个分离的代码块 lodash.bundle.js ,在技术概念上“懒加载”它。问题是加载这个包并不需要用户的交互 - 意思是每次加载页面的时候都会请求它。这样做并没有对我们有很多帮助,还会对性能产生负面影响。

我们试试不同的做法。我们增加一个交互,当用户点击按钮的时候用 console 打印一些文字。但是会等到第一次交互的时候再加载那个代码块(print.js)。为此,我们返回到代码分离的例子中,把 lodash 放到主代码块中,重新运行 代码分离 中的代码 final Dynamic Imports example

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- print.js
|- /node_modules

src/print.js

console.log(
  'The print.js module has loaded! See the network tab in dev tools...'
);

export default () => {
  console.log('Button Clicked: Here\'s "some text"!');
};

src/index.js

+ import _ from 'lodash';
+
- async function getComponent() {
+ function component() {
    const element = document.createElement('div');
-   const _ = await import(/* webpackChunkName: "lodash" */ 'lodash');
+   const button = document.createElement('button');
+   const br = document.createElement('br');

+   button.innerHTML = 'Click me and look at the console!';
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.appendChild(br);
+   element.appendChild(button);
+
+   // Note that because a network request is involved, some indication
+   // of loading would need to be shown in a production-level site/app.
+   button.onclick = e => import(/* webpackChunkName: "print" */ './print').then(module => {
+     const print = module.default;
+
+     print();
+   });

    return element;
  }

- getComponent().then(component => {
-   document.body.appendChild(component);
- });
+ document.body.appendChild(component());

现在运行 webpack 来验证一下我们的懒加载功能:

...
          Asset       Size  Chunks                    Chunk Names
print.bundle.js  417 bytes       0  [emitted]         print
index.bundle.js     548 kB       1  [emitted]  [big]  index
     index.html  189 bytes          [emitted]
...

框架

许多框架和类库对于如何用它们自己的方式来实现(懒加载)都有自己的建议。这里有一些例子:

ECMAScript 模块

ECMAScript 模块(ESM)是在 Web 中使用模块的规范。 所有现代浏览器均支持此功能,同时也是在 Web 中编写模块化代码的推荐方式。

webpack 支持处理 ECMAScript 模块以优化它们。

导出

关键字 export 允许将 ESM 中的内容暴露给其他模块:

export const CONSTANT = 42;

export let variable = 42;
// 对外暴露的变量为只读
// 无法从外部修改

export function fun() {
  console.log('fun');
}

export class C extends Super {
  method() {
    console.log('method');
  }
}

let a, b, other;
export { a, b, other as c };

export default 1 + 2 + 3 + more();

导入

关键字 import 允许从其他模块获取引用到 ESM 中:

import { CONSTANT, variable } from './module.js';
// 导入由其他模块导出的“绑定”
// 这些绑定是动态的. 这里并非获取到了值的副本
// 而是当将要访问“variable”时
// 再从导入的模块中获取当前值

import * as module from './module.js';
module.fun();
// 导入包含所有导出内容的“命名空间对象”

import theDefaultValue from './module.js';
// 导入 `default` 导出的快捷方式

将模块标记为 ESM

默认情况下,webpack 将自动检测文件是 ESM 还是其他模块系统。

Node.js 通过设置 package.json 中的属性来显式设置文件模块类型。 在 package.json 中设置 "type": "module" 会强制 package.json 下的所有文件使用 ECMAScript 模块。 设置 "type": "commonjs" 将会强制使用 CommonJS 模块。

{
  "type": "module"
}

除此之外,文件还可以通过使用 .mjs.cjs 扩展名来设置模块类型。 .mjs 将它们强制置为 ESM,.cjs 将它们强制置为 CommonJs。

在使用 text/javascriptapplication/javascript mime type 的 DataURI 中,也将使用 ESM。

除了模块格式外,将模块标记为 ESM 还会影响解析逻辑,操作逻辑和模块中的可用符号。

导入模块在 ESM 中更为严格,导入相对路径的模块必须包含文件名和文件扩展名(例如 *.js 或者 *.mjs),除非你设置了 fullySpecified=false

non-ESM 仅能导入 default 导出的模块,不支持命名导出的模块。

CommonJs 语法不可用: require, module, exports, __filename, __dirname.

Shimming 预置依赖

webpack compiler 能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些 third party(第三方库) 可能会引用一些全局依赖(例如 jQuery 中的 $)。因此这些 library 也可能会创建一些需要导出的全局变量。这些 "broken modules(不符合规范的模块)" 就是 shimming(预置依赖) 发挥作用的地方。

shim 另外一个极其有用的使用场景就是:当你希望 polyfill 扩展浏览器能力,来支持到更多用户时。在这种情况下,你可能只是想要将这些 polyfills 提供给需要修补(patch)的浏览器(也就是实现按需加载)。

下面的文章将向我们展示这两种用例。

Shimming 预置全局变量

让我们开始第一个 shimming 全局变量的用例。在此之前,先看下我们的项目:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
  |- index.html
|- /src
  |- index.js
|- /node_modules

还记得我们之前用过的 lodash 吗?出于演示目的,例如把这个应用程序中的模块依赖,改为一个全局变量依赖。要实现这些,我们需要使用 ProvidePlugin 插件。

使用 ProvidePlugin 后,能够在 webpack 编译的每个模块中,通过访问一个变量来获取一个 package。如果 webpack 看到模块中用到这个变量,它将在最终 bundle 中引入给定的 package。让我们先移除 lodashimport 语句,改为通过插件提供它:

src/index.js

-import _ from 'lodash';
-
 function component() {
   const element = document.createElement('div');

-  // Lodash, now imported by this script
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');

   return element;
 }

 document.body.appendChild(component());

webpack.config.js

 const path = require('path');
+const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  plugins: [
+    new webpack.ProvidePlugin({
+      _: 'lodash',
+    }),
+  ],
 };

我们本质上所做的,就是告诉 webpack……

如果你遇到了至少一处用到 _ 变量的模块实例,那请你将 lodash package 引入进来,并将其提供给需要用到它的模块。

运行我们的构建脚本,将会看到同样的输出:

$ npm run build

..

[webpack-cli] Compilation finished
asset main.js 69.1 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 344 bytes 2 modules
cacheable modules 530 KiB
  ./src/index.js 191 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 2910 ms

还可以使用 ProvidePlugin 暴露出某个模块中单个导出,通过配置一个“数组路径”(例如 [module, child, ...children?])实现此功能。所以,我们假想如下,无论 join 方法在何处调用,我们都只会获取到 lodash 中提供的 join 方法。

src/index.js

 function component() {
   const element = document.createElement('div');

-  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+  element.innerHTML = join(['Hello', 'webpack'], ' ');

   return element;
 }

 document.body.appendChild(component());

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
   plugins: [
     new webpack.ProvidePlugin({
-      _: 'lodash',
+      join: ['lodash', 'join'],
     }),
   ],
 };

这样就能很好的与 tree shaking 配合,将 lodash library 中的其余没有用到的导出去除。

细粒度 Shimming

一些遗留模块依赖的 this 指向的是 window 对象。在接下来的用例中,调整我们的 index.js

 function component() {
   const element = document.createElement('div');

   element.innerHTML = join(['Hello', 'webpack'], ' ');

+   // 假设我们处于 `window` 上下文
+   this.alert('Hmmm, this probably isn\'t a great idea...')
+
   return element;
 }

 document.body.appendChild(component());

当模块运行在 CommonJS 上下文中,这将会变成一个问题,也就是说此时的 this 指向的是 module.exports。在这种情况下,你可以通过使用 imports-loader 覆盖 this 指向:

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  module: {
+    rules: [
+      {
+        test: require.resolve('./src/index.js'),
+        use: 'imports-loader?wrapper=window',
+      },
+    ],
+  },
   plugins: [
     new webpack.ProvidePlugin({
       join: ['lodash', 'join'],
     }),
   ],
 };

全局 Exports

让我们假设,某个 library 创建出一个全局变量,它期望 consumer(使用者) 使用这个变量。为此,我们可以在项目配置中,添加一个小模块来演示说明:

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
  |- /src
    |- index.js
+   |- globals.js
  |- /node_modules

src/globals.js

const file = 'blah.txt';
const helpers = {
  test: function () {
    console.log('test something');
  },
  parse: function () {
    console.log('parse something');
  },
};

你可能从来没有在自己的源码中做过这些事情,但是你也许遇到过一个老旧的 library,和上面所展示的代码类似。在这种情况下,我们可以使用 exports-loader,将一个全局变量作为一个普通的模块来导出。例如,为了将 file 导出为 file 以及将 helpers.parse 导出为 parse,做如下调整:

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: require.resolve('./src/index.js'),
         use: 'imports-loader?wrapper=window',
       },
+      {
+        test: require.resolve('./src/globals.js'),
+        use:
+          'exports-loader?type=commonjs&exports=file,multiple|helpers.parse|parse',
+      },
     ],
   },
   plugins: [
     new webpack.ProvidePlugin({
       join: ['lodash', 'join'],
     }),
   ],
 };

此时,在我们的 entry 入口文件中(即 src/index.js),可以使用 const { file, parse } = require('./globals.js');,可以保证一切将顺利运行。

加载 Polyfills

目前为止我们所讨论的所有内容都是处理那些遗留的 package,让我们进入到第二个话题:polyfill

有很多方法来加载 polyfill。例如,想要引入 babel-polyfill 我们只需如下操作:

npm install --save babel-polyfill

然后,使用 import 将其引入到我们的主 bundle 文件:

src/index.js

+import 'babel-polyfill';
+
 function component() {
   const element = document.createElement('div');

   element.innerHTML = join(['Hello', 'webpack'], ' ');

   // Assume we are in the context of `window`
   this.alert("Hmmm, this probably isn't a great idea...");

   return element;
 }

 document.body.appendChild(component());

注意,这种方式优先考虑正确性,而不考虑 bundle 体积大小。为了安全和可靠,polyfill/shim 必须运行于所有其他代码之前,而且需要同步加载,或者说,需要在所有 polyfill/shim 加载之后,再去加载所有应用程序代码。 社区中存在许多误解,即现代浏览器“不需要”polyfill,或者 polyfill/shim 仅用于添加缺失功能 - 实际上,它们通常用于修复损坏实现(repair broken implementation),即使是在最现代的浏览器中,也会出现这种情况。 因此,最佳实践仍然是,不加选择地和同步地加载所有 polyfill/shim,尽管这会导致额外的 bundle 体积成本。

如果你认为自己已经打消这些顾虑,并且希望承受损坏的风险。那么接下来的这件事情,可能是你应该要做的: 我们将会把 import 放入一个新文件,并加入 whatwg-fetch polyfill:

npm install --save whatwg-fetch

src/index.js

-import 'babel-polyfill';
-
 function component() {
   const element = document.createElement('div');

   element.innerHTML = join(['Hello', 'webpack'], ' ');

   // Assume we are in the context of `window`
   this.alert("Hmmm, this probably isn't a great idea...");

   return element;
 }

 document.body.appendChild(component());

project

  webpack-demo
  |- package.json
  |- package-lock.json
  |- webpack.config.js
  |- /dist
  |- /src
    |- index.js
    |- globals.js
+   |- polyfills.js
  |- /node_modules

src/polyfills.js

import 'babel-polyfill';
import 'whatwg-fetch';

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
-  entry: './src/index.js',
+  entry: {
+    polyfills: './src/polyfills',
+    index: './src/index.js',
+  },
   output: {
-    filename: 'main.js',
+    filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: require.resolve('./src/index.js'),
         use: 'imports-loader?wrapper=window',
       },
       {
         test: require.resolve('./src/globals.js'),
         use:
           'exports-loader?type=commonjs&exports[]=file&exports[]=multiple|helpers.parse|parse',
       },
     ],
   },
   plugins: [
     new webpack.ProvidePlugin({
       join: ['lodash', 'join'],
     }),
   ],
 };

如上配置之后,我们可以在代码中添加一些逻辑,有条件地加载新的 polyfills.bundle.js 文件。根据需要支持的技术和浏览器来决定是否加载。我们将做一些简单的试验,来确定是否需要引入这些 polyfill:

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
     <title>Getting Started</title>
+    <script>
+      const modernBrowser = 'fetch' in window && 'assign' in Object;
+
+      if (!modernBrowser) {
+        const scriptElement = document.createElement('script');
+
+        scriptElement.async = false;
+        scriptElement.src = '/polyfills.bundle.js';
+        document.head.appendChild(scriptElement);
+      }
+    </script>
   </head>
   <body>
-    <script src="main.js"></script>
+    <script src="index.bundle.js"></script>
   </body>
 </html>

现在,在 entry 入口文件中,可以通过 fetch 获取一些数据:

src/index.js

 function component() {
   const element = document.createElement('div');

   element.innerHTML = join(['Hello', 'webpack'], ' ');

   // Assume we are in the context of `window`
   this.alert("Hmmm, this probably isn't a great idea...");

   return element;
 }

 document.body.appendChild(component());
+
+fetch('https://jsonplaceholder.typicode.com/users')
+  .then((response) => response.json())
+  .then((json) => {
+    console.log(
+      "We retrieved some data! AND we're confident it will work on a variety of browser distributions."
+    );
+    console.log(json);
+  })
+  .catch((error) =>
+    console.error('Something went wrong when fetching this data: ', error)
+  );

执行构建脚本,可以看到,浏览器发送了额外的 polyfills.bundle.js 文件请求,然后所有代码顺利执行。注意,以上的这些设定可能还会有所改进,这里我们向你提供一个很棒的想法:将 polyfill 提供给需要引入它的用户。

进一步优化

babel-preset-env package 通过 browserslist 来转译那些你浏览器中不支持的特性。这个 preset 使用 useBuiltIns 选项,默认值是 false,这种方式可以将全局 babel-polyfill 导入,改进为更细粒度的 import 格式:

import 'core-js/modules/es7.string.pad-start';
import 'core-js/modules/es7.string.pad-end';
import 'core-js/modules/web.timers';
import 'core-js/modules/web.immediate';
import 'core-js/modules/web.dom.iterable';

See the babel-preset-env documentation for more information.

Node 内置

process 这种 Node 内置模块,能直接根据配置文件进行正确的 polyfill,而不需要任何特定的 loader 或者 plugin。查看 node 配置页面获取更多信息。

其他工具

还有一些其他的工具,也能够帮助我们处理这些遗留模块。

如果这些遗留模块没有 AMD/CommonJS 版本,但你也想将他们加入 dist 文件,则可以使用 noParse 来标识出这个模块。这样就能使 webpack 将引入这些模块,但是不进行转化(parse),以及不解析(resolve) require()import 语句。这种用法还会提高构建性能。

最后,一些模块支持多种 模块格式,例如一个混合有 AMD、CommonJS 和 legacy(遗留) 的模块。在大多数这样的模块中,会首先检查 define,然后使用一些怪异代码导出一些属性。在这些情况下,可以通过 imports-loader 设置 additionalCode=var%20define%20=%20false; 来强制 CommonJS 路径。


TypeScript

TypeScript 是 JavaScript 的超集,为其增加了类型系统。TypeScript 可以被编译为普通的 JavaScript 代码。这篇指南将会介绍如何在 webpack 中集成 TypeScript。

基础配置

首先,执行以下命令安装 TypeScript 编译器和 loader:

npm install --save-dev typescript ts-loader

现在,我们将修改目录结构和配置文件:

project

  webpack-demo
  |- package.json
  |- package-lock.json
+ |- tsconfig.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
    |- index.js
+   |- index.ts
  |- /node_modules

tsconfig.json

这里我们添加一个基本的配置以支持 JSX,并将 TypeScript 编译到 ES5……

{
  "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "jsx": "react",
    "allowJs": true,
    "moduleResolution": "node"
  }
}

参阅 TypeScript 官方文档 了解更多关于 tsconfig.json 配置选项的信息。

参阅 配置 了解更多关于 webpack 配置的信息。

现在,配置 webpack 处理 TypeScript:

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

上面的配置将会指定 ./src/index.ts 为入口文件,并通过 ts-loader 加载所有 .ts.tsx 文件,并在当前目录 输出 一个 bundle.js 文件。

由于 lodash 没有默认导出,因此现在需要修改 lodash./index.ts 文件中的引入。

./index.ts

- import _ from 'lodash';
+ import * as _ from 'lodash';

  function component() {
    const element = document.createElement('div');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    return element;
  }

  document.body.appendChild(component());

loader

ts-loader

在本指南中,我们使用 ts-loader,因为它能够很方便地启用额外的 webpack 功能,例如将其他 web 资源导入到项目中。

请注意,如果已经使用 babel-loader 转译代码,可以使用 @babel/preset-typescript 以让 Babel 处理 JavaScript 和 TypeScript 文件,而不需要额外使用 loader。请记住,与 ts-loader 相反,底层的 @babel/plugin-transform-typescript 插件不执行任何类型检查。

source map

参阅 开发环境 指南了解更多关于 source map 的信息。

我们需要对 TypeScript 进行配置以启用 source map,从而实现将内联的 source map 输出到编译后的 JavaScript 文件。必须在 TypeScript 配置中添加下面这行:

tsconfig.json

  {
    "compilerOptions": {
      "outDir": "./dist/",
+     "sourceMap": true,
      "noImplicitAny": true,
      "module": "commonjs",
      "target": "es5",
      "jsx": "react",
      "allowJs": true,
      "moduleResolution": "node",
    }
  }

现在,我们需要告诉 webpack 提取这些 source map,并内联到最终的 bundle 中。

webpack.config.js

  const path = require('path');

  module.exports = {
    entry: './src/index.ts',
+   devtool: 'inline-source-map',
    module: {
      rules: [
        {
          test: /\.tsx?$/,
          use: 'ts-loader',
          exclude: /node_modules/,
        },
      ],
    },
    resolve: {
      extensions: [ '.tsx', '.ts', '.js' ],
    },
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
  };

查看 devtool 文档以了解更多信息。

客户端类型

你可以在 TypeScript 代码中使用 webpack 特定的特性,比如 import.meta.webpack。只需要添加 TypeScript reference 声明,webpack 便会为它们提供类型支持:

/// <reference types="webpack/module" />
console.log(import.meta.webpack); // 没有上面的声明的话,TypeScript 会抛出一个错误

使用第三方库

在从 npm 安装第三方库时,一定要记得同时安装此库的类型声明文件。你可以从 TypeSearch 中找到并安装这些第三方库的类型声明文件。

举个例子,如果想安装 lodash 类型声明文件,可以运行下面的命令:

npm install --save-dev @types/lodash

参与 这篇文章 以了解更多。

导入其他资源

想要在 TypeScript 中使用非代码资源,需要告诉 TypeScript 推断导入资源的类型。在项目里创建一个 custom.d.ts 文件,这个文件用来表示项目中 TypeScript 的自定义类型声明。我们为 .svg 文件设置一个声明:

custom.d.ts

declare module '*.svg' {
  const content: any;
  export default content;
}

在这里我们通过指定任何以 .svg 结尾的导入,将 SVG 声明为一个新的模块,并将模块的 content 定义为 any。我们可以通过将类型定义为字符串,来更加显式地将它声明为一个 url。同样的概念适用于其他资源,包括 CSS,SCSS,以及 JSON 等。

构建性能

参阅 构建性能 以了解更多关于构建性能的信息。

Web Workers

从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader

语法

new Worker(new URL('./worker.js', import.meta.url));

选择这种语法是为了实现不使用 bundler 就可以运行代码,它也可以在浏览器中的原生 ECMAScript 模块中使用。

请注意,虽然 Worker API 建议 Worker 构造函数接受表示 URL 的字符串脚本,在 webpack 5 中你只能使用 URL 代替。

示例

src/index.js

const worker = new Worker(new URL('./deep-thought.js', import.meta.url));
worker.postMessage({
  question:
    'The Answer to the Ultimate Question of Life, The Universe, and Everything.',
});
worker.onmessage = ({ data: { answer } }) => {
  console.log(answer);
};

src/deep-thought.js

self.onmessage = ({ data: { question } }) => {
  self.postMessage({
    answer: 42,
  });
};

Node.js

Node.js(>= 12.17.0) 也支持类似的语法:

import { Worker } from 'worker_threads';

new Worker(new URL('./worker.js', import.meta.url));

请注意,这仅在 ESM 中可用。但不可用于 CommonJS,无论 webpack 还是 Node.js 均是如此。

渐进式网络应用程序

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

本章将重点介绍,如何为我们的应用程序添加离线体验。我们将使用名为 Workbox 的 Google 项目来实现此目的,该项目提供的工具可帮助我们更简单地为 web app 提供离线支持。

现在,我们并没有运行在离线环境下

到目前为止,我们一直是直接查看本地文件系统的输出结果。通常情况下,真正的用户是通过网络访问 web app;用户的浏览器会与一个提供所需资源(例如,.html, .js.css 文件)的 server 通讯。

我们通过搭建一个拥有更多基础特性的 server 来测试下这种离线体验。这里使用 http-server package:npm install http-server --save-dev。还要修改 package.jsonscripts 部分,来添加一个 start script:

package.json

{
  ...
  "scripts": {
-    "build": "webpack"
+    "build": "webpack",
+    "start": "http-server dist"
  },
  ...
}

注意:默认情况下,webpack DevServer 会写入到内存。我们需要启用 devserverdevmiddleware.writeToDisk 配置项,来让 http-server 处理 ./dist 目录中的文件。

如果你之前没有操作过,先得运行命令 npm run build 来构建你的项目。然后运行命令 npm start。应该产生以下输出:

> http-server dist

Starting up http-server, serving dist
Available on:
  http://xx.x.x.x:8080
  http://127.0.0.1:8080
  http://xxx.xxx.x.x:8080
Hit CTRL-C to stop the server

如果你打开浏览器访问 http://localhost:8080 (即 http://127.0.0.1),你应该会看到 webpack 应用程序被 serve 到 dist 目录。如果停止 server 然后刷新,则 webpack 应用程序不再可访问。

这就是我们为实现离线体验所需要的改变。在本章结束时,我们应该要实现的是,停止 server 然后刷新,仍然可以看到应用程序正常运行。

添加 Workbox

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

npm install workbox-webpack-plugin --save-dev

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
+ const WorkboxPlugin = require('workbox-webpack-plugin');

  module.exports = {
    entry: {
      app: './src/index.js',
      print: './src/print.js',
    },
    plugins: [
      new HtmlWebpackPlugin({
-       title: 'Output Management',
+       title: 'Progressive Web Application',
      }),
+     new WorkboxPlugin.GenerateSW({
+       // 这些选项帮助快速启用 ServiceWorkers
+       // 不允许遗留任何“旧的” ServiceWorkers
+       clientsClaim: true,
+       skipWaiting: true,
+     }),
    ],
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

完成这些设置,再次执行 npm run build,看下会发生什么:

...
                  Asset       Size  Chunks                    Chunk Names
          app.bundle.js     545 kB    0, 1  [emitted]  [big]  app
        print.bundle.js    2.74 kB       1  [emitted]         print
             index.html  254 bytes          [emitted]
precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.js  268 bytes          [emitted]
      service-worker.js       1 kB          [emitted]
...

现在你可以看到,生成了两个额外的文件:service-worker.js 和名称冗长的 precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.jsservice-worker.js 是 Service Worker 文件,precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.jsservice-worker.js 引用的文件,所以它也可以运行。你本地生成的文件可能会有所不同,但是应该会有一个 service-worker.js 文件。

所以,值得高兴的是,我们现在已经创建出一个 Service Worker。接下来该做什么?

注册 Service Worker

接下来我们注册 Service Worker,使其出场并开始表演。通过添加以下注册代码来完成此操作:

index.js

  import _ from 'lodash';
  import printMe from './print.js';

+ 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://localhost:8080 并查看 console 控制台。在那里你应该看到:

SW registered

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

结论

你已经使用 Workbox 项目构建了一个离线应用程序。开始进入将 web app 改造为 PWA 的旅程。你现在可能想要考虑下一步做什么。这里是可以帮助到你解决下一步问题的比较不错的资源。

公共路径

publicPath 配置选项在各种场景中都非常有用。你可以通过它来指定应用程序中所有资源的基础路径。

示例

下面提供一些用于实际应用程序的示例,通过这些示例,此功能显得极其简单。实质上,发送到 output.path 目录的每个文件,都将从 output.publicPath 位置引用。这也包括(通过 代码分离 创建的)子 chunk 和作为依赖图一部分的所有其他资源(例如 image, font 等)。

基于环境设置

在开发环境中,我们通常有一个 assets/ 文件夹,它与索引页面位于同一级别。这没太大问题,但是,如果我们将所有静态资源托管至 CDN,然后想在生产环境中使用呢?

想要解决这个问题,可以直接使用一个有着悠久历史的 environment variable(环境变量)。假设我们有一个变量 ASSET_PATH

import webpack from 'webpack';

// 尝试使用环境变量,否则使用根路径
const ASSET_PATH = process.env.ASSET_PATH || '/';

export default {
  output: {
    publicPath: ASSET_PATH,
  },

  plugins: [
    // 这可以帮助我们在代码中安全地使用环境变量
    new webpack.DefinePlugin({
      'process.env.ASSET_PATH': JSON.stringify(ASSET_PATH),
    }),
  ],
};

在运行时设置

另一个可能出现的情况是,需要在运行时设置 publicPath。webpack 暴露了一个名为 __webpack_public_path__ 的全局变量。所以在应用程序的 entry point 中,可以直接如下设置:

__webpack_public_path__ = process.env.ASSET_PATH;

这些内容就是你所需要的。由于我们已经在配置中使用了 DefinePluginprocess.env.ASSET_PATH 将始终都被定义, 因此我们可以安全地使用。

// entry.js
import './public-path';
import './app';

Automatic publicPath $#automaticpublicPath$

有可能你事先不知道 publicPath 是什么,webpack 会自动根据 import.meta.urldocument.currentScriptscript.src 或者 self.location 变量设置 publicPath。你需要做的是将 output.publicPath 设为 'auto'

webpack.config.js

module.exports = {
  output: {
    publicPath: 'auto',
  },
};

请注意在某些情况下不支持 document.currentScript,例如:IE 浏览器,你不得不引入一个 polyfill,例如 currentScript Polyfill

集成

首先,我们要消除一个常见的误解。webpack 是一个模块打包工具(module bundler)(例如,BrowserifyBrunch)。而 不是一个任务执行工具(task runner)(例如,Make, Grunt 或者 Gulp )。任务执行工具用来自动化处理常见的开发任务,例如,lint(代码检测)、build(构建)、test(测试)。相比模块打包工具,任务执行工具则聚焦在偏重上层的问题上面。你仍然可以得益于这种用法:使用上层的工具,而将打包部分的问题留给 webpack。

打包工具帮助你取得准备用于部署的 JavaScript 和 stylesheet,将它们转换为适合浏览器的可用格式。例如,可以通过 压缩分离 chunk惰性加载 我们的 JavaScript 来提高性能。打包是 web 开发中最重要的挑战之一,解决此问题可以消除开发过程中的大部分痛点。

好的消息是,虽然有一些功能重叠,但是如果使用方式正确,任务运行工具和模块打包工具还是能够一起协同工作。本指南提供了关于如何将 webpack 与一些流行的任务运行工具集成在一起的高度概述。

NPM Scripts

通常 webpack 用户使用 npm scripts 来作为任务执行工具。这是比较好的开始。然而跨平台支持可能是个问题,但是有几种解决方案。许多用户(但不是大多数用户)直接使用 npm scripts 和各种级别的 webpack 配置和工具。

因此,虽然 webpack 核心重点是打包,但是可以通过各种扩展,将它用于任务运行工具的常见工作。集成一个单独的工具会增加复杂度,因此在开始前一定要权衡利弊。

Grunt

对于那些使用 Grunt 的人,我们推荐使用 grunt-webpack package。使用 grunt-webpack 你可以将 webpack 或 webpack-dev-server 作为一项任务(task)执行,访问 grunt template tags 中的统计信息,拆分开发和生产配置等等。如果还没有安装 grunt-webpackwebpack,请先安装它们:

npm install --save-dev grunt-webpack webpack

然后,注册一个配置并加载任务:

Gruntfile.js

const webpackConfig = require('./webpack.config.js');

module.exports = function (grunt) {
  grunt.initConfig({
    webpack: {
      options: {
        stats: !process.env.NODE_ENV || process.env.NODE_ENV === 'development',
      },
      prod: webpackConfig,
      dev: Object.assign({ watch: true }, webpackConfig),
    },
  });

  grunt.loadNpmTasks('grunt-webpack');
};

获取更多信息,请查看 仓库

Gulp

webpack-stream package(也称作 gulp-webpack) 的帮助下,可以相当直接地将 Gulp 与 webpack 集成。在这种情况下,不需要单独安装 webpack,因为它是 webpack-stream 直接依赖:

npm install --save-dev webpack-stream

只要将 webpack 替换为 require('webpack-stream'),并传递一个配置:

gulpfile.js

const gulp = require('gulp');
const webpack = require('webpack-stream');
gulp.task('default', function () {
  return gulp
    .src('src/entry.js')
    .pipe(
      webpack({
        // Any configuration options...
      })
    )
    .pipe(gulp.dest('dist/'));
});

获取更多信息,请查看 仓库

Mocha

mocha-webpack 可以将 Mocha 与 webpack 完全集成。这个仓库提供了很多关于其优势和劣势的细节,基本上 mocha-webpack 只是一个简单封装,提供与 Mocha 几乎相同的 CLI,并提供各种 webpack 功能,例如改进了 watch mode 和改进了路径分析。这里是一个如何安装并使用它来运行测试套件的示例(在 ./test 中找到):

npm install --save-dev webpack mocha mocha-webpack
mocha-webpack 'test/**/*.js'

获取更多信息,请查看 仓库

Karma

karma-webpack package 允许你使用 webpack 预处理 Karma 中的文件。

npm install --save-dev webpack karma karma-webpack

karma.conf.js

module.exports = function (config) {
  config.set({
    frameworks: ['webpack'],
    files: [
      { pattern: 'test/*_test.js', watched: false },
      { pattern: 'test/**/*_test.js', watched: false },
    ],
    preprocessors: {
      'test/*_test.js': ['webpack'],
      'test/**/*_test.js': ['webpack'],
    },
    webpack: {
      // Any custom webpack configuration...
    },
    plugins: ['karma-webpack'],
  });
};

获取更多信息,请查看 仓库

Package exports

The exports field in the package.json of a package allows to declare which module should be used when using module requests like import "package" or import "package/sub/path". It replaces the default implementation that returns main field resp. index.js files for "package" and the file system lookup for "package/sub/path".

When the exports field is specified, only these module requests are available. Any other requests will lead to a ModuleNotFound Error.

General syntax

In general the exports field should contain an object where each properties specifies a sub path of the module request. For the examples above the following properties could be used: "." for import "package" and "./sub/path" for import "package/sub/path". Properties ending with a / will forward a request with this prefix to the old file system lookup algorithm. For properties ending with *, * may take any value and any * in the property value is replaced with the taken value.

An example:

{
  "exports": {
    ".": "./main.js",
    "./sub/path": "./secondary.js",
    "./prefix/": "./directory/",
    "./prefix/deep/": "./other-directory/",
    "./other-prefix/*": "./yet-another/*/*.js"
  }
}
Module requestResult
package.../package/main.js
package/sub/path.../package/secondary.js
package/prefix/some/file.js.../package/directory/some/file.js
package/prefix/deep/file.js.../package/other-directory/file.js
package/other-prefix/deep/file.js.../package/yet-another/deep/file/deep/file.js
package/main.jsError

Alternatives

Instead of providing a single result, the package author may provide a list of results. In such a scenario this list is tried in order and the first valid result will be used.

Note: Only the first valid result will be used, not all valid results.

Example:

{
  "exports": {
    "./things/": ["./good-things/", "./bad-things/"]
  }
}

Here package/things/apple might be found in .../package/good-things/apple or in .../package/bad-things/apple.

Conditional syntax

Instead of providing results directly in the exports field, the package author may let the module system choose one based on conditions about the environment.

In this case an object mapping conditions to results should be used. Conditions are tried in object order. Conditions that contain invalid results are skipped. Conditions might be nested to create a logical AND. The last condition in the object might be the special "default" condition, which is always matched.

Example:

{
  "exports": {
    ".": {
      "red": "./stop.js",
      "yellow": "./stop.js",
      "green": {
        "free": "./drive.js",
        "default": "./wait.js"
      },
      "default": "./drive-carefully.js"
    }
  }
}

This translates to something like:

if (red && valid('./stop.js')) return './stop.js';
if (yellow && valid('./stop.js')) return './stop.js';
if (green) {
  if (free && valid('./drive.js')) return './drive.js';
  if (valid('./wait.js')) return './wait.js';
}
if (valid('./drive-carefully.js')) return './drive-carefully.js';
throw new ModuleNotFoundError();

The available conditions vary depending on the module system and tool used.

Abbreviation

When only a single entry (".") into the package should be supported the { ".": ... } object nesting can be omitted:

{
  "exports": "./index.mjs"
}
{
  "exports": {
    "red": "./stop.js",
    "green": "./drive.js"
  }
}

Notes about ordering

In an object where each key is a condition, order of properties is significant. Conditions are handled in the order they are specified.

Example: { "red": "./stop.js", "green": "./drive.js" } != { "green": "./drive.js", "red": "./stop.js" } (when both red and green conditions are set, first property will be used)

In an object where each key is a subpath, order of properties (subpaths) is not significant. More specific paths are preferred over less specific ones.

Example: { "./a/": "./x/", "./a/b/": "./y/", "./a/b/c": "./z" } == { "./a/b/c": "./z", "./a/b/": "./y/", "./a/": "./x/" } (order will always be: ./a/b/c > ./a/b/ > ./a/)

exports field is preferred over other package entry fields like main, module, browser or custom ones.

Support

FeatureSupported by
"." propertyNode.js, webpack, rollup, esinstall, wmr
normal propertyNode.js, webpack, rollup, esinstall, wmr
property ending with /Node.js(1), webpack, rollup, esinstall(2), wmr(3)
property ending with *Node.js, webpack, rollup, esinstall
AlternativesNode.js, webpack, rollup, esinstall(4)
Abbreviation only pathNode.js, webpack, rollup, esinstall, wmr
Abbreviation only conditionsNode.js, webpack, rollup, esinstall, wmr
Conditional syntaxNode.js, webpack, rollup, esinstall, wmr
Nested conditional syntaxNode.js, webpack, rollup, wmr(5)
Conditions OrderNode.js, webpack, rollup, wmr(6)
"default" conditionNode.js, webpack, rollup, esinstall, wmr
Path OrderNode.js, webpack, rollup
Error when not mappedNode.js, webpack, rollup, esinstall, wmr(7)
Error when mixing conditions and pathsNode.js, webpack, rollup

(1) deprecated in Node.js, * should be preferred.

(2) "./" is intentionally ignored as key.

(3) The property value is ignored and property key is used as target. Effectively only allowing mappings with key and value are identical.

(4) The syntax is supported, but always the first entry is used, which makes it unusable for any practical use case.

(5) Fallback to alternative sibling parent conditions is handling incorrectly.

(6) For the require condition object order is handled incorrectly. This is intentionally as wmr doesn't differ between referencing syntax.

(7) When using "exports": "./file.js" abbreviation, any request e. g. package/not-existing will resolve to that. When not using the abbreviation, direct file access e. g. package/file.js will not lead to an error.

Conditions

Reference syntax

One of these conditions is set depending on the syntax used to reference the module:

ConditionDescriptionSupported by
importRequest is issued from ESM syntax or similar.Node.js, webpack, rollup, esinstall(1), wmr(1)
requireRequest is issued from CommonJs/AMD syntax or similar.Node.js, webpack, rollup, esinstall(1), wmr(1)
styleRequest is issued from a stylesheet reference.
sassRequest is issued from a sass stylesheet reference.
assetRequest is issued from a asset reference.
scriptRequest is issued from a normal script tag without module system.

These conditions might also be set additionally:

ConditionDescriptionSupported by
moduleAll module syntax that allows to reference javascript supports ESM.
(only combined with import or require)
webpack, rollup, wmr
esmodulesAlways set by supported tools.wmr
typesRequest is issued from typescript that is interested in type declarations.

(1) import and require are both set independent of referencing syntax. require has always lower priority.

import

The following syntax will set the import condition:

  • ESM import declarations in ESM
  • JS import() expression
  • HTML <script type="module"> in HTML
  • HTML <link rel="preload/prefetch"> in HTML
  • JS new Worker(..., { type: "module" })
  • WASM import section
  • ESM HMR (webpack) import.hot.accept/decline([...])
  • JS Worklet.addModule
  • Using javascript as entrypoint

require

The following syntax will set the require condition:

  • CommonJs require(...)
  • AMD define()
  • AMD require([...])
  • CommonJs require.resolve()
  • CommonJs (webpack) require.ensure([...])
  • CommonJs (webpack) require.context
  • CommonJs HMR (webpack) module.hot.accept/decline([...])
  • HTML <script src="...">

style

The following syntax will set the style condition:

  • CSS @import
  • HTML <link rel="stylesheet">

asset

The following syntax will set the asset condition:

  • CSS url()
  • ESM new URL(..., import.meta.url)
  • HTML <img src="...">

script

The following syntax will set the script condition:

  • HTML <script src="...">

script should only be set when no module system is supported. When the script is preprocessed by a system supporting CommonJs it should set require instead.

This condition should be used when looking for a javascript file that can be injected as script tag in a HTML page without additional preprocessing.

Optimizations

The following conditions are set for various optimizations:

ConditionDescriptionSupported by
productionIn a production environment.
No devtooling should be included.
webpack
developmentIn a development environment.
Devtooling should be included.
webpack

Note: Since production and development is not supported by everyone, no assumption should be made when none of these is set.

Target environment

The following conditions are set depending on the target environment:

ConditionDescriptionSupported by
browserCode will run in a browser.webpack, esinstall, wmr
electronCode will run in electron.(1)webpack
workerCode will run in a (Web)Worker.(1)webpack
workletCode will run in a Worklet.(1)-
nodeCode will run in Node.js.Node.js, webpack, wmr(2)
denoCode will run in Deno.-
react-nativeCode will run in react-native.-

(1) electron, worker and worklet comes combined with either node or browser, depending on the context.

(2) This is set for browser target environment.

Since there are multiple versions of each environment the following guidelines apply:

  • node: See engines field for compatibility.
  • browser: Compatible with current Spec and stage 4 proposals at time of publishing the package. Polyfilling resp. transpiling must be handled on consumer side.
    • Features that are not possible to polyfill or transpile should be used carefully as it limits the possible usage.
  • deno: TBD
  • react-native: TBD

Conditions: Preprocessor and runtimes

The following conditions are set depending on which tool preprocesses the source code.

ConditionDescriptionSupported by
webpackProcessed by webpack.webpack

Sadly there is no node-js condition for Node.js as runtime. This would simplify creating exceptions for Node.js.

Conditions: Custom

The following tools support custom conditions:

ToolSupportedNotes
Node.jsyesUse --conditions CLI argument.
webpackyesUse resolve.conditionNames configuration option.
rollupyesUse exportConditions option for @rollup/plugin-node-resolve
esinstallno
wmrno

For custom conditions the following naming schema is recommended:

<company-name>:<condition-name>

Examples: example-corp:beta, google:internal.

Common patterns

All patterns are explained with a single "." entry into the package, but they can be extended from multiple entries too, by repeating the pattern for each entry.

These pattern should be used as guide not as strict ruleset. They can be adapted to the individual packages.

These pattern are based on the following list of goals/assumptions:

  • Packages are rotting.
    • We assume at some point packages are no longer being maintained, but they are continued to be used.
    • exports should be written to use fallbacks for unknown future cases. default condition can be used for that.
    • As the future is unknown we assume an environment similar to browsers and module system similar to ESM.
  • Not all conditions are supported by every tool.
    • Fallbacks should be used to handled these cases.
    • We assume the following fallback make sense in general:
      • ESM > CommonJs
      • Production > Development
      • Browser > node.js

Depending on the package intention maybe something else makes sense and in this case the patterns should be adopted to that. Example: For a command line tool a browser-like future and fallback doesn't make a lot of sense, and in this case node.js-like environments and fallbacks should be used instead.

For complex use cases multiple patterns need to be combined by nesting these conditions.

Target environment independent packages

These patterns make sense for packages that do not use environment specific APIs.

Providing only an ESM version

{
  "type": "module",
  "exports": "./index.js"
}

Note: Providing only a ESM comes with restrictions for node.js. Such a package would only work in Node.js >= 14 and only when using import. It won't work with require().

Providing CommonJs and ESM version (stateless)

{
  "type": "module",
  "exports": {
    "node": {
      "module": "./index.js",
      "require": "./index.cjs"
    },
    "default": "./index.js"
  }
}

Most tools get the ESM version. Node.js is an exception here. It gets a CommonJs version when using require(). This will lead to two instances of these package when referencing it with require() and import, but that doesn't hurt as the package doesn't have state.

The module condition is used as optimization when preprocessing node-targeted code with a tool that supports ESM for require() (like a bundler, when bundling for Node.js). For such a tool the exception is skipped. This is technically optional, but bundlers would include the package source code twice otherwise.

You can also use the stateless pattern if you are able to isolate your package state in JSON files. JSON is consumable from CommonJs and ESM without polluting the graph with the other module system.

Note that here stateless also means class instances are not tested with instanceof as there can be two different classes because of the double module instantiation.

Providing CommonJs and ESM version (stateful)

{
  "type": "module",
  "exports": {
    "node": {
      "module": "./index.js",
      "import": "./wrapper.js",
      "require": "./index.cjs"
    },
    "default": "./index.js"
  }
}
// wrapper.js
import cjs from './index.cjs';

export const A = cjs.A;
export const B = cjs.B;

In a stateful package we must ensure that the package is never instantiated twice.

This isn't a problem for most tools, but Node.js is again an exception here. For Node.js we always use the CommonJs version and expose named exports in the ESM with a ESM wrapper.

We use the module condition as optimization again.

Providing only a CommonJs version

{
  "type": "commonjs",
  "exports": "./index.js"
}

Providing "type": "commonjs" helps to statically detect CommonJs files.

Providing a bundled script version for direct browser consumption

{
  "type": "module",
  "exports": {
    "script": "./dist-bundle.js",
    "default": "./index.js"
  }
}

Note that despite using "type": "module" and .js for dist-bundle.js this file is not in ESM format. It should use globals to allow direct consumption as script tag.

Providing devtools or production optimizations

These patterns make sense when a package contains two versions, one for development and one for production. E. g. the development version could include additional code for better error message or additional warnings.

Without Node.js runtime detection

{
  "type": "module",
  "exports": {
    "development": "./index-with-devtools.js",
    "default": "./index-optimized.js"
  }
}

When the development condition is supported we use the version enhanced for development. Otherwise, in production or when mode is unknown, we use the optimized version.

With Node.js runtime detection

{
  "type": "module",
  "exports": {
    "development": "./index-with-devtools.js",
    "production": "./index-optimized.js",
    "node": "./wrapper-process-env.cjs",
    "default": "./index-optimized.js"
  }
}
// wrapper-process-env.cjs
if (process.env.NODE_ENV !== 'development') {
  module.exports = require('./index-optimized.cjs');
} else {
  module.exports = require('./index-with-devtools.cjs');
}

We prefer static detection of production/development mode via the production or development condition.

Node.js allows to detection production/development mode at runtime via process.env.NODE_ENV, so we use that as fallback in Node.js. Sync conditional importing ESM is not possible and we don't want to load the package twice, so we have to use CommonJs for the runtime detection.

When it's not possible to detect mode we fallback to the production version.

Providing different versions depending on target environment

A fallback environment should be chosen that makes sense for the package to support future environments. In general a browser-like environment should be assumed.

Providing Node.js, WebWorker and browser versions

{
  "type": "module",
  "exports": {
    "node": "./index-node.js",
    "worker": "./index-worker.js",
    "default": "./index.js"
  }
}

Providing Node.js, browser and electron versions

{
  "type": "module",
  "exports": {
    "electron": {
      "node": "./index-electron-node.js",
      "default": "./index-electron.js"
    },
    "node": "./index-node.js",
    "default": "./index.js"
  }
}

Combining patterns

Example 1

This is an example for a package that has optimizations for production and development usage with runtime detection for process.env and also ships a CommonJs and ESM version

{
  "type": "module",
  "exports": {
    "node": {
      "development": {
        "module": "./index-with-devtools.js",
        "import": "./wrapper-with-devtools.js",
        "require": "./index-with-devtools.cjs"
      },
      "production": {
        "module": "./index-optimized.js",
        "import": "./wrapper-optimized.js",
        "require": "./index-optimized.cjs"
      },
      "default": "./wrapper-process-env.cjs"
    },
    "development": "./index-with-devtools.js",
    "production": "./index-optimized.js",
    "default": "./index-optimized.js"
  }
}

Example 2

This is an example for a package that supports Node.js, browser and electron, has optimizations for production and development usage with runtime detection for process.env and also ships a CommonJs and ESM version.

{
  "type": "module",
  "exports": {
    "electron": {
      "node": {
        "development": {
          "module": "./index-electron-node-with-devtools.js",
          "import": "./wrapper-electron-node-with-devtools.js",
          "require": "./index-electron-node-with-devtools.cjs"
        },
        "production": {
          "module": "./index-electron-node-optimized.js",
          "import": "./wrapper-electron-node-optimized.js",
          "require": "./index-electron-node-optimized.cjs"
        },
        "default": "./wrapper-electron-node-process-env.cjs"
      },
      "development": "./index-electron-with-devtools.js",
      "production": "./index-electron-optimized.js",
      "default": "./index-electron-optimized.js"
    },
    "node": {
      "development": {
        "module": "./index-node-with-devtools.js",
        "import": "./wrapper-node-with-devtools.js",
        "require": "./index-node-with-devtools.cjs"
      },
      "production": {
        "module": "./index-node-optimized.js",
        "import": "./wrapper-node-optimized.js",
        "require": "./index-node-optimized.cjs"
      },
      "default": "./wrapper-node-process-env.cjs"
    },
    "development": "./index-with-devtools.js",
    "production": "./index-optimized.js",
    "default": "./index-optimized.js"
  }
}

Looks complex, yes. We were already able to reduce some complexity due to a assumption we can make: Only node need a CommonJs version and can detect production/development with process.env.

Guidelines

  • Avoid the default export. It's handled differently between tooling. Only use named exports.
  • Never provide different APIs or semantics for different conditions.
  • Write your source code as ESM and transpile to CJS via babel, typescript or similar tools.
  • Either use .cjs or type: "commonjs" in package.json to clearly mark source code as CommonJs. This makes it statically detectable for tools if CommonJs or ESM is used. This is important for tools that only support ESM and no CommonJs.
  • ESM used in packages support the following types of requests:
    • module requests are supported, pointing to other packages with a package.json.
    • relative requests are supported, pointing to other files within the package.
      • They must not point to files outside of the package.
    • data: url requests are supported.
    • other absolute or server-relative requests are not supported by default, but they might be supported by some tools or environments.

entry 高级用法

每个入口使用多种文件类型

在不使用 import 样式文件的应用程序中(预单页应用程序或其他原因),使用一个值数组结构的 entry,并且在其中传入不同类型的文件,可以实现将 CSS 和 JavaScript(和其他)文件分离在不同的 bundle。

举个例子。我们有一个具有两种页面类型的 PHP 应用程序:home(首页) 和 account(帐户)。home 与应用程序其余部分(account 页面)具有不同的布局和不可共享的 JavaScript。我们想要从应用程序文件中输出 home 页面的 home.jshome.css,为 account 页面输出 account.jsaccount.css

home.js

console.log('home page type');

home.scss

// home page individual styles

account.js

console.log('account page type');

account.scss

// account page individual styles

我们将在 production(生产) 模式中使用 MiniCssExtractPlugin 作为 CSS 的一个最佳实践。

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: process.env.NODE_ENV,
  entry: {
    home: ['./home.js', './home.scss'],
    account: ['./account.js', './account.scss'],
  },
  output: {
    filename: '[name].js',
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          // fallback to style-loader in development
          process.env.NODE_ENV !== 'production'
            ? 'style-loader'
            : MiniCssExtractPlugin.loader,
          'css-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
};

由于我们未指定其他输出路径,因此使用以上配置运行 webpack 将输出到 ./dist./dist 目录下现在包含四个文件:

  • home.js
  • home.css
  • account.js
  • account.css

资源模块

资源模块(asset module)是一种模块类型,它允许使用资源文件(字体,图标等)而无需配置额外 loader。

在 webpack 5 之前,通常使用:

资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader:

  • asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
  • asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
  • asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
  • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。

当在 webpack 5 中使用旧的 assets loader(如 file-loader/url-loader/raw-loader 等)和 asset 模块时,你可能想停止当前 asset 模块的处理,并再次启动处理,这可能会导致 asset 重复,你可以通过将 asset 模块的类型设置为 'javascript/auto' 来解决。

webpack.config.js

module.exports = {
  module: {
   rules: [
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            }
          },
        ],
+       type: 'javascript/auto'
      },
   ]
  },
}

如需从 asset loader 中排除来自新 URL 处理的 asset,请添加 dependency: { not: ['url'] } 到 loader 配置中。

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/i,
+       dependency: { not: ['url'] },
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },
    ],
  }
}

Resource 资源

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
+ module: {
+   rules: [
+     {
+       test: /\.png/,
+       type: 'asset/resource'
+     }
+   ]
+ },
};

src/index.js

import mainImage from './images/main.png';

img.src = mainImage; // '/dist/151cfcfa1bd74779aadb.png'

所有 .png 文件都将被发送到输出目录,并且其路径将被注入到 bundle 中,除此之外,你可以为它们自定义 outputPathpublicPath 属性。

自定义输出文件名

默认情况下,asset/resource 模块以 [hash][ext][query] 文件名发送到输出目录。

可以通过在 webpack 配置中设置 output.assetModuleFilename 来修改此模板字符串:

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
+   assetModuleFilename: 'images/[hash][ext][query]'
  },
  module: {
    rules: [
      {
        test: /\.png/,
        type: 'asset/resource'
      }
    ]
  },
};

另一种自定义输出文件名的方式是,将某些资源发送到指定目录:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
+   assetModuleFilename: 'images/[hash][ext][query]'
  },
  module: {
    rules: [
      {
        test: /\.png/,
        type: 'asset/resource'
-     }
+     },
+     {
+       test: /\.html/,
+       type: 'asset/resource',
+       generator: {
+         filename: 'static/[hash][ext][query]'
+       }
+     }
    ]
  },
};

使用此配置,所有 html 文件都将被发送到输出目录中的 static 目录中。

Rule.generator.filenameoutput.assetModuleFilename 相同,并且仅适用于 assetasset/resource 模块类型。

inline 资源(inlining asset)

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
-   assetModuleFilename: 'images/[hash][ext][query]'
  },
  module: {
    rules: [
      {
-       test: /\.png/,
-       type: 'asset/resource'
+       test: /\.svg/,
+       type: 'asset/inline'
-     },
+     }
-     {
-       test: /\.html/,
-       type: 'asset/resource',
-       generator: {
-         filename: 'static/[hash][ext][query]'
-       }
-     }
    ]
  }
};

src/index.js

- import mainImage from './images/main.png';
+ import metroMap from './images/metro.svg';

- img.src = mainImage; // '/dist/151cfcfa1bd74779aadb.png'
+ block.style.background = `url(${metroMap})`; // url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDo...vc3ZnPgo=)

所有 .svg 文件都将作为 data URI 注入到 bundle 中。

自定义 data URI 生成器

webpack 输出的 data URI,默认是呈现为使用 Base64 算法编码的文件内容。

如果要使用自定义编码算法,则可以指定一个自定义函数来编码文件内容:

webpack.config.js

const path = require('path');
+ const svgToMiniDataURI = require('mini-svg-data-uri');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.svg/,
        type: 'asset/inline',
+       generator: {
+         dataUrl: content => {
+           content = content.toString();
+           return svgToMiniDataURI(content);
+         }
+       }
      }
    ]
  },
};

现在,所有 .svg 文件都将通过 mini-svg-data-uri 包进行编码。

source 资源(source asset)

webpack.config.js

const path = require('path');
- const svgToMiniDataURI = require('mini-svg-data-uri');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
-       test: /\.svg/,
-       type: 'asset/inline',
-       generator: {
-         dataUrl: content => {
-           content = content.toString();
-           return svgToMiniDataURI(content);
-         }
-       }
+       test: /\.txt/,
+       type: 'asset/source',
      }
    ]
  },
};

src/example.txt

Hello world

src/index.js

- import metroMap from './images/metro.svg';
+ import exampleText from './example.txt';

- block.style.background = `url(${metroMap}); // url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDo...vc3ZnPgo=)
+ block.textContent = exampleText; // 'Hello world'

所有 .txt 文件将原样注入到 bundle 中。

URL 资源

当使用 new URL('./path/to/asset', import.meta.url),webpack 也会创建资源模块。

src/index.js

const logo = new URL('./logo.svg', import.meta.url);

根据你配置中 target 的不同,webpack 会将上述代码编译成不同结果:

// target: web
new URL(
  __webpack_public_path__ + 'logo.svg',
  document.baseURI || self.location.href
);

// target: webworker
new URL(__webpack_public_path__ + 'logo.svg', self.location);

// target: node, node-webkit, nwjs, electron-main, electron-renderer, electron-preload, async-node
new URL(
  __webpack_public_path__ + 'logo.svg',
  require('url').pathToFileUrl(__filename)
);

自 webpack 5.38.0 起,Data URLs 也支持在 new URL() 中使用了:

src/index.js

const url = new URL('data:,', import.meta.url);
console.log(url.href === 'data:,');
console.log(url.protocol === 'data:');
console.log(url.pathname === ',');

通用资源类型

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
+       test: /\.txt/,
+       type: 'asset',
      }
    ]
  },
};

现在,webpack 将按照默认条件,自动地在 resourceinline 之间进行选择:小于 8kb 的文件,将会视为 inline 模块类型,否则会被视为 resource 模块类型。

可以通过在 webpack 配置的 module rule 层级中,设置 Rule.parser.dataUrlCondition.maxSize 选项来修改此条件:

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.txt/,
        type: 'asset',
+       parser: {
+         dataUrlCondition: {
+           maxSize: 4 * 1024 // 4kb
+         }
+       }
      }
    ]
  },
};

还可以 指定一个函数 来决定是否 inline 模块。

变更内联 loader 的语法

在 asset 模块和 webpack 5 之前,可以使用内联语法与上述传统的 loader 结合使用。

现在建议去掉所有的内联 loader 的语法,使用资源查询条件来模仿内联语法的功能。

示例,将 raw-loader 替换为 asset/source 类型:

- import myModule from 'raw-loader!my-module';
+ import myModule from 'my-module?raw';

webpack 相关配置:

module: {
    rules: [
    // ...
+     {
+       resourceQuery: /raw/,
+       type: 'asset/source',
+     }
    ]
  },

如果你想把原始资源排除在其他 loader 的处理范围以外,请使用使用取反的正则:

module: {
    rules: [
    // ...
+     {
+       test: /\.m?js$/,
+       resourceQuery: { not: [/raw/] },
+       use: [ ... ]
+     },
      {
        resourceQuery: /raw/,
        type: 'asset/source',
      }
    ]
  },

或者使用 oneOf 的规则列表。此处只应用第一个匹配规则:

module: {
    rules: [
    // ...
+     { oneOf: [
        {
          resourceQuery: /raw/,
          type: 'asset/source',
        },
+       {
+         test: /\.m?js$/,
+         use: [ ... ]
+       },
+     ] }
    ]
  },

Disable emitting assets

For use cases like Server side rendering, you might want to disable emitting assets, which is feasible with emit option under Rule.generator:

module.exports = {
  // …
  module: {
    rules: [
      {
        test: /\.png$/i,
        type: 'asset/resource',
        generator: {
          emit: false,
        },
      },
    ],
  },
};

1 位贡献者

webpack