可打印

指南

本节包含帮助您理解和掌握 webpack 提供的各种工具和功能的指南。第一篇指南将带您了解如何入门

这些指南会随着内容的深入而变得更加高级。大多数指南都可作为起点,完成后您应该会更自在地深入查阅实际的文档

入门

Webpack 用于编译 JavaScript 模块。一旦安装,您可以通过其命令行界面 (CLI)API 与 webpack 交互。如果您刚接触 webpack,请阅读核心概念这份比较,以了解为什么您可能希望使用它而非社区中的其他工具。

基础设置

首先,让我们创建一个目录,初始化 npm,本地安装 webpack,并安装 webpack-cli(用于在命令行上运行 webpack 的工具)

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

在整个指南中,我们将使用 diff 块来展示我们对目录、文件和代码所做的更改。例如

+ this is a new line you shall copy into your code
- and this is a line to be removed from your code
  and this is a line not to touch.

现在我们将创建以下目录结构、文件及其内容

项目

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

src/index.js

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

  // Lodash, currently included via a script, is required for this line to work
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

  return element;
}

document.body.appendChild(component());

index.html

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

我们还需要调整我们的 package.json 文件,以确保我们将包标记为 private,并移除 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 文件依赖于 lodash 在其运行前已包含在页面中。这是因为 index.js 从未明确声明需要 lodash;它假设全局变量 _ 存在。

以这种方式管理 JavaScript 项目存在问题

  • 无法立即看出脚本依赖于外部库。
  • 如果缺少依赖项,或包含的顺序不正确,应用程序将无法正常运行。
  • 如果包含了一个依赖项但未使用,浏览器将被迫下载不必要的代码。

让我们改用 webpack 来管理这些脚本。

创建打包文件

首先,我们将稍微调整目录结构,将“源代码”(./src)与“分发”代码(./dist)分开。“源代码”是我们将编写和编辑的代码。“分发”代码是我们构建过程的最小化和优化后的 output,最终将加载到浏览器中。按如下方式调整目录结构

项目

  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, currently included via a script, is required for this line to work
+  // Lodash, now imported by this script
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');

   return element;
 }

 document.body.appendChild(component());

现在,由于我们将打包脚本,我们必须更新 index.html 文件。让我们移除 lodash 的 <script> 标签,因为我们现在通过 import 导入它,并修改另一个 <script> 标签以加载打包文件,而不是原始的 ./src 文件

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
     <title>Getting Started</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 可以使用此信息构建依赖图。然后它使用该图生成一个优化的打包文件,其中脚本将按正确的顺序执行。

话虽如此,让我们运行 npx webpack,它将以 src/index.js 中的脚本作为入口点,并生成 dist/main.js 作为输出npx 命令(Node 8.2/npm 5.2.0 或更高版本自带)会运行我们一开始安装的 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'

模块

importexport 语句已在 ES2015 中标准化。目前大多数浏览器都支持它们,但有些浏览器不识别新语法。不过不用担心,webpack 开箱即用支持它们。

在幕后,webpack 实际上会“转译”代码,以便旧版浏览器也能运行。如果您检查 dist/main.js,您可能会看到 webpack 是如何做到的,这非常巧妙!除了 importexport,webpack 还支持各种其他模块语法,有关更多信息,请参阅模块 API

请注意,webpack 不会更改除 importexport 语句之外的任何代码。如果您使用其他ES2015 特性,请务必通过 webpack 的加载器系统使用 Babel转译器

使用配置

从版本 4 开始,webpack 不需要任何配置,但大多数项目需要更复杂的设置,这就是 webpack 支持配置文件的原因。这比在终端中手动输入大量命令要高效得多,所以让我们创建一个

项目

  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

配置文件比命令行界面使用提供了更大的灵活性。我们可以通过这种方式指定加载器规则、插件、解析选项和许多其他增强功能。有关更多信息,请参阅配置文档

NPM 脚本

鉴于从命令行界面运行本地 webpack 副本并不那么有趣,我们可以设置一个快捷方式。让我们通过添加一个npm 脚本来调整我们的 package.json

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 命令。请注意,在 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 管理图像和字体等资源。此时,您的项目应该如下所示

项目

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 之前,前端开发人员会使用像 GruntGulp 这样的工具来处理这些资源,并将它们从 /src 文件夹移动到 /dist/build 目录。JavaScript 模块也使用了同样的想法,但像 webpack 这样的工具会动态打包所有依赖项(创建所谓的依赖图)。这非常棒,因为现在每个模块都明确声明了它的依赖项,我们将避免打包未使用的模块。

webpack 最酷的功能之一是,除了 JavaScript 之外,您还可以包含任何其他类型的文件,只要有加载器或内置的资源模块支持。这意味着上面列出的适用于 JavaScript 的相同好处(例如,明确的依赖项)可以应用于构建网站或 Web 应用程序所使用的所有内容。让我们从 CSS 开始,因为您可能已经熟悉该设置。

设置

在我们开始之前,让我们对项目进行一些小的改动

dist/index.html

 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
-    <title>Getting Started</title>
+    <title>Asset Management</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 模块中 import CSS 文件,您需要安装 style-loadercss-loader 并将其添加到您的module 配置

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'],
+      },
+    ],
+  },
 };

模块加载器可以链式调用。链中的每个加载器都会对处理后的资源应用转换。链以反向顺序执行。第一个加载器将其结果(已应用转换的资源)传递给下一个,依此类推。最后,webpack 期望链中的最后一个加载器返回 JavaScript。

应保持上述加载器顺序:'style-loader' 在前,然后是'css-loader'。如果不遵循此约定,webpack 可能会抛出错误。

这使您能够将 import './style.css' 导入到依赖该样式的文件中。现在,当该模块运行时,一个包含字符串化 CSS 的 <style> 标签将被插入到您的 HTML 文件的 <head> 中。

让我们通过向项目添加一个新的 style.css 文件并在我们的 index.js 中导入它来尝试一下

项目

  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, now imported by this script
   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 现在被设置为红色样式。要查看 webpack 所做的工作,请检查页面(不要查看页面源代码,因为它不会显示结果,因为 <style> 标签是由 JavaScript 动态创建的)并查看页面的 head 标签。它应该包含我们在 index.js 中导入的样式块。

请注意,您可以(在大多数情况下应该)最小化 CSS 以在生产环境中获得更好的加载时间。此外,几乎任何您能想到的 CSS 方言都有加载器——例如 postcsssassless

加载图像

现在我们正在引入 CSS,但我们的图像(如背景和图标)呢?从 webpack 5 开始,使用内置的资源模块,我们也可以轻松地将它们整合到我们的系统中

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'),也会发生类似的过程。加载器会识别这是一个本地文件,并将 './my-image.png' 路径替换为图像在 output 目录中的最终路径。html-loader 以相同的方式处理 <img src="./my-image.png" />

让我们向项目中添加一张图片,看看它是如何工作的,您可以使用任何您喜欢的图片

项目

  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, now imported by this script
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

+  // Add the image to our existing 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',
+      },
     ],
   },
 };

向您的项目添加一些字体文件

项目

  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

配置好加载器并就位字体后,您可以通过 @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。JSON 支持实际上是内置的,类似于 NodeJS,这意味着 import Data from './data.json' 默认情况下将起作用。要导入 CSV、TSV 和 XML,您可以使用 csv-loaderxml-loader。让我们处理这三种类型的加载

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'],
+      },
     ],
   },
 };

向您的项目添加一些数据文件

项目

  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

现在您可以 import 这四种数据类型(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, now imported by this script
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

   // Add the image to our existing 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。如果您查看开发工具中的控制台,您应该能够看到导入的数据被记录到控制台!

// No warning
import data from './data.json';

// Warning shown, this is not allowed by the spec.
import { foo } from './data.json';

自定义 JSON 模块解析器

可以通过使用自定义解析器而不是特定的 webpack 加载器,将任何 tomlyamljson5 文件作为 JSON 模块导入。

假设您在 src 文件夹下有 data.tomldata.yamldata.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); // output `TOML Example`
+console.log(toml.owner.name); // output `Tom Preston-Werner`
+
+console.log(yaml.title); // output `YAML Example`
+console.log(yaml.owner.name); // output `Tom Preston-Werner`
+
+console.log(json.title); // output `JSON5 Example`
+console.log(json.owner.name); // output `Tom Preston-Werner`

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

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

   // Add the image to our existing 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 目录中。只要您安装了所有外部依赖项并且您的配置定义了相同的加载器,您就可以正常使用了。

然而,假设您墨守成规,或者您有一些在多个组件(视图、模板、模块等)之间共享的资源。仍然可以将这些资源存储在基本目录中,甚至可以使用别名来使它们更易于 import

总结

在接下来的指南中,我们将不会使用本指南中用到的所有不同资源,所以让我们进行一些清理,以便为指南的下一部分输出管理做好准备

项目

  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); // output `TOML Example`
-console.log(toml.owner.name); // output `Tom Preston-Werner`
-
-console.log(yaml.title); // output `YAML Example`
-console.log(yaml.owner.name); // output `Tom Preston-Werner`
-
-console.log(json.title); // output `JSON5 Example`
-console.log(json.owner.name); // output `Tom Preston-Werner`

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

-  // Lodash, now imported by this script
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-  element.classList.add('hello');
-
-  // Add the image to our existing 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 文件中,但随着您的应用程序的增长,一旦您开始在文件名中使用哈希值并输出多个打包文件,手动管理 index.html 文件将变得困难。然而,存在一些插件可以使这个过程更容易管理。

准备工作

首先,让我们稍微调整一下项目

项目

  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>Asset Management</title>
+    <title>Output Management</title>
+    <script src="./print.bundle.js"></script>
   </head>
   <body>
-    <script src="bundle.js"></script>
+    <script src="./index.bundle.js"></script>
   </body>
 </html>

现在调整配置。我们将把 src/print.js 添加为一个新的入口点 (print),我们也将更改输出,使其根据入口点名称动态生成打包文件名

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,您可以看到点击按钮时会发生什么。

但是,如果我们更改其中一个入口点的名称,甚至添加一个新的入口点,会发生什么呢?生成的打包文件将在构建时被重命名,但我们的 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 Management',
+    }),
+  ],
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

在进行构建之前,您应该知道 HtmlWebpackPlugin 默认会生成自己的 index.html 文件,即使我们已经在 dist/ 文件夹中有一个了。这意味着它将用新生成的文件替换我们的 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 为您创建了一个全新的文件,并且所有打包文件都已自动添加。

如果您想了解 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 的output,那么清单将是一个很好的起点。

可以使用WebpackManifestPlugin 将清单数据提取到 JSON 文件中以供消费。

我们不会通过一个完整的示例来展示如何在您的项目中使用此插件,但您可以阅读概念页面缓存指南,以了解这如何与长期缓存联系起来。

总结

现在您已经了解了如何动态地向 HTML 添加打包文件,让我们深入研究开发指南。或者,如果您想深入探讨更高级的主题,我们建议您前往代码分割指南

