基本安装

首先我们创建一个目录,初始化 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.

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

project

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

src/index.js

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

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

  return element;
}

document.body.appendChild(component());

index.html

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

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

Tip

如果你想要了解 package.json 内在机制的更多信息,我们推荐阅读 npm 文档

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 来管理这些脚本。

创建一个 bundle

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

project

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

你可能会发现,尽管 index.html 目前放在 dist 目录下,但它是手动创建的。在指南的 后续章节 中,我们会教你如何生成 index.html 而非手动编辑它。如此做,便可安全地清空 dist 目录并重新生成目录中的所有文件。

我们需要在本地安装 lodash 依赖,以在 index.js 中打包它:

npm install --save lodash
Tip

安装一个将被打包到生产环境 bundle 的 package 时,应该使用 npm install --save;而在安装一个用于开发环境的 package 时(例如,linter、测试库等),应该使用 npm install --save-dev。更多信息请查看 npm 文档

现在,在我们的脚本中导入 lodash

src/index.js

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

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

   return element;
 }

 document.body.appendChild(component());

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

dist/index.html

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

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

换言之,执行 npx webpack 会将我们的脚本 src/index.js 作为 入口起点,也会生成 dist/main.js 作为 输出。Node 8.2/npm 5.2.0 及以上版本提供的 npx 命令,可以运行在最初安装的 webpack package 中的 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
Tip

输出可能会稍有不同,但是只要构建成功,那么你就可以放心继续。

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

模块

ES2015 中的 import 和 export 语句已经被标准化。虽然大多数浏览器还无法支持它们,但是 webpack 却能够提供开箱即用般的支持。

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

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

使用配置文件

在 webpack v4 中,无须任何配置即可运行,然而大多数项目会需要很复杂的设置,这就是为什么 webpack 仍然要支持 配置文件。这比在 terminal(终端)中手动输入大量命令要更加高效,所以让我们创建一个配置文件:

project

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

webpack.config.js

const path = require('path');

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

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

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

如果 webpack.config.js 存在,则 webpack 命令将默认选择使用它。我们在这里使用 --config 选项只是向你表明可以传递任何名称的配置文件。这对于需要拆分成多个文件的复杂配置是非常有用的。

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

npm scripts

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

package.json

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

现在,可以使用 npm run build 命令替代之前使用的 npx 命令。注意,使用 npm scripts 便可以像使用 npx 那样通过模块名引用本地安装的 npm packages。这是大多数基于 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
Tip

可以通过在 npm run build 命令与参数之间添加两个连接符的方式向 webpack 传递自定义参数,例如:npm run build -- --color

总结

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

project

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

不要使用 webpack 编译不可信的代码。它可能会在你的计算机,远程服务器或者在你 web 应用程序使用者的浏览器中执行恶意代码。

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

管理资源

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

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

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

设置

在开始之前,让我们对项目做一个小的修改:

dist/index.html

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

webpack.config.js

 const path = require('path');

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

加载 CSS

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

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

webpack.config.js

 const path = require('path');

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

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

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

Tip

webpack 根据正则表达式确定应该查找哪些文件,并将其提供给指定的 loader。在此示例中,所有以 .css 结尾的文件,都将被提供给 style-loader 和 css-loader

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

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

project

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

src/style.css

.hello {
  color: red;
}

src/index.js

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

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

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

   return element;
 }

 document.body.appendChild(component());

然后运行构建命令:

$ npm run build

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

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

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

加载图像

假如,现在正在下载 CSS,但是像 background 和 icon 这样的图像应该如何处理呢?在 webpack 5 中,使用内置的 Asset Modules 便可以轻松地将这些内容混入我们的系统中:

webpack.config.js

 const path = require('path');

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

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

试试向项目中添加一个图像,并观察它是如何工作的,你可以使用任何你喜欢的图像:

project

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

src/index.js

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

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

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

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

 document.body.appendChild(component());

src/style.css

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

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

$ npm run build

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

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

加载字体

那么,像字体这样的其他资源如何处理呢?使用 Asset Modules 可以接收并加载任何文件,然后将其输出到构建目录。换言之,我们可以将它们用于任何类型的文件,也包括字体。让我们更新 webpack.config.js 来处理字体文件:

webpack.config.js

 const path = require('path');

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

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

project

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

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

src/style.css

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

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

$ npm run build

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

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

加载数据

此外,可以加载的有用资源还有数据,如 JSON 文件、CSV、TSV 和 XML。与 NodeJS 类似,对 JSON 的支持实际上也是内置的,即 import Data from './data.json' 默认将正常运行。如果要导入 CSV、TSV 和 XML,你可以使用 csv-loader 和 xml-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'],
+      },
     ],
   },
 };

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

project

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

src/data.xml

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

src/data.csv

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

现在可以 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 现在使用 import 引入
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

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

   element.appendChild(myIcon);

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

 document.body.appendChild(component());

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

Tip

在使用 d3 等工具实现某些数据可视化时,这个功能极其有用。这将帮助你不用在运行时发送请求获取和解析数据,而是在构建过程中将其提前加载到模块中,以便浏览器加载模块后,可以直接访问解析过的数据。

Warning

只有在使用 JSON 模块默认导出时会没有警告。

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

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

自定义 JSON 模块解析器

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

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

src/data.toml

title = "TOML Example"

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

src/data.yaml

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

src/data.json5

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

首先安装 tomlyamljs 和 json5 对应的 package:

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 现在使用 import 引入。
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   element.classList.add('hello');

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

   element.appendChild(myIcon);

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

   return element;
 }

 document.body.appendChild(component());

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

全局资源

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

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

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

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

回退处理

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

project

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

webpack.config.js

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

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

src/index.js

 import _ from 'lodash';
