揭秘 webpack 的“导入”功能:使用动态参数

揭秘 webpack 的“导入”功能:使用动态参数

在本文中,我们将学习揭开 webpack 的“导入”功能迷雾:使用动态参数。

虽然是 webpack 的一个热门卖点功能,但是导入功能有很多隐藏的细节和特性,很多开发者可能都不知道。例如,导入函数可以接受动态表达式,能够实现一些众所周知的特性,比如延迟加载等。您可以将动态表达式视为不是原始字符串(例如 import('./path/to/file.js'))的任何内容。动态表达式的一些示例可能是:import('./animals/' + 'cat' + '.js')import('./animals/' + animalName + '.js'),其中 animalName 是在运行时或编译时才有具体的值。在本文中,我们将深入探讨导入功能的动态表达式的概念,并希望在最后,您将更加熟悉此 webpack 功能提供的可能性范围。

本文没有特殊的先决条件,除了基本了解导入函数在其参数为静态时的行为(即它创建一个新块)。还有一篇名为An in-depth perspective on webpack's bundling process的文章解释了ModulesChunks等概念,但应该不会过多影响对本文的理解。

让我们开始吧!

The implications of dynamic arguments

尽管在编译时该值是未知的,但通过使用带有动态参数的 import() 函数,我们仍然可以实现延迟加载。与 SystemJS 不同,webpack 不能在运行时加载任意模块,因此在运行时知道值这一事实将限制 webpack 确保参数可以解析为的所有可能值都被考虑在内。我们将在以下部分中了解它的含义,我们将在其中检查import函数可以接受的自定义参数值。

现在,我们将专注于 import 的参数。以下所有部分都将基于相同的示例,其中有一个名为 animals的目录,其中有与animal相对应的文件:

├── animals
│   ├── cat.js
│   ├── dog.js
│   ├── fish.js
│   └── lion.js
├── index.js

每个示例都使用如下import函数:import('./animals/${fileName}.js')。就 ./animals/${fileName}.js 段而言,每个 ${fileName} 指的是一个动态部分,默认情况下会被替换为 /.*/ (你可以把它想象成一个 glob 模式)。给定的表达式可以有多个动态部分。提供的参数最终将生成一个 RegExp 对象,该对象将用于确定应考虑哪些文件。遍历从提供的路径的第一个静态部分开始(在本例中为 ./animals),在每一步中,它将从当前目录读取文件,并针对它们测试 RegExp 对象。它还可以遍历嵌套目录(这是默认行为),一旦正确发现文件,webpack 将根据选择的模式继续。在此示例中,生成的 RegExp 对象将是 /^\\.\\/.*\\.js$/ 并且它将针对所有位于 animals/ 目录中的文件进行测试(例如 regExp.test('. /cat.js'))。

重要的是要提到遍历和文件查找是在编译时完成的。

作为旁注,我们可以在配置文件中选择动态部分的替换以及是否应遍历嵌套目录:

// wepback.config.js
module: {
    parser: {
      javascript: {
        wrappedContextRegExp: /.*/,
		wrappedContextRecursive: true
      }
    }
  }

因此,wrappedContextRecursive 指定是否应该遍历嵌套目录(例如,是否考虑到 animal/aquatic/ 中的文件)并且使用 WrappedContextRegExp 我们可以告诉 webpack 用什么来替换表达式的动态部分。

基于默认配置,我们的初始表达式 ./animals/${fileName}.js 将导致 ./animals/.*.js(loosely)。

在接下来的部分中,我们将探讨一旦计算出这些文件会发生什么。

让我们开始吧!

延迟模式

这是默认模式,这意味着您不必明确指定它。假设有一个如下所示的目录结构:

├── animals
│   ├── cat.js
│   ├── dog.js
│   ├── fish.js
│   └── lion.js
└── index.js

通过在我们的应用程序代码中使用import功能:

// index.js

// In this example, the page shows an `input` tag and a button.
// The user is supposed to type an animal name and when the button is pressed,
// the chunk whose name corresponds to the animal name will be loaded.

let fileName;

// Here the animal name is written by the user.
document.querySelector('input').addEventListener('input', ev => {
  fileName = ev.target.value;
});

// And here the chunk is loaded. Notice how the chunk depends on the animal name
// written by the user.
document.getElementById('demo').addEventListener('click', () => {
  import(/* webpackChunkName: 'animal' */ `./animals/${fileName}.js`)
    .then(m => {
      console.warn('CHUNK LOADED!', m);
      m.default();
    })
    .catch(console.warn);
});