开发

如果您一直遵循这些指南,您应该对 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,
   },
 };

使用源映射

当 webpack 打包您的源代码时,将错误和警告追溯到其原始位置可能会变得困难。例如,如果您将三个源文件(a.jsb.jsc.js)打包成一个文件(bundle.js),并且其中一个源文件包含错误,则堆栈跟踪将指向 bundle.js。这并不总是有用,因为您可能想知道错误究竟来自哪个源文件。

为了更容易地跟踪错误和警告,JavaScript 提供了源映射,它将您的编译代码映射回您的原始源代码。如果错误源自 b.js,源映射会准确地告诉您这一点。

在源映射方面有许多不同的选项可用。务必查看它们,以便您可以根据需要进行配置。

在本指南中,让我们使用 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('I get called from print.js!');
+  cosnole.log('I get called from 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 “观察”依赖图中的所有文件以进行更改。如果其中一个文件更新,代码将重新编译,这样您就不必手动运行完整的构建。

让我们添加一个 npm 脚本来启动 webpack 的观察模式

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('I get called from print.js!');
+  console.log('I get called from print.js!');
 }

现在保存您的文件并检查终端窗口。您应该会看到 webpack 自动重新编译了更改的模块!

唯一的缺点是您必须刷新浏览器才能看到更改。如果也能自动发生就更好了,所以让我们尝试 webpack-dev-server,它会做到这一点。

使用 webpack-dev-server

webpack-dev-server 为您提供了一个基本的 Web 服务器和使用实时重载的能力。让我们设置它

npm install --save-dev webpack-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 上提供文件。

让我们也添加一个脚本来轻松运行开发服务器

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 服务器将在代码编译后自动重新加载。试试看吧!

webpack-dev-server 附带许多可配置选项。请查阅文档了解更多信息。

使用 webpack-dev-middleware

webpack-dev-middleware 是一个包装器,它将 webpack 处理的文件发射到服务器。它在 webpack-dev-server 内部使用,但也可作为一个单独的包使用,以便在需要时进行更多自定义设置。我们将看一个将 webpack-dev-middleware 与 express 服务器结合的示例。

让我们安装 expresswebpack-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: '/',
   },
 };

publicPath 也将用于我们的服务器脚本中,以确保文件在 https://:3000 上正确提供。我们稍后将指定端口号。下一步是设置我们的自定义 express 服务器

项目

  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);

// Tell express to use the webpack-dev-middleware and use the webpack.config.js
// configuration file as a base.
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

// Serve the files on port 3000.
app.listen(3000, function () {
  console.log('Example app listening on port 3000!\n');
});

现在添加一个 npm 脚本,让运行服务器变得更容易一些

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.

现在打开您的浏览器并访问 https://:3000。您应该会看到您的 webpack 应用程序正在运行并正常工作!

调整您的文本编辑器

当使用代码的自动编译时,您在保存文件时可能会遇到问题。一些编辑器具有“安全写入”功能,这可能会干扰重新编译。

要在一些常用编辑器中禁用此功能,请参阅以下列表

  • Sublime Text 3:将 atomic_save: 'false' 添加到您的用户首选项中。
  • JetBrains IDEs (例如 WebStorm):在 Preferences > Appearance & Behavior > System Settings 中取消勾选“使用安全写入”。
  • Vim:将 :set backupcopy=yes 添加到您的设置中。

总结

现在您已经学会了如何自动编译代码并运行开发服务器,您可以查看下一篇指南,该指南将涵盖代码分割

代码分割

代码分割是 webpack 最引人注目的特性之一。此功能允许您将代码分割成各种打包文件,然后可以按需或并行加载。它可用于实现更小的打包文件并控制资源加载优先级,如果使用得当,可以对加载时间产生重大影响。

有三种通用的代码分割方法可用

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

入口点

这是迄今为止最简单、最直观的代码分割方式。然而,它更手动,并且有一些我们将会遇到的陷阱。让我们看看如何将另一个模块从主打包文件中分割出来

项目

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

如前所述,这种方法存在一些陷阱

  • 如果入口代码块之间有任何重复模块,它们将包含在两个打包文件中。
  • 它不够灵活,不能用于与核心应用程序逻辑动态分割代码。

这两点中的第一点对我们的示例来说绝对是个问题,因为 lodash 也被导入到 ./src/index.js 中,因此会在两个打包文件中重复。让我们在下一节中消除这种重复。

防止重复

入口依赖

dependOn 选项允许在代码块之间共享模块

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'] }。这样可以在使用 async 脚本标签时获得更好的优化和一致的执行顺序。

SplitChunksPlugin

SplitChunksPlugin 允许我们将公共依赖项提取到现有入口代码块或全新的代码块中。让我们用它来消除上一个示例中 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 分离到一个单独的代码块中,并从我们的主打包文件中移除冗余部分。但是,重要的是要注意,公共依赖项只有在满足 webpack 指定的大小阈值时才会被提取到单独的代码块中。

让我们运行 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

以下是社区提供的一些其他用于代码分割的有用插件和加载器

动态导入

webpack 在动态代码分割方面支持两种类似的技术。第一种也是推荐的方法是使用符合ECMAScript 动态导入提案import() 语法。遗留的、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',
-    },
-  },
 };

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

项目

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

现在,我们不再静态导入 lodash,而是使用动态导入来分离一个代码块

src/index.js

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

-  // Lodash, now imported by this script
-  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 模块时,import 不再解析为 module.exports 的值,而是为 CommonJS 模块创建一个人工命名空间对象。有关其原因的更多信息,请阅读webpack 4: import() and CommonJs

让我们运行 webpack,看看 lodash 被分离到单独的打包文件中

...
[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,因此它可以与异步函数一起使用。以下是如何简化代码的方法

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 4.6.0+ 增加了对预取和预加载的支持。

在声明导入时使用这些内联指令,webpack 可以输出“资源提示”,告诉浏览器

  • 预取:资源将来可能用于某些导航
  • 预加载:资源在当前导航期间也将需要

一个例子是有一个 HomePage 组件,它渲染一个 LoginButton 组件,然后点击后按需加载一个 LoginModal 组件。

LoginButton.js

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

这将导致 <link rel="prefetch" href="login-modal-chunk.js"> 被添加到页面的头部,这将指示浏览器在空闲时预取 login-modal-chunk.js 文件。

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

  • 预加载的代码块与父代码块并行开始加载。预取的代码块在父代码块加载完成后开始。
  • 预加载的代码块具有中等优先级并立即下载。预取的代码块在浏览器空闲时下载。
  • 预加载的代码块应该立即被父代码块请求。预取的代码块可以在将来任何时候使用。
  • 浏览器支持不同。

一个例子是有一个 Component,它总是依赖于一个应该在单独代码块中的大型库。

让我们想象一个 ChartComponent 组件,它需要一个庞大的 ChartingLibrary。它在渲染时显示一个 LoadingIndicator,并立即按需导入 ChartingLibrary

ChartComponent.js

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

当请求使用 ChartComponent 的页面时,charting-library-chunk 也通过 <link rel="preload"> 被请求。假设页面代码块更小且完成更快,页面将显示 LoadingIndicator,直到已请求的 charting-library-chunk 完成。这将稍微提高加载时间,因为它只需要一次往返,而不是两次。特别是在高延迟环境中。

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

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // Do something with the error.
    // For example, we can retry the request in case of any net error
  });

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

为了防止此类问题,您可以添加自己的 onerror 处理程序,它会在发生任何错误时移除脚本

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

在这种情况下,出错的脚本将被移除。Webpack 将创建自己的脚本,并且任何错误都将立即处理,而不会出现任何超时。

打包分析

一旦您开始分割代码,分析输出来检查模块的去向会很有用。官方分析工具是一个很好的起点。还有一些其他社区支持的选项

  • webpack-chart:用于 webpack 统计信息的交互式饼图。
  • webpack-visualizer:可视化并分析您的打包文件,查看哪些模块占用空间以及哪些可能重复。
  • webpack-bundle-analyzer:一个插件和命令行工具,以方便的交互式可缩放树状图表示打包内容。
  • webpack bundle optimize helper:此工具将分析您的打包文件,并为您提供可操作的建议,以改进并减小打包文件大小。
  • bundle-stats:生成打包报告(打包大小、资源、模块)并比较不同构建之间的结果。
  • webpack-stats-viewer:一个带有 webpack 统计信息构建的插件。显示有关 webpack 打包详细信息的更多信息。

后续步骤

请参阅懒加载以获取 import() 如何在实际应用程序中使用的更具体示例,并参阅缓存以了解如何更有效地分割代码。

缓存

因此,我们正在使用 webpack 打包我们的模块化应用程序,生成一个可部署的 /dist 目录。一旦 /dist 的内容部署到服务器,客户端(通常是浏览器)将访问该服务器以获取站点及其资源。最后一步可能很耗时,这就是浏览器使用一种称为缓存的技术的原因。这使得站点加载更快,并减少了不必要的网络流量。然而,当您需要获取新代码时,它也可能导致麻烦。

本指南侧重于确保 webpack 编译生成的文件可以保持缓存,除非其内容已更改所需的配置。

输出文件名

我们可以使用 output.filename 替换设置来定义输出文件的名称。Webpack 提供了一种使用括号字符串(称为替换)来模板化文件名的方法。[contenthash] 替换将根据资产的内容添加一个唯一的哈希值。当资产的内容更改时,[contenthash] 也会更改。

让我们使用入门中的示例和输出管理中的插件来设置我们的项目,这样我们就不必手动维护 index.html 文件了

项目

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,
    },
  };

使用此配置运行我们的构建脚本 npm run build 应该会产生以下输出

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

如您所见,打包文件的名称现在反映了其内容(通过哈希值)。如果我们再次运行构建而不进行任何更改,我们期望文件名保持不变。然而,如果我们再次运行它,我们可能会发现情况并非如此

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

这是因为 webpack 在入口代码块中包含了一些样板代码,特别是运行时和清单。

提取样板代码

正如我们在代码分割中了解到的,SplitChunksPlugin 可用于将模块分割成单独的打包文件。Webpack 提供了一项优化功能,可以使用optimization.runtimeChunk 选项将运行时代码分割到单独的代码块中。将其设置为 single 以创建所有代码块的单个运行时打包文件

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',
+   },
  };

让我们再次运行构建,看看提取的运行时打包文件

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 代码块也是一个好习惯,因为它们比我们的本地源代码更不容易更改。这一步将允许客户端从服务器请求更少的内容以保持最新。这可以通过使用SplitChunksPlugin 的示例 2 中演示的cacheGroups 选项来完成。让我们添加带有以下参数的 optimization.splitChunkscacheGroups,然后进行构建

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 打包文件

...
                          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 字节!

模块标识符

让我们向项目添加另一个模块 print.js

项目

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, now imported by this script
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.onclick = Print.bind(null, 'Hello webpack!');

    return element;
  }

  document.body.appendChild(component());