-import './style.css';
-import Icon from './icon.png';
-import Data from './data.xml';
-import Notes from './data.csv';
-import toml from './data.toml';
-import yaml from './data.yaml';
-import json from './data.json5';
-
-console.log(toml.title); // 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); //  `JSON5 Example`
-console.log(json.owner.name); // output `Tom Preston-Werner`

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

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

   return element;
 }

 document.body.appendChild(component());

并移除之前添加的依赖:

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

开发环境

在开始前,我们先将 mode 设置为 'development',并将 title 设置为 'Development'

webpack.config.js

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

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

使用 source map

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

为了更容易地追踪 error 和 warning,JavaScript 提供了 source map 功能,可以将编译后的代码映射回原始源代码。source map 会直接告诉你错误来源于哪一个源代码。

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

在本指南中,我们将使用 inline-source-map 选项,这有助于解释说明示例意图(此配置仅用于示例,不要用于生产环境):

webpack.config.js

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

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

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

src/print.js

 export default function printMe() {
-  console.log('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)的引用。这将帮助我们确切知道所要解决问题的位置。

选择一个开发工具

Warning

某些文本编辑器具有“safe write(安全写入)”功能,可能会干扰下面一些工具。阅读 调整文本编辑器 以解决这些问题。

在每次编译代码时,手动运行 npm run build 会显得很麻烦。

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

  1. webpack 的 Watch Mode(观察模式)
  2. webpack-dev-server
  3. webpack-dev-middleware

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

使用观察模式

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

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

package.json

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

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

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

src/print.js

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

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

唯一的缺点是,为了看到修改后的实际效果,需要手动刷新浏览器。如果能够自动刷新浏览器就更好了!接下来我们会尝试通过 webpack-dev-server 实现此功能。

使用 webpack-dev-server

webpack-dev-server 提供了一个基本的 web server,并具有实时重新加载的功能。设置如下:

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

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

webpack.config.js

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

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

以上配置告知 webpack-dev-server,将 dist 目录下的文件 serve 到 localhost:8080 下(译注:serve 意即将资源作为 server 的可访问文件)。

Tip

由于在这个示例中单个 HTML 页面有多个入口,所以添加了 optimization.runtimeChunk: 'single' 配置,否则可能会遇到 这个问题。查看 代码分割 章节了解更多细节。

Tip

webpack-dev-server 会从 output.path 中定义的目录中的 bundle 文件提供服务,即文件可以通过 http://[devServer.host]:[devServer.port]/[output.publicPath]/[output.filename] 进行访问。

Warning

webpack-dev-server 在编译之后不会写入到任何输出文件,而是将 bundle 文件保留在内存中,然后将它们 serve 到 server 中,就好像它们是挂载在 server 根路径上的真实文件一样。如果你的页面希望在其他不同路径中找到 bundle 文件,可以通过 dev server 配置中的 devMiddleware.publicPath 选项进行修改。

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

package.json

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

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

webpack-dev-server 具有许多可配置的选项。关于其他更多配置,请查看 配置文档

Tip

现在,server 正在运行,你可能需要尝试 模块热替换(hot module replacement)

使用 webpack-dev-middleware

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

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

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

现在,我们需要调整 webpack 配置文件,以确保 middleware(中间件)功能能够正确启用:

webpack.config.js

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

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

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

project

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

server.js

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

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

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

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

现在,添加一个 npm script,以使更方便地运行 server:

package.json

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

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

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

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

Tip

如果想要了解更多关于模块热替换的运行机制,我们推荐你参阅 模块热替换 指南。

调整文本编辑器

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

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

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

总结

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

代码分离

Tip

本指南继续沿用 起步 中的示例代码。请确保你已熟悉这些指南中提供的示例以及 管理输出 章节。

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

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

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

入口起点

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

project

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

another-module.js

import _ from 'lodash';

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

webpack.config.js

 const path = require('path');

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

构建后结果如下:

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

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

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

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

防止重复

入口依赖

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

webpack.config.js

 const path = require('path');

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

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

webpack.config.js

 const path = require('path');

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

构建结果如下:

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

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

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

SplitChunksPlugin

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

webpack.config.js

  const path = require('path');

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

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

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

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

动态导入

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

Warning

调用 import() 会在内部使用 promise。因此如果在旧版本浏览器中(例如,IE 11)使用 import(),需要使用一个 polyfill 库(例如 es6-promise 或 promise-polyfill)来 shim Promise

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

webpack.config.js

 const path = require('path');

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

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

project

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

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

src/index.js

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

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

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

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

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

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

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

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

src/index.js

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

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

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

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

在稍后示例中,当需要根据计算后的变量导入特定模块时,可以通过向 import() 传入一个 动态表达式 实现。

预获取/预加载模块

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

在声明 import 时,使用下面这些内置指令,可以让 webpack 输出“resource hint”,来告知浏览器:

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

下面这个预获取的简单示例中,有一个 HomePage 组件,其内部渲染一个 LoginButton 组件,然后在点击后按需加载 LoginModal 组件。

LoginButton.js

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

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

Tip

只要父 chunk 完成加载,webpack 就会添加预获取提示。

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

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

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

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

ChartComponent.js

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

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

Tip

不正确地使用 webpackPreload 会有损性能,请谨慎使用。

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

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

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

为了避免上述问题,你可以添加自己的 onerror 处理脚本,将会在错误发生时移除该 script。

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

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

bundle 分析

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

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

下一步

接下来,查看 懒加载 来学习如何在真实的应用程序中使用 import() 的具体示例,以及查看 缓存 来学习如何有效地分离代码。

 

 

Logo

一座年轻的奋斗人之城,一个温馨的开发者之家。在这里,代码改变人生,开发创造未来!

更多推荐