webpack 将为animal目录中的每个文件生成一个块。这是lazy选项的行为。在这个例子中发生的事情是,用户将在输入中键入动物的名称,当单击按钮时,将加载与该名称对应的块。如果在animal目录中找不到动物的名称,则会抛出错误。你现在可能会想:如果webpack创建多个chunk,最终只有一个chunk匹配路径,这不是浪费资源吗?好吧,实际上并非如此,因为所有这些可能的块只在服务器上保存文件,除非浏览器需要它们(例如,当 import() 的路径与现有文件路径匹配时),否则它们不会发送到浏览器。

与在编译时知道路径的静态导入情况一样(例如 import('./animals/cat.js)),当只创建一个块时,当导入的路径是动态时,加载的块将被缓存 ,因此在多次需要相同块的情况下不会浪费任何重要资源。

准确地说,webpack 将加载的块存储在一个映射中,这样如果请求的块已经被加载,它将立即从映射中检索。映射的键是块的 ID,值取决于块的状态:0(加载块时)、promise(当前加载块时)和undefined(没有从任何地方请求过块时) )。

我们可以从这个图中注意到已经创建的 4 个块(animal目录中的每个文件一个),以及主要的父块(称为索引)。拥有(根)父块至关重要,因为它包含在应用程序中获取和集成其他子块所需的逻辑。

webpack 在内部处理这种行为的方式是通过一个映射,其中键是文件名(在这种情况下,键是animal目录中的文件名),值是数组(正如我们将看到的,数组的模式将是{ filename:[moduleId,chunkId] })。这种类型的数组包含对 webpack 非常有用的信息,例如:chunk id(它将在 HTTP 请求中请求相应 JS 文件使用)、module id(以便它知道在一旦chunk完成加载时,需要哪个模块),最后是模块的导出类型(webpack 使用它是为了在使用 ES 模块以外的其他类型的模块时实现兼容性)。无论我们使用何种模式,都会使用这种用于跟踪模块及其特征的映射概念。

要查看该数组的外观示例,您可以打开 StackBlitz 应用程序,其链接可在本节开头(或此处)找到,然后运行 ​​npm run build 脚本。然后,如果您打开 dist/main.js 文件,您已经可以注意到我们之前谈到的映射:

var map = {
	"./cat.js": [
		2,
		0
	],
	"./dog.js": [
		3,
		1
	],
	"./fish.js": [
		4,
		2
	],
	"./lion.js": [
		5,
		3
	]
};

再一次,这个对象遵循这个模式:{ filename: [moduleId, chunkId] }。具体来说,如果用户键入 cat 然后按下按钮,id 为 2 的块将被加载,一旦块准备好,它将使用 id 为 0 的模块。

还值得探索一个数组指定了模块的导出类型的情况。在这种情况下,cat.js 文件是 CommonJS 模块,其余的是 ES 模块:

// cat.js
module.exports = () => console.log('CAT');

如果你运行 npm run build 并检查 dist/main.js 文件,map看起来会有点不同:

var map = {
	"./cat.js": [
		2,
		7,
		0
	],
	"./dog.js": [
		3,
		9,
		1
	],
	"./fish.js": [
		4,
		9,
		2
	],
	"./lion.js": [
		5,
		9,
		3
	]
};

在这里,模式是这样的:{ filename: [moduleId, moduleExportsMode, chunkId] }。基于模块的导出类型,webpack 知道在块加载后如何加载模块。基本上,9 表示一个简单的 ES 模块,在这种情况下,将需要具有 moduleId的模块。 7表示一个 CommonJS 模块,在这种情况下 webpack 需要从它创建一个fake ES 模块。

要在实践中查看它,您可以打开最后提供的示例并启动服务器。如果我想使用 cat 模块,在单击按钮后,我应该会看到一个对包含相关模块的块的新请求:

可能已经注意到,控制台告诉我们块已经加载,以及它包含的模块,即 cat 模块。例如,如果我们想使用 fish 模块,也会采取相同的步骤:

导入匹配的每个文件,都会发生同样的情况。

 

eager模式

让我们首先看看我们将在本节中使用的示例:

let fileName;

// Here the animal name is written by the user.
document.querySelector('input').addEventListener('input', ev => {
  fileName = ev.target.value;
});

// Here the chunk that depends on `fileName` is loaded.
document.getElementById('demo').addEventListener('click', () => {
  import(/* webpackChunkName: 'animal', webpackMode: 'eager' */ `./animals/${fileName}.js`)
    .then(m => {
      console.warn('FILE LOADED!', m);
      m.default();
    })
    .catch(console.warn);
});

如您所见,可以使用 webpackMode: 'eager' 注释指定模式。

使用 Eager 模式时,不会创建任何额外的块。与导入模式匹配的所有模块都将成为同一主块的一部分。更具体地说,考虑到相同的文件结构,

├── animals
│   ├── cat.js
│   ├── dog.js
│   ├── fish.js
│   └── lion.js
└── index.js

就好像当前模块将直接需要动物目录中的模块,但实际上没有任何模块将被执行。它们只会被放置到模块的对象/数组中,当它单击按钮时,它将在现场执行并检索该模块,而无需额外的网络请求或任何其他异步操作。

运行 npm run build 并打开 dist/main.js 文件后,您应该会看到一个像这样的map对象:

var map = {
	"./cat.js": 2,
	"./dog.js": 3,
	"./fish.js": 4,
	"./lion.js": 5
};

每个值都表示模块的 ID,如果您向下滚动一点,您会找到这些模块:

/* 2 */ // -> The `cat.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {},
/* 3 */ // -> The `dog.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}

因此,这种方法的优点是模块在需要时会立即被检索,而不是为每个模块发出额外的 HTTP 请求,这是使用惰性模式时发生的情况。

这可以在我们的示例中得到验证:启动服务器后,尝试 require 存在于 animals 目录中的任何模块。预期的行为是“网络”面板中不应出现任何请求,并且应正确执行每个现有模块

最后,这里有一张图表来总结这个模式的行为:

lazy-once模式

在上一节中,我们已经看到了如何手动指定模式,因此告诉 webpack 我们要使用惰性一次模式的方式应该不足为奇:

/*
The same file structure is assumed:

├── animals
│   ├── cat.js
│   ├── dog.js
│   ├── fish.js
│   └── lion.js
└── index.js
*/
let fileName;

// Here the user chooses the name of the module.
document.querySelector('input').addEventListener('input', ev => {
  fileName = ev.target.value;
});

// When clicked, the chunk will be loaded and the module that matches with the `fileName`
// variable will be executed and retrieved.
document.getElementById('demo').addEventListener('click', () => {
  import(/* webpackChunkName: 'animal', webpackMode: 'lazy-once' */ `./animals/${fileName}.js`)
    .then(m => {
      console.warn('FILE LOADED!', m);
      m.default();
    })
    .catch(console.warn);
});

这种情况下的行为在某种程度上类似于我们在上一节中遇到的情况,除了所有与导入表达式匹配的模块都将添加到子块而不是主块中。这样,就惰性块而言,它也接近惰性模式。

运行 npm run build 后,dist 目录应该有 2 个文件:main.js,它是主块,以及 animal.js,它是所有与目录中的文件对应的模块所驻留的块。这种加载模块的方式的好处是你不会用所有可以匹配导入表达式的可能模块来重载主块,而是将它们放在另一个可以延迟加载的块中。当用户按下按钮加载模块时,将通过网络请求整个块,当它准备好时,将执行并检索用户请求的模块。

此外,这个新加载的块包含的所有模块都将被 webpack 注册。有趣的是,如果现在用户需要一个不同的模块,该模块也属于刚刚加载的块,那么网络上不会有任何额外的请求。这是因为块将从 webpack 内部维护的缓存中提供服务,并且所需的模块将从 webpack 记录它们的模块的数组/对象中检索。

让我们在我们的示例中也尝试一下。我将首先输入 cat 然后按下按钮。在 Network 选项卡中,应该有一个对 animal 块的请求,如前所述,它包含所有必要的模块:

现在,如果我们想使用 lion 模块,我应该不会看到新请求,而只会看到 lion 模块已执行的确认:

这是一个图表来补充到目前为止所积累的内容:

weak模式

由于它的特殊性,我们将这部分保存到最后。通过使用弱导入,我们实质上是在告诉 webpack 我们想要使用的资源应该已经准备好进行检索。意味着现在应该从其他地方加载(即需要和使用)有问题的资源,因此,当使用弱导入时,此操作不会触发任何获取机制(例如,发出网络请求以加载块),而仅使用 webpack 用于跟踪模块的数据结构中的模块.

我们将从一个简单的示例开始,该示例最初会引发错误,然后我们将对其进行扩展,以便更好地了解此弱模式的含义:

let fileName;

// Here the user types the name of the module
document.querySelector('input').addEventListener('input', ev => {
  fileName = ev.target.value;
});

// Here that module is retrieved directly if possible, otherwise
// an error will be thrown.
document.getElementById('demo').addEventListener('click', () => {
  import(/* webpackChunkName: 'animal', webpackMode: 'weak' */ `./animals/${fileName}.js`)
    .then(m => {
      console.warn('FILE LOADED!', m);
      m.default();
    })
    .catch(console.warn);
});

到目前为止没有详细说明,这只是我们在其他部分一直在做的,即指定我们希望导入函数运行的模式,在这种情况下是弱的。

如果您在输入中键入 cat 然后按下按钮,您会在控制台中注意到一个错误:

这应该是有道理的,因为正如前面提到的,弱导入期望资源应该已经准备好使用,而不是让 webpack 采取行动以使其可用。我们目前做事的方式, cat 模块不是从其他任何地方加载的,所以这就是我们面临错误的原因。

为了快速缓解这个问题,我们可以添加一个 import * as c from './animals/cat';文件开头的声明:

// index.js

import * as c from './animals/cat';

let fileName;
/* ... */

如果我们再次运行 npm run build 和 npm run start 并执行相同的步骤,我们应该会看到 cat 模块已成功执行。但是,如果您尝试使用 cat 以外的任何其他模块,则会出现相同的错误:

此功能可用于强制预先加载模块,以便您确保在某个点可访问模块。否则会抛出错误。

与其他模式相反,模块不会被添加到当前块中,也不会添加到子块中,也不会添加到自己的块中。在这种情况下,webpack 所做的是跟踪与导入表达式匹配的模块是否存在,并在需要时跟踪模块的导出类型(例如,如果它们都是 ES 模块,则不需要它) .例如:

var map = {
	"./cat.js": 1,
	"./dog.js": null,
	"./fish.js": null,
	"./lion.js": null
};

在上面的地图中(可以在 dist/main.js 文件中找到 - 唯一生成的文件),可以确定 cat 模块在整个应用程序中使用。但是,它并不一定保证 cat 模块可用。因此,上面的地图对象的作用就是跟踪项目中完全有目的的模块(即它们是否被使用)。换句话说,它跟踪模块的存在。其他值为 null 的模块称为孤立模块。

可能存在模块存在但不可用的情况。考虑以下示例:

let fileName;

// Here the user chooses the name of the file.
document.querySelector('input').addEventListener('input', ev => {
  fileName = ev.target.value;
});

// Requesting the module that should already be available.
document.getElementById('demo').addEventListener('click', () => {
  import(/* webpackChunkName: 'animal', webpackMode: 'weak' */ `./animals/${fileName}.js`)
    .then(m => {
      console.warn('FILE LOADED!', m);
      m.default();
    })
    .catch(console.warn);
});

// Dynamically loading the `cat.js` module.
document.getElementById('load-cat').addEventListener('click', () => {
  import('./animals/cat.js').then(m => {
    console.warn('CAT CHUNK LOADED');
  });
});

import('./animals/cat.js') 语句中,我们可以看出该模块存在于应用程序中,但为了使其可用,必须先单击#load-cat 按钮。通过单击它,将获取块并且 cat 模块将变得可访问,这是因为当加载块时,它的所有模块都将可用于整个应用程序。

我们可以尝试直接 require cat 模块(无需先按 Load cat 块),但最终会报错,说模块不可用:

但是,如果我们先加载 cat 块然后再 require 模块,那么一切都应该正常工作:

本节的要点是,当使用弱模式时,预计资源已经在手边。因此,模块必须克服 3 个过滤器:它必须与导入表达式匹配,它必须在整个应用程序中使用(例如,它直接导入或通过块导入)并且它必须可用(即已经从其他地方加载)。

总结:

在本文中,我们了解到导入函数可以做的不仅仅是创建一个块。希望在这一点上,当涉及到使用带有动态参数的导入时,事情会变得更有意义。

版权声明:著作权归作者所有。

thumb_up 1 | star_outline 0 | textsms 0