运行另一次构建,我们期望只有我们 main 打包文件的哈希值会改变,然而...

...
                           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 打包文件因其新内容而改变。
  • vendor 打包文件改变了,因为它的 module.id 改变了。
  • 并且,运行时打包文件改变了,因为它现在包含对新模块的引用。

第一个和最后一个是预期的,我们想要修复的是 vendor 哈希值。让我们使用带有 'deterministic' 选项的optimization.moduleIds

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, now imported by this script
    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 打包文件的文件名中产生了 55e79e5927a639d21a1b

总结

缓存可能很复杂,但对应用程序或站点用户的益处使其值得付出努力。请参阅下面的“延伸阅读”部分了解更多信息。

创作库

除了应用程序,webpack 还可以用于打包 JavaScript 库。以下指南旨在帮助库作者简化他们的打包策略。

创作一个库

让我们假设我们正在编写一个小型库 webpack-numbers,它允许用户将数字 1 到 5 从数字表示转换为文本表示,反之亦然,例如 2 到 'two'。

基本的项目结构将如下所示

项目

+  |- 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 模块 require:

    const webpackNumbers = require('webpack-numbers');
    // ...
    webpackNumbers.wordToNum('Two');
  • AMD 模块 require:

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

    <!DOCTYPE html>
    <html>
      ...
      <script src="https://example.org/webpack-numbers.js"></script>
      <script>
        // ...
        // Global variable
        webpackNumbers.wordToNum('Five');
        // Property in the window object
        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',
+    globalObject: 'this',
+    library: {
+      name: 'webpackNumbers',
+      type: 'umd',
+    },
   },
 };

现在 webpack 将打包一个可在 CommonJS、AMD 和脚本标签环境中工作的库。

外部化 Lodash

现在,如果您运行 npx webpack,您会发现创建了一个相当大的打包文件。如果您检查该文件,您会看到 lodash 已与您的代码一起打包。在这种情况下,我们更愿意将 lodash 视为一个对等依赖项。这意味着消费者应该已经安装了 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 将它们从打包文件中排除。您需要逐个排除它们,或者使用正则表达式。

module.exports = {
  //...
  externals: [
    'library/one',
    'library/two',
    // Everything that starts with "library/"
    /^library\/.+$/,
  ],
};

最后步骤

按照生产指南中提到的步骤优化您的生产输出。我们还将生成打包文件的路径添加到 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 中区分开发构建和生产构建,您可以使用环境变量。

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 的最新状态也有助于提高性能。此外,保持您的包管理器(例如 npmyarn)的最新状态也有助于提高性能。新版本创建更有效的模块树并提高解析速度。

加载器

将加载器应用于所需的最少模块。而不是

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

使用 include 字段,只对实际需要通过加载器转换的模块应用加载器

const path = require('path');

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

引导

每个额外的加载器/插件都有启动时间。尽量使用最少的工具。

解析

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

  • 最小化 resolve.modulesresolve.extensionsresolve.mainFilesresolve.descriptionFiles 中的项数,因为它们会增加文件系统调用的数量。
  • 如果您不使用符号链接(例如 npm linkyarn link),请设置 resolve.symlinks: false
  • 如果您使用不特定于上下文的自定义解析插件,请设置 resolve.cacheWithContext: false

DLL

使用 DllPlugin 将不经常更改的代码移动到单独的编译中。这将提高应用程序的编译速度,尽管它会增加构建过程的复杂性。

更小 = 更快

减小编译的总大小以提高构建性能。尝试保持代码块较小。

  • 使用更少/更小的库。
  • 在多页应用程序中使用 SplitChunksPlugin
  • 在多页应用程序中以异步模式使用 SplitChunksPlugin
  • 移除未使用的代码。
  • 只编译您当前正在开发的代码部分。

工作池

thread-loader 可用于将昂贵的加载器分流到工作池。

持久缓存

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

自定义插件/加载器

对其进行分析,以免在此处引入性能问题。

进度插件

可以通过从 webpack 配置中移除 ProgressPlugin 来缩短构建时间。请记住,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 变体。

避免生产环境专用工具

某些实用程序、插件和加载器仅在构建生产环境时才有意义。例如,在开发过程中使用 TerserPlugin 最小化和混淆代码通常没有意义。这些工具通常应在开发中排除

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

最小入口代码块

Webpack 只向文件系统发出更新的代码块。对于某些配置选项(HMR、output.chunkFilename 中的 [name]/[chunkhash]/[contenthash][fullhash]),除了更改的代码块之外,入口代码块也会失效。

确保入口代码块的生成成本低廉,方法是保持其体积小。以下配置为运行时代码创建了一个额外的代码块,因此生成成本低廉

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

避免额外的优化步骤

Webpack 会进行额外的算法工作以优化输出的大小和加载性能。这些优化对于较小的代码库来说性能良好,但在较大的代码库中可能会代价高昂

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

无路径信息的输出

Webpack 能够生成输出打包文件中的路径信息。但是,这会对打包数千个模块的项目造成垃圾回收压力。在 options.output.pathinfo 设置中将其关闭

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

Node.js 版本 8.9.10-9.11.1

在 Node.js 8.9.10 - 9.11.1 版本中,ES2015 Map 和 Set 实现存在性能回退。Webpack 大量使用这些数据结构,因此此回退影响编译时间。

更早和更晚的 Node.js 版本不受影响。

TypeScript 加载器

为了在使用 ts-loader 时缩短构建时间,请使用 transpileOnly 加载器选项。此选项本身会关闭类型检查。要再次获得类型检查,请使用ForkTsCheckerWebpackPlugin。这通过将类型检查和 ESLint linting 移动到单独的进程来加速 TypeScript 类型检查和 ESLint linting。

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

生产

以下步骤在生产中特别有用。

源映射

源映射非常昂贵。您真的需要它们吗?


特定工具问题

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

Babel

  • 最小化预设/插件的数量

TypeScript

  • 使用 fork-ts-checker-webpack-plugin 在单独的进程中进行类型检查。
  • 配置加载器以跳过类型检查。
  • happyPackMode: true / transpileOnly: true 模式下使用 ts-loader

Sass

  • node-sass 有一个会阻塞 Node.js 线程池中线程的 bug。当与 thread-loader 一起使用时,请设置 workerParallelJobs: 2

内容安全策略 (Content Security Policies)

Webpack 能够为其加载的所有脚本添加一个 nonce。要激活此功能,请设置一个 __webpack_nonce__ 变量并将其包含在您的入口脚本中。然后将为每个唯一的页面视图生成并提供一个唯一的基于哈希的 nonce(这就是为什么 __webpack_nonce__ 在入口文件中指定而不是在配置中指定的原因)。请注意,__webpack_nonce__ 应该始终是一个 base64 编码的字符串。

示例

在入口文件中

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

启用 CSP

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

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

有关 CSP 和 nonce 属性的更多信息,请参阅本页面底部的延伸阅读部分。

受信任类型 (Trusted Types)

Webpack 还能够使用受信任类型加载动态构建的脚本,以遵守 CSP require-trusted-types-for 指令限制。请参阅 output.trustedTypes 配置选项。

开发 - Vagrant

如果您有一个更高级的项目,并使用 Vagrant 在虚拟机中运行开发环境,您通常也会希望在虚拟机中运行 webpack。

配置项目

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

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

接下来,在您的项目中安装 webpack, webpack-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 文件。脚本标签应指向您的打包文件。如果配置中未指定 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 访问。我们将从我们的主机 PC 访问它,所以我们需要更改 --host 以允许这样做。

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 上访问,这很好,因为 nginx 会负责使其在您的主机 PC 上可用。

总结

我们使 Vagrant 盒子可以通过静态 IP 访问,然后使 webpack-dev-server 公开可访问,以便可以通过浏览器访问。然后我们解决了 VirtualBox 不发送文件系统事件导致服务器在文件更改时无法重新加载的常见问题。

依赖管理

ES6 模块

CommonJS

AMD

带有表达式的 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$/

上下文模块

生成一个上下文模块。它包含对该目录中所有可以通过与正则表达式匹配的请求进行 require 的模块的引用。上下文模块包含一个将请求转换为模块 ID 的映射。

示例映射

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

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

这意味着支持动态 require,但会导致所有匹配的模块都包含在 bundle 中。

require.context

您可以使用 require.context() 函数创建自己的上下文。

它允许您传入要搜索的目录、一个指示是否也应搜索子目录的标志以及一个用于匹配文件的正则表达式。

Webpack 在构建时解析代码中的 require.context()

语法如下:

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

示例

require.context('./test', false, /\.test\.js$/);
// a context with files from the test directory that can be required with a request ending with `.test.js`.
require.context('../', true, /\.stories\.js$/);
// a context with all files in the parent folder and descending folders ending with `.stories.js`.

上下文模块 API

上下文模块导出一个(require)函数,该函数接受一个参数:请求。

导出的函数有 3 个属性:resolvekeysid

  • resolve 是一个函数,返回解析请求的模块 ID。
  • keys 是一个函数,返回上下文模块可以处理的所有可能请求的数组。

如果您想 require 目录中或匹配模式的所有文件,这会很有用,例如:

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$/));
// At build-time cache will be populated with all required modules.
  • id 是上下文模块的模块 ID。这对于 module.hot.accept 可能有用。

安装

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

先决条件

在开始之前,请确保您安装了最新版本的 Node.js。当前的长期支持 (LTS) 版本是一个理想的起点。您可能会遇到各种旧版本的问题,因为它们可能缺少 webpack 和/或其相关包所需的功能。

本地安装

最新的 webpack 发布版本是

GitHub release

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

npm install --save-dev webpack
# or specific version
npm install --save-dev webpack@<version>

如果您使用的是 webpack v4 或更高版本并希望从命令行调用 webpack,您还需要安装 CLI

npm install --save-dev webpack-cli

我们建议大多数项目都采用本地安装。当引入重大更改时,这使得单独升级项目变得更容易。通常,webpack 通过一个或多个 npm 脚本运行,这些脚本将在本地 node_modules 目录中查找 webpack 安装。

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

全局安装

以下 NPM 安装将使 webpack 全局可用:

npm install --global webpack

前沿版本

如果您热衷于使用 webpack 提供的最新功能,您可以使用以下命令安装 Beta 版本,甚至直接从 webpack 仓库安装:

npm install --save-dev webpack@next
# or a specific tag/branch
npm install --save-dev webpack/webpack#<tagname/branchname>

热模块替换

模块热替换(Hot Module Replacement 或 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',
+      // Runtime code for hot module replacement
+      hot: 'webpack/hot/dev-server.js',
+      // Dev server client for web socket transport, hot and live reload logic
+      client: 'webpack-dev-server/client/index.js?hot=true&live-reload=true',
    },
    devtool: 'inline-source-map',
    devServer: {
      static: './dist',
+     // Dev server client for web socket transport, hot and live reload logic
+     hot: false,
+     client: false,
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Hot Module Replacement',
      }),
+     // Plugin for 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.js 中的 console.log 语句,您应该在浏览器控制台中看到以下输出(暂时不用担心 button.onclick = printMe 的输出,我们稍后也会更新那部分)。

print.js

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

控制台

[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 Updating print.js...
+ main.js:4330 [HMR] Updated modules:
+ main.js:4330 [HMR]  - 20

通过 Node.js API

将 Webpack Dev Server 与 Node.js API 一起使用时,不要将开发服务器选项放在 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: [
    // Runtime code for hot module replacement
    'webpack/hot/dev-server.js',
    // Dev server client for web socket transport, hot and live reload logic
    'webpack-dev-server/client/index.js?hot=true&live-reload=true',
    // Your entry
    './src/index.js',
  ],
  devtool: 'inline-source-map',
  plugins: [
    // Plugin for hot module replacement
    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` and `client` options are disabled because we added them manually
const server = new webpackDevServer({ hot: false, client: false }, compiler);

(async () => {
  await server.start();
  console.log('dev server is running');
})();

请参阅 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 event is bind to the original printMe function

    element.appendChild(btn);

    return element;
  }

- document.body.appendChild(component());
+ let element = component(); // Store the element to re-render on print.js changes
+ document.body.appendChild(element);

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('Accepting the updated printMe module!');
-     printMe();
+     document.body.removeChild(element);
+     element = component(); // Re-render the "component" to update the click handler
+     document.body.appendChild(element);
    })
  }

这只是一个例子,但还有许多其他例子很容易让人困惑。幸运的是,有很多加载器(下面提到了一些)可以让模块热替换变得容易得多。

HMR 与样式表

借助 style-loader,CSS 的模块热替换实际上非常简单。此加载器在幕后使用 module.hot.accept,在 CSS 依赖项更新时修补 <style> 标签。

首先,让我们使用以下命令安装这两个加载器:

npm install --save-dev style-loader css-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,
    },
  };

通过将样式表导入模块来热加载样式表

项目

  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 event is bind to the original printMe function

    element.appendChild(btn);

    return element;
  }

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

  if (module.hot) {
    module.hot.accept('./print.js', function() {
      console.log('Accepting the updated printMe module!');
      document.body.removeChild(element);
      element = component(); // Re-render the "component" to update the click handler
      document.body.appendChild(element);
    })
  }

body 上的样式更改为 background: red;,您应该立即看到页面背景颜色更改,而无需完全刷新。

styles.css

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

其他代码和框架

社区中还有许多其他加载器和示例,可以使 HMR 与各种框架和库顺利交互...

  • React Hot Loader: 实时调整 React 组件。
  • Vue Loader: 此加载器开箱即用地支持 Vue 组件的 HMR。
  • Elm Hot webpack Loader: 支持 Elm 编程语言的 HMR。
  • Angular HMR: 无需加载器!HMR 支持内置于 Angular CLI 中,将 --hmr 标志添加到您的 ng serve 命令中。
  • Svelte Loader: 此加载器开箱即用地支持 Svelte 组件的 HMR。

摇树优化 (Tree Shaking)

摇树优化(Tree shaking)是 JavaScript 语境中常用的一个术语,用于指代消除死代码。它依赖于 ES2015 模块语法(即 importexport)的静态结构。这个名称和概念由 ES2015 模块打包器 rollup 推广开来。

webpack 2 版本内置了对 ES2015 模块(又名 harmony modules)以及未使用的模块导出检测的支持。新的 webpack 4 版本扩展了此功能,可以通过 package.json"sideEffects" 属性向编译器提供提示,以指示项目中的哪些文件是“纯粹的”,因此在不使用时可以安全地删除。

添加一个工具函数

让我们向项目中添加一个新的工具文件 src/math.js,它导出两个函数:

项目

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,以确保打包文件未被压缩。

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, now imported by this script
-   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.innerHTML = [
+     'Hello webpack!',
+     '5 cubed is equal to ' + cube(5)
+   ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

请注意,我们没有 import src/math.js 模块中的 square 方法。该函数被称为“死代码”,意味着一个未使用的 export 应该被删除。现在让我们运行 npm 脚本 npm run build,并检查输出的打包文件:

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 没有被导入,但是,它仍然包含在打包文件中。我们将在下一节修复这个问题。

将文件标记为无副作用

在一个 100% ESM 模块的世界里,识别副作用是直接了当的。然而,我们还没有到那一步,所以在此期间,有必要向 webpack 的编译器提供关于代码“纯洁性”的提示。

实现这一点的方式是 "sideEffects" package.json 属性。

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

上述所有代码都不包含副作用,因此我们可以将该属性标记为 false,以告知 webpack 可以安全地修剪未使用的导出。

如果您的代码确实有一些副作用,则可以改为提供一个数组:

{
  "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"]
}

最后,"sideEffects" 也可以通过 module.rules 配置选项进行设置。

澄清摇树优化和 sideEffects

sideEffectsusedExports(更广为人知的名称是摇树优化)优化是两回事。

sideEffects 效率更高,因为它允许跳过整个模块/文件和完整的子树。

usedExports 依赖于 terser 来检测语句中的副作用。这在 JavaScript 中是一项困难的任务,并且不如直接使用 sideEffects 标志有效。它也无法跳过子树/依赖项,因为规范规定副作用需要被评估。虽然导出函数运行良好,但 React 的高阶组件 (HOC) 在这方面存在问题。

让我们举个例子:

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 被调用,并且返回值也被调用。调用 mergehoistStatics 是否有副作用?在分配 WithProvider.contextTypes(Setter?)或读取 WrappedComponent.contextTypes(Getter?)时是否有副作用?

Terser 实际上试图弄清楚,但在很多情况下它无法确定。这并不意味着 terser 没有做好它的工作,因为它无法弄清楚。在 JavaScript 这样的动态语言中,可靠地确定它太困难了。

但是我们可以通过使用 /*#__PURE__*/ 注解来帮助 terser。它将一个语句标记为无副作用。因此,一个小的更改就可以使代码进行摇树优化:

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 个模块包含在打包文件中:

  • index.js: 几乎为空
  • configure.js
  • components/Button.js
  • components/Button.css

在此优化之后,其他优化仍然可以应用。例如:Button.js 中的 buttonFrombuttonsFrom 导出也未使用。usedExports 优化将检测到它,terser 可能会从模块中删除一些语句。

模块连接也适用。因此,这 4 个模块加上入口模块(可能还有更多依赖项)可以连接起来。最终 index.js 没有生成任何代码

完整示例:理解 CSS 文件的副作用

为了更好地理解 sideEffects 标志的影响,让我们看一个包含 CSS 资产的 npm 包的完整示例,以及它们在摇树优化过程中可能受到的影响。我们将创建一个虚构的 UI 组件库,名为“awesome-ui”。

包结构

我们的示例包如下:

awesome-ui/
├── package.json
├── dist/
│   ├── index.js
│   ├── components/
│   │   ├── index.js
│   │   ├── Button/
│   │   │   ├── index.js
│   │   │   └── Button.css
│   │   ├── Card/
│   │   │   ├── index.js
│   │   │   └── Card.css
│   │   └── Modal/
│   │       ├── index.js
│   │       └── Modal.css
│   └── theme/
│       ├── index.js
│       └── defaultTheme.css

包文件内容

package.json

{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": false
}

dist/index.js

export * from './components';
export * from './theme';

dist/components/index.js

export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Modal } from './Modal';

dist/components/Button/index.js

import './Button.css'; // This has a side effect - it applies styles when imported!

export default function Button(props) {
  // Button component implementation
  return {
    type: 'button',
    ...props,
  };
}

dist/components/Button/Button.css

.awesome-ui-button {
  background-color: #0078d7;
  color: white;
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
}

dist/components/Card/index.jsdist/components/Modal/index.js 将具有相似的结构。

dist/theme/index.js

import './defaultTheme.css'; // This has a side effect!

export const themeColors = {
  primary: '#0078d7',
  secondary: '#f3f2f1',
  danger: '#d13438',
};

使用此包时会发生什么?

现在,想象一个只希望使用 Button 组件的消费者应用程序:

import { Button } from 'awesome-ui';

// Use the Button component

在 package.json 中设置 sideEffects: false

当 webpack 在启用摇树优化的情况下处理此导入时:

  1. 它只看到 Button 的导入。
  2. 它查看 package.json 并看到 sideEffects: false
  3. 它确定只需要包含 Button 组件的代码。
  4. 由于所有文件都标记为没有副作用,它将包含 Button 的 JavaScript 代码。
  5. CSS 文件导入被删除! 即使 Button.css 在 Button/index.js 中被导入,webpack 也假定此导入没有副作用。

结果:Button 组件将渲染,但由于 Button.css 在摇树优化期间被删除,因此没有任何样式。

此包的正确配置

为了解决这个问题,我们需要更新 package.json,将 CSS 文件正确标记为具有副作用:

{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": ["**/*.css"]
}

有了这个配置:

  1. Webpack 仍然识别出只需要 Button 组件。
  2. 但现在它识别出 CSS 文件具有副作用。
  3. 因此,在处理 Button/index.js 时,它会包含 Button.css。

副作用决策树

以下是 webpack 在摇树优化期间评估模块的方式:

  1. 此模块的导出是否被直接或间接使用?

    • 如果是:包含该模块。
    • 如果否:继续步骤 2。
  2. 模块是否标记有副作用?

    • 如果是(sideEffects 包含此文件或为 true):包含该模块。
    • 如果否(sideEffectsfalse 或不包含此文件):排除该模块及其依赖项。

对于我们库中具有正确 sideEffects 配置的文件:

  • dist/index.js:没有直接导出使用,没有副作用 -> 跳过
  • dist/components/index.js:没有直接导出使用,没有副作用 -> 跳过
  • dist/components/Button/index.js:直接导出使用 -> 包含
  • dist/components/Button/Button.css:没有导出,有副作用 -> 包含
  • dist/components/Card/*:没有导出使用,没有副作用 -> 排除
  • dist/components/Modal/*:没有导出使用,没有副作用 -> 排除
  • dist/theme/*:没有导出使用,没有副作用 -> 排除

实际影响

不正确的副作用配置可能会产生重大影响:

  1. CSS 未包含:组件在没有样式的情况下渲染。
  2. 全局 JavaScript 未运行:Polyfill 或全局配置未执行。
  3. 初始化代码跳过:注册组件或设置事件监听器的函数从未运行。

这些问题可能特别难以调试,因为它们通常只在启用摇树优化的生产构建中出现。

测试副作用配置

测试副作用配置是否正确的好方法是:

  1. 创建一个只导入一个组件的最小应用程序。
  2. 使用生产设置(启用摇树优化)构建它。
  3. 检查所有必要的样式和行为是否正常工作。
  4. 查看生成的 bundle 以确认包含了正确的文件。

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

可以通过使用 /*#__PURE__*/ 注解告诉 webpack 一个函数调用是无副作用的(纯的)。它可以放在函数调用前面,以将其标记为无副作用。传递给函数的参数不会被注解标记,可能需要单独标记。当未使用的变量的变量声明中的初始值被认为是无副作用的(纯的)时,它会被标记为死代码,不执行并被压缩器删除。当 optimization.innerGraph 设置为 true 时,此行为启用。

file.js

/*#__PURE__*/ double(55);

压缩输出

我们已经通过使用 importexport 语法来提示“死代码”将被删除,但我们仍然需要将其从打包文件中删除。为此,将 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)。通过压缩和摇树优化,我们的 bundle 现在小了几字节!虽然在这个人造示例中看起来不多,但在处理具有复杂依赖树的大型应用程序时,摇树优化可以显著减小 bundle 大小。

副作用的常见陷阱

在使用摇树优化和 sideEffects 标志时,有几个常见的陷阱需要避免:

1. 过于乐观的 sideEffects: false

在 package.json 中设置 sideEffects: false 对于实现最佳摇树优化很有吸引力,但这可能会在您的代码实际具有副作用时导致问题。隐藏副作用的例子包括:

  • CSS 导入(如上所示)
  • 修改全局对象的 Polyfill
  • 注册全局事件监听器的库
  • 修改原型链的代码

2. 带有副作用的重新导出

考虑这种模式:

// This file has side effects that might be skipped
import './polyfill';

// Re-export components
export * from './components';

如果消费者只导入特定组件,那么如果未正确标记副作用,polyfill 导入可能会完全跳过。

3. 忘记嵌套依赖

您的包可能正确标记了副作用,但如果它依赖于错误标记其副作用的第三方包,您仍然可能会遇到问题。

4. 仅在开发模式下测试

摇树优化通常只在生产模式下完全激活。只在开发中测试可能会隐藏摇树优化问题,直到部署。

总结

我们学到的是,为了利用摇树优化,您必须...

  • 使用 ES2015 模块语法(即 importexport)。
  • 确保没有编译器将您的 ES2015 模块语法转换为 CommonJS 模块(这是流行的 Babel 预设 @babel/preset-env 的默认行为 - 有关更多详细信息,请参阅文档)。
  • 在项目的 package.json 文件中添加 "sideEffects" 属性。
  • 注意正确标记具有副作用的文件,尤其是 CSS 导入。
  • 使用 production mode 配置选项来启用各种优化,包括压缩和摇树优化(副作用优化在开发模式下使用标志值启用)。
  • 确保您为 devtool 设置了正确的值,因为其中一些不能在 production 模式下使用。

您可以将您的应用程序想象成一棵树。您实际使用的源代码和库代表了树的绿色、活着的叶子。死代码代表了秋天枯萎、死去的树叶。为了摆脱枯死的叶子,您必须摇晃这棵树,使它们掉落。

如果您对优化输出的更多方法感兴趣,请跳到下一指南以获取有关构建生产环境的详细信息。

生产

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

设置

开发生产构建的目标大相径庭。在开发中,我们希望有强大的源映射和一个带有实时重新加载或模块热替换的 localhost 服务器。在生产中,我们的目标转向专注于压缩打包、更轻量级的源映射以及优化的资产以提高加载时间。鉴于这种逻辑分离,我们通常建议为每个环境编写单独的 webpack 配置

虽然我们将分离出生产开发特定的部分,但请注意,我们仍将保持一个“通用”配置以保持代码简洁。为了将这些配置合并在一起,我们将使用一个名为 webpack-merge 的实用工具。有了“通用”配置,我们就无需在特定于环境的配置中重复代码。

让我们从安装 webpack-merge 并拆分我们在前几指南中已经完成的部分开始:

npm install --save-dev webpack-merge

项目

  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(强源映射)以及我们的 devServer 配置。最后,在 webpack.prod.js 中,mode 设置为 production,它加载了 TerserPlugin,该插件首次在摇树优化指南中介绍。

注意环境特定配置中 merge() 调用的使用,以在 webpack.dev.jswebpack.prod.js 中包含我们的通用配置。webpack-merge 工具提供了各种高级合并功能,但对于我们的用例,我们不需要这些。

NPM 脚本

现在,让我们修改 npm 脚本以使用新的配置文件。对于运行 webpack-dev-serverstart 脚本,我们将使用 webpack.dev.js;对于运行 webpack 创建生产构建的 build 脚本,我们将使用 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"
    }
  }

随意运行这些脚本,看看随着我们不断向生产配置中添加内容,输出会发生什么变化。

指定模式

许多库会根据 process.env.NODE_ENV 变量来确定库中应包含的内容。例如,当 process.env.NODE_ENV 未设置为 'production' 时,某些库可能会添加额外的日志记录和测试以使调试更容易。但是,当 process.env.NODE_ENV 设置为 'production' 时,它们可能会删除或添加大量代码以优化您的实际用户的运行方式。自 webpack v4 以来,指定 mode 会通过 DefinePlugin 自动为您配置 process.env.NODE_ENV

webpack.prod.js

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

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

如果您正在使用像 react 这样的库,在添加 DefinePlugin 后,您应该会看到打包文件大小显著减小。此外,请注意,我们本地的任何 /src 代码也可以利用这一点,因此以下检查是有效的:

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());

压缩

Webpack v4+ 默认会在生产模式下压缩您的代码。

请注意,虽然 TerserPlugin 是压缩的绝佳起点并被默认使用,但还有其他选项:

如果您决定尝试其他压缩插件,请确保您的新选择也像摇树优化指南中描述的那样删除死代码,并将其作为 optimization.minimizer 提供。

源映射

我们鼓励您在生产环境中启用源映射,因为它们对于调试和运行基准测试都很有用。话虽如此,您应该选择一个构建速度相当快且推荐用于生产环境的源映射(参见 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 替代方案

上述许多选项都可以设置为命令行参数。例如,optimization.minimize 可以通过 --optimization-minimize 设置,而 mode 可以通过 --mode 设置。运行 npx webpack --help=verbose 可获取完整的 CLI 参数列表。

虽然这些简写方法很有用,但我们建议在 webpack 配置文件中设置这些选项,以获得更多的可配置性。

懒加载

懒加载,或“按需加载”,是优化您的网站或应用程序的好方法。这种做法本质上是将您的代码在逻辑断点处进行分割,然后一旦用户执行了需要或将需要新代码块的操作,就加载它。这加快了应用程序的初始加载速度,并减轻了其整体重量,因为某些代码块可能永远不会被加载。

动态导入示例

让我们以代码分割中的示例为例,并对其稍作调整,以更清晰地演示这个概念。那里的代码确实导致生成了一个单独的块 lodash.bundle.js,并且在脚本运行后立即“懒加载”了它。问题在于加载这个 bundle 不需要用户交互——这意味着每次页面加载时,请求都会触发。这并没有给我们带来太大帮助,反而会负面影响性能。

让我们尝试一些不同的东西。当用户点击按钮时,我们将添加一个交互来将一些文本记录到控制台。但是,我们将等到第一次交互发生时才加载该代码(print.js)。为此,我们将返回并重构代码分割中的最终动态导入示例,并将 lodash 保留在主块中。

项目

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]
...

延迟导入示例

在某些情况下,将模块的所有用途转换为异步可能很烦人或很困难,因为它强制对所有函数进行不必要的异步化,而无法提供仅延迟同步评估工作的能力。

TC39 提案 延迟模块评估 旨在解决这个问题。

该提案旨在引入一种新的语法导入形式,它将始终只返回一个命名空间特殊对象。使用时,模块及其依赖项将不会执行,但会在模块图被视为已加载之前完全加载到准备执行的状态。

只有在访问此模块的属性时,才会执行操作(如果需要)。

通过启用 experiments.deferImport 可以使用此功能。

项目

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';
+ import defer * as print from './print';

  function component() {
    const element = document.createElement('div');
    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.
+   // In this example, the print module is downloaded but not evaluated,
+   // so there is no network request involved after the button is clicked.
-   button.onclick = e => import(/* webpackChunkName: "print" */ './print').then(module => {
+   button.onclick = e => {
      const print = module.default;
+     //                  ^ The module is evaluated here.

      print();
-   });
+   };

    return element;
  }

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

这类似于 CommonJS 风格的懒加载

src/index.js

  import _ from 'lodash';
- import defer * as print from './print';

  function component() {
    const element = document.createElement('div');
    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);

    // In this example, the print module is downloaded but not evaluated,
    // so there is no network request involved after the button is clicked.
    button.onclick = e => {
+     const print = require('./print');
+     //            ^ The module is evaluated here.
      const print = module.default;
-     //                  ^ The module is evaluated here.

      print();
    };

    return element;
  }

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

框架

许多框架和库都有自己的建议,说明如何在它们的方法中实现这一点。以下是一些示例:

ECMAScript 模块

ECMAScript 模块(ESM)是 Web 中使用模块的规范。所有现代浏览器都支持它,也是 Web 模块化代码编写的推荐方式。

Webpack 支持处理 ECMAScript 模块以对其进行优化。

导出

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

export const CONSTANT = 42;

export let variable = 42;
// only reading is exposed
// it's not possible to modify the variable from outside

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';
// import "bindings" to exports from another module
// these bindings are live. The values are not copied,
// instead accessing "variable" will get the current value
// in the imported module

import * as module from './module.js';
module.fun();
// import the "namespace object" which contains all exports

import theDefaultValue from './module.js';
// shortcut to import the "default" export

将模块标记为 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。

在 DataURI 中,使用 text/javascriptapplication/javascript MIME 类型也将强制模块类型为 ESM。

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

ESM 中的导入解析更严格。除非您通过 fullySpecified=false 禁用此行为,否则相对请求必须包含文件名和文件扩展名(例如 *.js*.mjs)。

只有“default”导出可以从非 ESM 导入。命名导出不可用。

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

垫片 (Shimming)

webpack 编译器可以理解用 ES2015 模块、CommonJS 或 AMD 编写的模块。然而,一些第三方库可能期望全局依赖(例如 jQuery$)。这些库也可能创建需要导出的全局变量。这些“损坏的模块”是垫片发挥作用的一个实例。

垫片在另一种情况下也很有用,即当您想 polyfill 浏览器功能以支持更多用户时。在这种情况下,您可能只想将这些 polyfill 提供给需要修补的浏览器(即按需加载它们)。

以下文章将详细介绍这两种用例。

垫片全局变量

让我们从第一个垫片全局变量的用例开始。在做任何事情之前,让我们再看看我们的项目:

项目

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

还记得我们正在使用的 lodash 包吗?为了演示目的,假设我们想在整个应用程序中将其作为全局变量提供。为此,我们可以使用 ProvidePlugin

ProvidePlugin 将一个包作为变量提供给通过 webpack 编译的每个模块。如果 webpack 看到该变量被使用,它将把给定的包包含在最终的打包文件中。让我们继续,删除 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 包并将其提供给需要它的模块。

如果我们运行构建,我们应该仍然看到相同的输出。

$ 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 方法的地方提供 lodashjoin 方法:

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'],
     }),
   ],
 };

这与摇树优化完美配合,因为 lodash 库的其余部分应该会被删除。

细粒度垫片

一些遗留模块依赖于 thiswindow 对象。让我们更新 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());

当模块在 this 等于 module.exports 的 CommonJS 上下文中执行时,这会成为一个问题。在这种情况下,您可以使用 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'],
     }),
   ],
 };

全局导出

假设一个库创建了一个全局变量,并期望其消费者使用它。我们可以在设置中添加一个小型模块来演示这一点:

项目

  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');
  },
};

现在,虽然您在自己的源代码中可能永远不会这样做,但您可能会遇到一个过时的库,其中包含与上面所示相似的代码,并且您希望使用它。在这种情况下,我们可以使用 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'],
     }),
   ],
 };

现在,在我们的入口脚本(即 src/index.js)中,我们可以使用 const { file, parse } = require('./globals.js');,一切都应该顺利运行。

加载 Polyfill

到目前为止,我们讨论的一切都与处理旧版软件包有关。让我们继续讨论第二个主题:polyfill

有很多方法可以加载 polyfill。例如,要包含 babel-polyfill,我们可能需要:

npm install --save babel-polyfill

并将其 import 进来以包含在我们的主打包文件中:

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());

请注意,这种方法优先考虑正确性而非打包文件大小。为了安全和健壮,polyfill/shims 必须在所有其他代码之前运行,因此需要同步加载,或者所有应用程序代码需要在所有 polyfill/shims 加载之后才能加载。社区中也有许多误解,认为现代浏览器“不需要” polyfill,或者 polyfill/shims 仅仅是为了添加缺失的功能——事实上,它们通常会修复损坏的实现,即使是在最现代的浏览器中也是如此。因此,最佳实践仍然是无条件地同步加载所有 polyfill/shims,尽管这会增加打包文件大小。

如果您觉得您已经解决了这些问题并希望承担破坏的风险,这里有一种可能的方法:让我们将 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());

项目

  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>

现在我们可以在入口脚本中 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 包使用 browserslist 来仅转译您的浏览器矩阵中不支持的内容。此预设带有 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';

更多信息请参阅 babel-preset-env 文档

Node 内置模块

Node 内置模块,例如 process,可以直接从您的配置文件中进行 polyfill,而无需使用任何特殊的加载器或插件。有关更多信息和示例,请参阅 node 配置页面

其他实用工具

还有一些其他工具可以在处理旧版模块时提供帮助。

当模块没有 AMD/CommonJS 版本并且您想包含 dist 时,您可以在 noParse 中标记此模块。这将导致 webpack 包含该模块而不对其进行解析或解析 require()import 语句。这种做法也用于提高构建性能。

最后,有些模块支持多种模块样式;例如 AMD、CommonJS 和遗留模块的组合。在大多数这些情况下,它们首先检查 define,然后使用一些古怪的代码来导出属性。在这些情况下,通过 imports-loader 设置 additionalCode=var%20define%20=%20false; 可能有助于强制执行 CommonJS 路径。

TypeScript

TypeScript 是 JavaScript 的一个带类型超集,它编译为纯 JavaScript。在本指南中,我们将学习如何将 TypeScript 与 webpack 集成。

基础设置

首先通过运行以下命令安装 TypeScript 编译器和加载器:

npm install --save-dev typescript ts-loader

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

项目

  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'),
  },
};

这将指示 webpack 从 ./index.ts 进入,通过 ts-loader 加载所有 .ts.tsx 文件,并将 bundle.js 文件输出到当前目录。

现在让我们更改 ./index.tslodash 的导入,因为 lodash 定义中没有默认导出。

./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());

webpack.config.ts 中使用 TypeScript 的方法

webpack.config.ts 中使用 TypeScript 有 5 种方式:

  1. 使用带 TypeScript 配置的 webpack

    webpack -c ./webpack.config.ts

    (由于 rechoirinterpret 的限制,并非所有功能都受支持。)

  2. 为 Node.js 使用自定义 --import

    NODE_OPTIONS='--import tsx'  webpack --disable-interpret -c ./webpack.config.ts
  3. 为 Node.js v22.7.0 ≥ 您的 Node.js 版本 < v23.6.0 使用内置 TypeScript 模块

    NODE_OPTIONS='--experimental-strip-types' webpack --disable-interpret -c ./webpack.config.ts
  4. 为 Node.js ≥ v22.6.0 使用内置 TypeScript 模块

    webpack --disable-interpret -c ./webpack.config.ts
  5. 为 Node.js ≥ v22.6.0 使用 tsx

    NODE_OPTIONS='--no-experimental-strip-types --import tsx' webpack --disable-interpret -c ./webpack.config.ts

加载器

ts-loader

我们在本指南中使用 ts-loader,因为它使启用其他 webpack 功能(例如导入其他 Web 资产)变得更容易。

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

源映射

要了解有关源映射的更多信息,请参阅开发指南

要启用源映射,我们必须配置 TypeScript 将内联源映射输出到我们编译后的 JavaScript 文件。以下行必须添加到我们的 TypeScript 配置中:

tsconfig.json

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

现在我们需要告诉 webpack 提取这些源映射并包含在我们的最终打包文件中:

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。Webpack 也为它们提供了类型,添加一个 TypeScript reference 指令来声明它:

/// <reference types="webpack/module" />
console.log(import.meta.webpack); // without reference declared above, TypeScript will throw an error

使用第三方库

从 npm 安装第三方库时,务必记住安装该库的类型定义。

例如,如果我们想安装 lodash,我们可以运行以下命令来获取其类型定义:

npm install --save-dev @types/lodash

如果 npm 包中已包含其声明类型文件,则无需下载相应的 @types 包。更多信息请参阅 TypeScript 变更日志博客

导入其他资产

要将非代码资产与 TypeScript 一起使用,我们需要延迟这些导入的类型。这需要一个 custom.d.ts 文件,它表示我们项目中 TypeScript 的自定义定义。让我们为 .svg 文件设置一个声明:

custom.d.ts

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

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

构建性能

请参阅构建性能指南中的构建工具部分。

Web Workers

从 webpack 5 开始,您可以无需 worker-loader 即可使用 Web Workers

语法

new Worker(new URL('./worker.js', import.meta.url));
// or customize the chunk name with magic comments
// see https://webpack.js.cn/api/module-methods/#magic-comments
new Worker(
  /* webpackChunkName: "foo-worker" */ new URL('./worker.js', import.meta.url)
);

选择此语法是为了允许在没有打包器的情况下运行代码,它也可在浏览器中的原生 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 中可用。Webpack 或 Node.js 都不支持 CommonJS 语法中的 Worker

渐进式 Web 应用

渐进式 Web 应用(或称 PWA)是提供类似于原生应用程序体验的 Web 应用。有许多因素促成了这一点。其中,最重要的是应用能够在**离线**时运行的能力。这通过使用名为 Service Workers 的 Web 技术实现。

本节将重点介绍如何为我们的应用添加离线体验。我们将使用一个名为 Workbox 的 Google 项目来实现,它提供了有助于更轻松地为 Web 应用设置离线支持的工具。

我们目前无法离线工作

到目前为止,我们一直通过直接访问本地文件系统来查看输出。然而,通常真实用户通过网络访问 Web 应用;他们的浏览器与一个**服务器**通信,该服务器会提供所需的资源(例如 .html.js.css 文件)。

那么,让我们使用一个具有更基本功能的服务器来测试当前体验。让我们使用 http-server 包:npm install http-server --save-dev。我们还将修改 package.jsonscripts 部分,添加一个 start 脚本。

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

如果您在浏览器中打开 https://:8080(即 http://127.0.0.1),您应该会看到您的 webpack 应用从 dist 目录提供。如果您停止服务器并刷新,webpack 应用将不再可用。

这就是我们希望改变的地方。一旦我们完成本模块,我们应该能够停止服务器,刷新页面,并且仍然看到我们的应用。

添加 Workbox

让我们添加 Workbox webpack 插件并调整 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({
+       // these options encourage the ServiceWorkers to get in there fast
+       // and not allow any straggling "old" SWs to hang around
+       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 启动服务。导航到 https://:8080 并查看控制台。您应该会在其中看到:

SW registered

现在来测试一下。停止服务器并刷新页面。如果您的浏览器支持 Service Workers,那么您应该仍然能看到您的应用。然而,它是通过您的 Service Worker 而**不是**服务器提供的。

总结

您已使用 Workbox 项目构建了一个离线应用。您已开始将您的 Web 应用转变为 PWA 的旅程。您现在可能想进一步探索。一个能帮助您的好资源可以在这里找到。

公共路径

publicPath 配置选项在各种场景中都非常有用。它允许您为应用中的所有资源指定基本路径。

用例

在实际应用中,有几个用例使得此功能变得特别巧妙。本质上,每个输出到 output.path 目录的文件都将从 output.publicPath 位置引用。这包括(通过代码分割创建的)子块以及依赖图中的任何其他资源(例如图像、字体等)。

基于环境

例如,在开发环境中,我们可能有一个 assets/ 文件夹与我们的索引页处于同一级别。这很好,但如果我们在生产环境中希望将所有这些静态资源托管在 CDN 上怎么办?

要解决这个问题,您可以轻松使用一个旧的环境变量。假设我们有一个变量 ASSET_PATH

import webpack from 'webpack';

// Try the environment variable, otherwise use root
const ASSET_PATH = process.env.ASSET_PATH || '/';

export default {
  output: {
    publicPath: ASSET_PATH,
  },

  plugins: [
    // This makes it possible for us to safely use env vars on our code
    new webpack.DefinePlugin({
      'process.env.ASSET_PATH': JSON.stringify(ASSET_PATH),
    }),
  ],
};

动态

另一个可能的用例是动态设置 publicPath。Webpack 暴露了一个名为 __webpack_public_path__ 的全局变量,允许您这样做。在您的应用入口点,您可以这样做:

__webpack_public_path__ = process.env.ASSET_PATH;

这就是您所需要的一切。由于我们已经在配置中使用了 DefinePluginprocess.env.ASSET_PATH 将始终被定义,因此我们可以安全地这样做。

// entry.js
import './public-path';
import './app';

自动公共路径

有时您可能无法提前知道 publicPath 是什么,webpack 可以通过从 import.meta.urldocument.currentScriptscript.srcself.location 等变量确定公共路径来自动处理。您需要将 output.publicPath 设置为 'auto'

webpack.config.js

module.exports = {
  output: {
    publicPath: 'auto',
  },
};

请注意,在不支持 document.currentScript 的情况下,例如 IE 浏览器,您将不得不包含一个像 currentScript Polyfill 这样的 polyfill。

集成

让我们首先澄清一个常见的误解。Webpack 是一个模块打包器,类似于 BrowserifyBrunch。它*不是*任务运行器,不像 MakeGruntGulp。任务运行器处理常见开发任务的自动化,例如代码检查、构建或测试项目。与打包器相比,任务运行器更关注高层次任务。您仍然可以从它们的高级工具中受益,同时将打包问题留给 webpack。

打包器帮助您准备好 JavaScript 和样式表以供部署,将它们转换为适合浏览器的格式。例如,JavaScript 可以被压缩拆分为块延迟加载以提高性能。打包是 Web 开发中最重要的挑战之一,很好地解决它可以在此过程中消除很多痛苦。

好消息是,尽管存在一些重叠,但如果方法得当,任务运行器和打包器可以很好地协同工作。本指南提供了 webpack 如何集成到一些更流行的任务运行器中的高级概述。

NPM 脚本

通常,webpack 用户使用 npm scripts 作为他们的任务运行器。这是一个很好的起点。跨平台支持可能会成为一个问题,但有几种解决方法。许多(如果不是大多数)用户通过 npm scripts 以及不同级别的 webpack 配置和工具来完成工作。

因此,虽然 webpack 的核心重点是打包,但也有各种扩展可以使您将其用于任务运行器的典型工作。集成一个单独的工具会增加复杂性,因此在继续之前务必权衡利弊。

Grunt

对于使用 Grunt 的用户,我们推荐 grunt-webpack 包。使用 grunt-webpack,您可以将 webpack 或 webpack-dev-server 作为任务运行,在模板标签中获取统计信息,拆分开发和生产配置等等。如果您尚未安装,请首先安装 grunt-webpack 以及 webpack 本身。

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 包(又名 gulp-webpack),Gulp 的集成也相当直接。在这种情况下,不需要单独安装 webpack,因为它是 webpack-stream 的直接依赖项。

npm install --save-dev webpack-stream

您可以 require('webpack-stream') 而不是 webpack,并且可以选择性地向其传递配置。

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 的简洁集成。该仓库提供了有关优缺点的更多详细信息,但本质上 mocha-webpack 是一个简单的包装器,提供与 Mocha 本身几乎相同的 CLI,并提供各种 webpack 功能,例如改进的观察模式和改进的路径解析。以下是您如何安装它并使用它来运行测试套件(位于 ./test 中)的一个小示例:

npm install --save-dev webpack mocha mocha-webpack
mocha-webpack 'test/**/*.js'

更多信息请访问仓库

Karma

karma-webpack 包允许您在 Karma 中使用 webpack 预处理文件。

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'],
  });
};

更多信息请访问仓库

高级入口

每个入口多种文件类型

当对 entry 使用值数组时,可以提供不同类型的文件,以在未在 JavaScript 中使用 import 样式(单页应用之前或出于其他原因)的应用中,为 CSS 和 JavaScript(以及其他)文件实现单独的打包。

让我们举个例子。我们有一个 PHP 应用,包含两种页面类型:主页和账户页。主页具有不同的布局和与应用其余部分(账户页)不共享的 JavaScript。我们希望从应用文件中为主页输出 home.jshome.css,为账户页输出 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

我们将使用 MiniCssExtractPluginproduction 模式下处理 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

资源模块

资源模块允许在不配置额外加载器的情况下使用资源文件(字体、图标等)。

在 webpack 5 之前,通常使用:

资源模块类型通过添加 4 种新的模块类型来替换所有这些加载器:

  • asset/resource 输出一个单独的文件并导出 URL。以前通过使用 file-loader 实现。
  • asset/inline 导出资源的 data URI。以前通过使用 url-loader 实现。
  • asset/source 导出资源的源代码。以前通过使用 raw-loader 实现。
  • asset 自动选择导出数据 URI 还是输出单独的文件。以前通过设置资源大小限制的 url-loader 实现。

在 webpack 5 中,当旧资源加载器(即 file-loader/url-loader/raw-loader)与资源模块一起使用时,您可能希望阻止资源模块再次处理您的资源,因为这会导致资源重复。可以通过将资源的模块类型设置为 'javascript/auto' 来实现。

webpack.config.js

module.exports = {
  module: {
   rules: [
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            }
          },
        ],
+       type: 'javascript/auto'
      },
   ]
  },
}

要从资源加载器中排除来自新 URL 调用的资源,请将 dependency: { not: ['url'] } 添加到加载器配置中。

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/i,
+       dependency: { not: ['url'] },
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },
    ],
  }
}

公共路径

默认情况下,在底层,asset 类型执行 __webpack_public_path__ + import.meta。这意味着在您的配置中设置 output.publicPath 将允许您覆盖 asset 加载的 URL。

动态覆盖

如果您在代码中设置 __webpack_public_path__,为了不破坏 asset 加载逻辑,您需要确保它在应用中作为第一个代码运行,并且不使用函数来实现。一个例子是创建一个名为 publicPath.js 的文件,其内容如下:

__webpack_public_path__ = 'https://cdn.url.com';

然后更新您的 webpack.config.js 中的 entry 字段,使其看起来像:

module.exports = {
  entry: ['./publicPath.js', './App.js'],
};

或者,您可以在 App.js 中执行以下操作,而无需修改 webpack 配置。唯一的缺点是您必须在此处强制执行顺序,这可能会与某些代码检查工具发生冲突。

import './publicPath.js';

资源资产

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 文件都将输出到输出目录,其路径将注入到打包中。此外,您可以自定义它们的 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 模块类型。

内联资源

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 文件都将作为数据 URI 注入到打包中。

自定义数据 URI 生成器

默认情况下,webpack 发出的数据 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 包进行编码。

源资源

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 文件将按原样注入到打包中。

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 起,new URL() 中也支持 数据 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 配置的模块规则级别设置 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
+         }
+       }
      }
    ]
  },
};

您还可以指定一个函数来决定是否内联模块。

替换内联加载器语法

在资源模块和 Webpack 5 之前,可以使用上述传统加载器的内联语法

现在建议删除所有内联加载器语法,并使用 resourceQuery 条件来模拟内联语法的功能。

例如,在用 asset/source 类型替换 raw-loader 的情况下:

- import myModule from 'raw-loader!my-module';
+ import myModule from 'my-module?raw';

以及在 webpack 配置中:

module: {
    rules: [
    // ...
+     {
+       resourceQuery: /raw/,
+       type: 'asset/source',
+     }
    ]
  },

如果您想排除原始资源不被其他加载器处理,请使用负条件:

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: [ ... ]
+       },
+     ] }
    ]
  },

禁用资源输出

对于像服务器端渲染这样的用例,您可能希望禁用资源输出,这可以通过 Rule.generator 下的 emit 选项实现。

module.exports = {
  // …
  module: {
    rules: [
      {
        test: /\.png$/i,
        type: 'asset/resource',
        generator: {
          emit: false,
        },
      },
    ],
  },
};

包导出

包的 package.json 中的 exports 字段允许在使用 import "package"import "package/sub/path" 等模块请求时声明应使用哪个模块。它取代了默认实现,即为 "package" 返回 main 字段或 index.js 文件,以及为 "package/sub/path" 进行文件系统查找的默认实现。

当指定 exports 字段时,只有这些模块请求可用。任何其他请求都将导致 ModuleNotFound 错误。

通用语法

通常,exports 字段应包含一个对象,其中每个属性指定模块请求的子路径。对于上述示例,可以使用以下属性:"." 用于 import "package""./sub/path" 用于 import "package/sub/path"。以 / 结尾的属性会将带有此前缀的请求转发到旧的文件系统查找算法。对于以 * 结尾的属性,* 可以取任何值,并且属性值中的任何 * 都将替换为所取的值。

示例

{
  "exports": {
    ".": "./main.js",
    "./sub/path": "./secondary.js",
    "./prefix/": "./directory/",
    "./prefix/deep/": "./other-directory/",
    "./other-prefix/*": "./yet-another/*/*.js"
  }
}
模块请求结果
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.js错误

替代方案

除了提供单个结果外,包作者还可以提供一个结果列表。在这种情况下,此列表将按顺序尝试,并使用第一个有效结果。

注意:仅使用第一个有效结果,而不是所有有效结果。

示例

{
  "exports": {
    "./things/": ["./good-things/", "./bad-things/"]
  }
}

这里 package/things/apple 可能会在 .../package/good-things/apple.../package/bad-things/apple 中找到。

例如,给定以下配置:

{
  "exports": {
    ".": ["-bad-specifier-", "./non-existent.js", "./existent.js"]
  }
}

Webpack 5.94.0+ 现在将抛出错误,因为找不到 non-existent.js,而之前的行为会解析到 existent.js

条件语法

包作者可以不直接在 exports 字段中提供结果,而是让模块系统根据环境条件选择一个。

在这种情况下,应使用一个将条件映射到结果的对象。条件按对象顺序尝试。包含无效结果的条件将被跳过。条件可以嵌套以创建逻辑 AND。对象中的最后一个条件可以是特殊的 "default" 条件,它总是匹配的。

示例

{
  "exports": {
    ".": {
      "red": "./stop.js",
      "yellow": "./stop.js",
      "green": {
        "free": "./drive.js",
        "default": "./wait.js"
      },
      "default": "./drive-carefully.js"
    }
  }
}

这可以转化为类似以下内容:

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();

可用条件因所使用的模块系统和工具而异。

缩写

当包中只支持一个入口(".")时,可以省略 { ".": ... } 对象嵌套。

{
  "exports": "./index.mjs"
}
{
  "exports": {
    "red": "./stop.js",
    "green": "./drive.js"
  }
}

关于排序的注意事项

在每个键都是条件的对象中,属性的顺序是重要的。条件按其指定的顺序处理。

示例:{ "red": "./stop.js", "green": "./drive.js" } != { "green": "./drive.js", "red": "./stop.js" }(当 redgreen 条件都设置时,将使用第一个属性)

在每个键都是子路径的对象中,属性(子路径)的顺序不重要。更具体的路径优先于不那么具体的路径。

示例:{ "./a/": "./x/", "./a/b/": "./y/", "./a/b/c": "./z" } == { "./a/b/c": "./z", "./a/b/": "./y/", "./a/": "./x/" }(顺序将始终是:./a/b/c > ./a/b/ > ./a/

exports 字段优先于其他包入口字段,如 mainmodulebrowser 或自定义字段。

支持

功能支持者
"." 属性Node.js、webpack、rollup、esinstall、wmr
普通属性Node.js、webpack、rollup、esinstall、wmr
/ 结尾的属性Node.js(1)、webpack、rollup、esinstall(2)、wmr(3)
* 结尾的属性Node.js、webpack、rollup、esinstall
替代方案Node.js、webpack、rollup、esinstall(4)
仅缩写路径Node.js、webpack、rollup、esinstall、wmr
仅缩写条件Node.js、webpack、rollup、esinstall、wmr
条件语法Node.js、webpack、rollup、esinstall、wmr
嵌套条件语法Node.js、webpack、rollup、wmr(5)
条件顺序Node.js、webpack、rollup、wmr(6)
"default" 条件Node.js、webpack、rollup、esinstall、wmr
路径顺序Node.js、webpack、rollup
未映射时报错Node.js、webpack、rollup、esinstall、wmr(7)
混合条件和路径时报错Node.js、webpack、rollup

(1) 在 Node.js 17 中移除。请改用 *

(2) "./" 被有意地忽略为键。

(3) 属性值被忽略,属性键被用作目标。实际上只允许键和值相同的映射。

(4) 支持该语法,但始终使用第一个入口,这使其无法用于任何实际用例。

(5) 回退到替代的同级父条件处理不正确。

(6) 对于 require 条件,对象顺序处理不正确。这是有意为之,因为 wmr 不区分引用语法。

(7) 当使用 "exports": "./file.js" 缩写时,任何请求(例如 package/not-existing)都将解析到该文件。不使用缩写时,直接的文件访问(例如 package/file.js)不会导致错误。

条件

引用语法

根据用于引用模块的语法,将设置以下条件之一:

条件描述支持者
import请求来自 ESM 语法或类似语法。Node.js、webpack、rollup、esinstall(1)、wmr(1)
require请求来自 CommonJs/AMD 语法或类似语法。Node.js、webpack、rollup、esinstall(1)、wmr(1)
style请求来自样式表引用。-
sass请求来自 sass 样式表引用。-
asset请求来自资源引用。-
script请求来自没有模块系统的普通脚本标签。-

这些条件也可能额外设置:

条件描述支持者
module所有允许引用 JavaScript 的模块语法都支持 ESM。
(仅与 importrequire 结合使用)
webpack、rollup、wmr
esmodules受支持的工具总是设置此项。wmr
types请求来自对类型声明感兴趣的 TypeScript。-

(1) importrequire 都被设置,与引用语法无关。require 总是具有较低的优先级。

import

以下语法将设置 import 条件:

  • ESM 中的 ESM import 声明
  • JS import() 表达式
  • HTML 中的 HTML <script type="module">
  • HTML 中的 HTML <link rel="preload/prefetch">
  • JS new Worker(..., { type: "module" })
  • WASM import 部分
  • ESM HMR (webpack) import.hot.accept/decline([...])
  • JS Worklet.addModule
  • 使用 JavaScript 作为入口点

require

以下语法将设置 require 条件:

  • 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

以下语法将设置 style 条件:

  • CSS @import
  • HTML <link rel="stylesheet">

asset

以下语法将设置 asset 条件:

  • CSS url()
  • ESM new URL(..., import.meta.url)
  • HTML <img src="...">

script

以下语法将设置 script 条件:

  • HTML <script src="...">

script 只应在不支持模块系统时设置。当脚本被支持 CommonJs 的系统预处理时,它应该设置 require

此条件应用于查找可在 HTML 页面中作为脚本标签注入而无需额外预处理的 JavaScript 文件。

优化

以下条件用于各种优化:

条件描述支持者
生产环境在生产环境中。
不应包含开发工具。
webpack
development在开发环境中。
应包含开发工具。
webpack

注意:由于并非所有工具都支持 productiondevelopment,因此在未设置这些条件时,不应做任何假设。

目标环境

以下条件根据目标环境设置:

条件描述支持者
browser代码将在浏览器中运行。webpack、esinstall、wmr
electron代码将在 Electron 中运行。(1)webpack
worker代码将在 (Web)Worker 中运行。(1)webpack
worklet代码将在 Worklet 中运行。(1)-
node代码将在 Node.js 中运行。Node.js、webpack、wmr(2)
deno代码将在 Deno 中运行。-
react-native代码将在 react-native 中运行。-

(1) electronworkerworklet 会与 nodebrowser 结合使用,具体取决于上下文。

(2) 这为浏览器目标环境设置。

由于每个环境都有多个版本,因此适用以下准则:

  • node:兼容性请参见 engines 字段。
  • browser:与发布包时的当前规范和第 4 阶段提案兼容。polyfill 或转译必须在消费者端处理。
    • 无法 polyfill 或转译的功能应谨慎使用,因为它会限制可能的使用范围。
  • deno:待定
  • react-native:待定

条件:预处理器和运行时

以下条件根据预处理源代码的工具而设置。

条件描述支持者
webpack由 webpack 处理。webpack

遗憾的是,Node.js 作为运行时没有 node-js 条件。这将简化为 Node.js 创建例外的情况。

条件:自定义

以下工具支持自定义条件:

工具支持备注
Node.js使用 --conditions CLI 参数。
webpack使用 resolve.conditionNames 配置选项。
rollup@rollup/plugin-node-resolve 使用 exportConditions 选项。
esinstall-
wmr-

对于自定义条件,建议使用以下命名方案:

<公司名称>:<条件名称>

示例:example-corp:betagoogle:internal

常见模式

所有模式都通过包中的单个 "." 入口进行解释,但它们也可以从多个入口扩展,只需为每个入口重复该模式即可。

这些模式应作为指导而非严格的规则集。它们可以根据各个包进行调整。

这些模式基于以下目标/假设列表:

  • 包正在“腐烂”。
    • 我们假设在某个时候,包不再被维护,但它们仍然被继续使用。
    • exports 应该编写为对未知的未来情况使用回退。default 条件可以用于此。
    • 由于未来未知,我们假设一个类似于浏览器和 ESM 模块系统的环境。
  • 并非所有工具都支持所有条件。
    • 应使用回退来处理这些情况。
    • 我们假设以下回退通常是合理的:
      • ESM > CommonJs
      • 生产 > 开发
      • 浏览器 > node.js

根据包的意图,可能有其他更合适的方案,在这种情况下,应采用相应的模式。例如:对于命令行工具,浏览器式的未来和回退没有太大意义,在这种情况下,应改用 Node.js 类似的环境和回退。

对于复杂的用例,需要通过嵌套这些条件来组合多个模式。

目标环境无关的包

这些模式适用于不使用特定于环境的 API 的包。

仅提供 ESM 版本

{
  "type": "module",
  "exports": "./index.js"
}

注意:仅提供 ESM 对 Node.js 有限制。这样的包只会在 Node.js >= 14 版本中且仅在使用 import 时工作。它不适用于 require()

提供 CommonJs 和 ESM 版本(无状态)

{
  "type": "module",
  "exports": {
    "node": {
      "module": "./index.js",
      "require": "./index.cjs"
    },
    "default": "./index.js"
  }
}

大多数工具获取 ESM 版本。Node.js 在这里是一个例外。当使用 require() 时,它会获取 CommonJs 版本。当使用 require()import 引用它时,这会导致该包的两个实例,但这不会造成损害,因为该包是无状态的。

当使用支持 ESM for require() 的工具(例如为 Node.js 打包时使用的打包器)预处理面向 Node 的代码时,module 条件被用作优化。对于这样的工具,将跳过此例外。这在技术上是可选的,但否则打包器会两次包含包的源代码。

如果您能够将包状态隔离在 JSON 文件中,也可以使用无状态模式。JSON 可以从 CommonJs 和 ESM 中消费,而不会用其他模块系统污染图。

请注意,这里的无状态也意味着类实例不通过 instanceof 进行测试,因为双重模块实例化可能导致存在两个不同的类。

提供 CommonJs 和 ESM 版本(有状态)

{
  "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;

在一个有状态的包中,我们必须确保该包永远不会被实例化两次。

这对大多数工具来说不是问题,但 Node.js 在这里再次是个例外。对于 Node.js,我们总是使用 CommonJs 版本,并通过 ESM 包装器在 ESM 中暴露命名导出。

我们再次将 module 条件用作优化。

仅提供 CommonJs 版本

{
  "type": "commonjs",
  "exports": "./index.js"
}

提供 "type": "commonjs" 有助于静态检测 CommonJs 文件。

提供用于直接浏览器消费的打包脚本版本

{
  "type": "module",
  "exports": {
    "script": "./dist-bundle.js",
    "default": "./index.js"
  }
}

请注意,尽管 dist-bundle.js 使用了 "type": "module".js,但此文件并非 ESM 格式。它应该使用全局变量以允许作为脚本标签直接消费。

提供开发工具或生产优化

当一个包包含两个版本(一个用于开发,一个用于生产)时,这些模式是合理的。例如,开发版本可以包含额外的代码以提供更好的错误消息或额外的警告。

不带 Node.js 运行时检测

{
  "type": "module",
  "exports": {
    "development": "./index-with-devtools.js",
    "default": "./index-optimized.js"
  }
}

当支持 development 条件时,我们使用为开发而增强的版本。否则,在生产环境或模式未知时,我们使用优化版本。

带 Node.js 运行时检测

{
  "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');
}

我们倾向于通过 productiondevelopment 条件进行生产/开发模式的静态检测。

Node.js 允许通过 process.env.NODE_ENV 在运行时检测生产/开发模式,因此我们在 Node.js 中将其用作回退。同步条件导入 ESM 是不可能的,并且我们不希望加载两次包,因此我们必须使用 CommonJs 进行运行时检测。

当无法检测模式时,我们回退到生产版本。

根据目标环境提供不同版本

应选择一个对包支持未来环境有意义的回退环境。通常应假定为类似浏览器的环境。

提供 Node.js、WebWorker 和浏览器版本

{
  "type": "module",
  "exports": {
    "node": "./index-node.js",
    "worker": "./index-worker.js",
    "default": "./index.js"
  }
}

提供 Node.js、浏览器和 Electron 版本

{
  "type": "module",
  "exports": {
    "electron": {
      "node": "./index-electron-node.js",
      "default": "./index-electron.js"
    },
    "node": "./index-node.js",
    "default": "./index.js"
  }
}

组合模式

示例 1

这是一个包的示例,它针对生产和开发使用进行了优化,带有 process.env 的运行时检测,并同时发布 CommonJs 和 ESM 版本。

{
  "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"
  }
}

示例 2

这是一个包的示例,它支持 Node.js、浏览器和 Electron,具有针对生产和开发使用进行优化的功能,带有 process.env 的运行时检测,并同时发布 CommonJs 和 ESM 版本。

{
  "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"
  }
}

看起来很复杂,是的。我们已经能够通过一个假设来减少一些复杂性:只有 node 需要 CommonJs 版本,并且可以通过 process.env 检测生产/开发模式。

指南

  • 避免使用 default 导出。它在不同工具之间处理方式不同。只使用命名导出。
  • 切勿为不同条件提供不同的 API 或语义。
  • 将您的源代码编写为 ESM,并通过 Babel、TypeScript 或类似工具将其转译为 CJS。
  • package.json 中使用 .cjstype: "commonjs" 来明确将源代码标记为 CommonJs。这使得工具可以静态检测是使用了 CommonJs 还是 ESM。这对于只支持 ESM 而不支持 CommonJs 的工具来说很重要。
  • 包中使用的 ESM 支持以下类型的请求:
    • 支持模块请求,指向具有 package.json 的其他包。
    • 支持相对请求,指向包内的其他文件。
      • 它们不得指向包外部的文件。
    • 支持 data: URL 请求。
    • 其他绝对或服务器相对请求默认不受支持,但某些工具或环境可能支持它们。

1 贡献者

webpack