ESM: ECMAScript 模块

ECMAScript 模块(也称为 ES 模块或 ESM)作为 ECMAScript 2015 规范的一部分引入,旨在为 JavaScript 提供适合不同执行环境的官方模块系统。 ESM 规范试图保留 CommonJS 和 AMD 等先前现有模块系统中的一些好主意。 语法非常简单紧凑。 支持循环依赖以及异步加载模块的可能性。

ESM 和 CommonJS 之间最重要的区别是 ES 模块是静态的,这意味着导入是在每个模块的顶层且在任何控制流语句之外进行描述的。 此外,导入模块的名称不能在运行时使用表达式动态生成,只允许常量字符串。

例如,使用 ES 模块时以下代码将无效:

if (condition) {
    import module1 from 'module1'
} else {
    import module2 from 'module2'
}

在 CommonJS 中,编写这样的代码是完全可以的:

let module = null
if (condition) {
    module = require('module1')
} else {
    module = require('module2')
}

乍一看,ESM 的这一特性似乎是不必要的限制,但实际上,静态导入会带来许多有趣的场景,而这些场景对于 CommonJS 的动态特性来说并不实用。 例如,静态导入允许对依赖树进行静态分析,从而允许诸如死代码消除(树抖动)等优化。

在 Node.js 中使用 ESM

默认情况下,Node.js 会考虑使用 CommonJS 语法编译每个 .js 文件; 因此,如果我们在 .js 文件中使用 ESM 语法,解释器只会抛出错误。

有几种方法可以告诉 Node.js 解释器将给定模块视为 ES 模块而不是 CommonJS 模块:

  • 为模块文件指定扩展名 .mjs

  • 将名为 “type” 且值为 “module” 的字段添加到最近的父 package.json

在本书的其余部分以及提供的代码示例中,我们将继续使用 .js 扩展名,以使大多数文本编辑器更容易访问代码,因此,如果您直接从书中复制和粘贴示例,请确保您 还创建一个包含 "type":"module" 条目的 package.json 文件。

现在让我们看一下 ESM 语法。

命名导出和导入

ESM 允许我们通过 export 关键字从模块导出功能。

请注意,ESM 使用单数词 export,而不是 CommonJS 使用的复数词(exports 和 module.exports)。

在 ES 模块中,默认情况下所有内容都是私有的,只有导出的实体可以从其他模块公开访问。

可以在我们想要提供给模块用户的实体前面使用 export 关键字。 让我们看一个例子:

// logger.js

// exports a function as `log`
export function log (message) {
    console.log(message)
}

// exports a constant as `DEFAULT_LEVEL`
export const DEFAULT_LEVEL = 'info'

// exports an object as `LEVELS`
export const LEVELS = {
    error: 0,
    debug: 1,
    warn: 2,
    data: 3,
    info: 4,
    verbose: 5
}

// exports a class as `Logger`
export class Logger {
    constructor (name) {
        this.name = name
    }

    log (message) {
        console.log(`[${this.name}] ${message}`)
    }
}

如果我们想从模块导入实体,我们可以使用 import 关键字。 语法非常灵活,它允许我们导入一个或多个实体,甚至可以重命名导入。 让我们看一些例子:

import * as loggerModule from './logger.js'
console.log(loggerModule)

在此示例中,我们使用 * 语法(也称为命名空间导入)导入模块的所有成员并将它们分配给本地 loggerModule 变量。 该示例将输出如下内容:

[Module] {
    DEFAULT_LEVEL: 'info',
    LEVELS: { error: 0, debug: 1, warn: 2, data: 3, info: 4, verbose: 5 },
    Logger: [Function: Logger],
    log: [Function: log]
}

正如我们所看到的,我们模块中导出的所有实体现在都可以在 loggerModule 命名空间中访问。例如,我们可以通过 loggerModule.log 引用 log() 函数。

需要注意的是,与 CommonJS 不同,使用 ESM,我们必须指定导入模块的文件扩展名。 对于 CommonJS,我们可以使用 ./logger./logger.js,对于 ESM,我们被迫使用 ./logger.js

如果我们使用一个大型模块,通常我们不想导入它的所有功能,而只想导入其中的一个或几个实体:

import { log } from './logger.js'
log('Hello World')

如果我们想要导入多个实体,我们将这样做:

import { log, Logger } from './logger.js'
log('Hello World')
const logger = new Logger('DEFAULT')
logger.log('Hello world')

当我们使用这种类型的 import 语句时,实体将被导入到当前作用域中,因此存在名称冲突的风险。 例如,以下代码将不起作用:

import { log } from './logger.js'
const log = console.log

如果我们尝试执行前面的代码片段,解释器会失败并出现以下错误:

SyntaxError: Identifier 'log' has already been declared

在这种情况下,我们可以通过使用 as 关键字重命名导入的实体来解决冲突:

import { log as log2 } from './logger.js'
const log = console.log

log('message from log')
log2('message from log2')

当通过从不同模块导入两个具有相同名称的实体生成冲突时,此方法特别有用,因此更改原始名称超出了消费者的控制范围。

默认导出和导入

CommonJS 的一项广泛使用的功能是能够通过 module.exports 的分配导出单个未命名实体。 我们发现这非常方便,因为它鼓励模块开发人员遵循单一职责原则并仅公开一个清晰的接口。 借助 ESM,我们可以通过所谓的默认导出来执行类似的操作。 默认导出使用 default export 关键字,如下所示:

// logger.js
export default class Logger {
    constructor (name) {
        this.name = name
    }

    log (message) {
        console.log(`[${this.name}] ${message}`)
    }
}

在这种情况下,名称 Logger 将被忽略,导出的实体将注册在名称 default 下。 这个导出的名称以特殊方式处理,可以按如下方式导入:

// main.js

import MyLogger from './logger.js'
const logger = new MyLogger('info')
logger.log('Hello World')

与命名 ESM 导入的区别在于,由于默认导出被视为未命名,因此我们可以导入它,同时为其指定一个我们选择的本地名称。 在此示例中,我们可以将 MyLogger 替换为在我们的上下文中有意义的任何其他内容。 这与我们对 CommonJS 模块所做的非常相似。 另请注意,我们不必将导入名称括在括号中或在重命名时使用 as 关键字。

在内部,默认导出相当于以 default 为名称的命名导出。 我们可以通过运行以下代码片段轻松验证此声明:

// showDefault.js
import * as loggerModule from './logger.js'
console.log(loggerModule)

执行时,前面的代码将打印如下内容:

[Module] { default: [Function: Logger] }

但是,我们不能做的一件事是显式导入默认实体。 事实上,像下面这样的事情将会失败:

import { default } from './logger.js'

执行将失败并出现 SyntaxError: Unexpected reserved word error。 发生这种情况是因为 default 关键字不能用作变量名。 default 作为对象属性是有效的,因此在前面的示例中,使用 loggerModule.default 是可以的,但我们不能在作用域中直接使用名为 default 的变量。

混合导出

可以在 ES 模块中混合命名导出和默认导出。 让我们看一个例子:

// logger.js
export default function log (message) {
    console.log(message)
}

export function info (message) {
    log(`info: ${message}`)
}

前面的代码将 log() 函数导出为默认导出以及名为 info() 的函数的命名导出。 请注意,info() 可以在内部引用 log()。 不可能用 default() 替换对 log() 的调用来执行此操作,因为这将是语法错误(不期望出现 default)。

如果我们想同时导入默认导出和一个或多个命名导出,我们可以使用以下格式来完成:

import mylog, { info } from './logger.js'

在前面的示例中,我们将从 logger.js 导入默认导出作为 mylog 以及命名导出信息。

现在让我们讨论一些关键细节以及默认导出和命名导出之间的差异:

  • 命名导出是显式的。 有了预先确定的名称,IDE 就可以通过自动导入、自动完成和重构工具来支持开发人员。 例如,如果我们输入 writeFileSync,编辑器可能会自动在当前文件的开头添加 import { writeFileSync } from 'fs' 。 相反,默认导出使所有这些事情变得更加复杂,因为给定的功能在不同的文件中可能有不同的名称,因此很难仅根据给定的名称来推断哪个模块可能提供给定的功能。

  • 默认导出是一种方便的机制,用于传达模块最重要的功能。 此外,从用户的角度来看,可以更轻松地导入明显的功能,而无需知道绑定的确切名称。

  • 在某些情况下,默认导出可能会使应用死代码消除(tree shake)变得更加困难。 例如,模块只能提供默认导出,这是一个对象,其中所有功能都作为该对象的属性公开。 当我们导入这个默认对象时,大多数模块捆绑器将考虑正在使用的整个对象,并且他们将无法从导出的功能中消除任何未使用的代码。

由于这些原因,通常认为坚持使用命名导出是一种良好的做法,特别是当您想要公开多个功能时,并且仅当您想要导出一项明确的功能时才使用默认导出。

这并不是一个硬性规则,而且这个建议也有明显的例外。 例如,所有 Node.js 核心模块都有一个默认导出和多个命名导出。 此外,React (nodejsdp.link/react) 使用混合导出。

仔细考虑您的特定模块的最佳方法是什么,以及您希望为模块的用户提供什么样的开发人员体验。

模块标识符

模块标识符(也称为模块说明符)是不同类型的值,我们可以在 import 语句中使用它们来指定要加载的模块的位置。

到目前为止,我们只看到了相对路径,但还有其他几种可能性和一些细微差别需要记住。 让我们列出所有的可能性:

  • 相对说明符,例如 ./logger.js../logger.js。 它们用于引用相对于导入文件位置的路径。

  • 绝对说明符,例如 file:///opt/nodejs/config.js 。 它们直接且明确地引用完整路径。 请注意,这是 ESM 引用模块绝对路径的唯一方法,使用 /// 前缀不起作用。 这是与 CommonJS 的显着区别。

  • 裸说明符是像 fastifyhttp 这样的标识符,它们代表 node_modules 文件夹中可用的模块,通常通过包管理器(例如 npm)安装或作为核心 Node.js 模块使用。

  • 深度导入说明符,例如 fastify/lib/logger.js,它引用 node_modules 中的包内的路径(在本例中为 fastify)。

在浏览器环境中,可以通过指定模块 URL 直接导入模块,例如 https://unpkg.com/lodash 。 Node.js 不支持此功能。

异步导入

正如我们在上一节中看到的,导入语句是静态的,因此受到两个重要的限制:

  • 无法在运行时构造模块标识符

  • 模块导入在每个文件的顶层声明,并且不能嵌套在控制流语句中

在某些用例中,这些限制可能变得有点过于严格。 例如,想象一下,如果我们必须为当前用户语言导入特定的翻译模块,或者依赖于用户操作系统的模块的变体。

另外,如果我们想要加载给定的模块(该模块可能特别重),只有当用户访问需要该模块的功能时,该怎么办?

为了让我们克服这些限制,ES 模块提供了 async imports(也称为动态导入)。

可以使用特殊的 import() 运算符在运行时执行异步导入。

import() 运算符在语法上等同于将模块标识符作为参数的函数,并返回解析为模块对象的 Promise。

我们将在第 5 章 “使用 Promise 和 Async/Await 的异步控制流模式” 中了解有关 Promise 的更多信息,因此现在不必太担心理解特定 Promise 语法的所有细微差别。

模块标识符可以是静态导入支持的任何模块标识符,如上一节所述。 现在,让我们通过一个简单的示例来了解如何使用动态导入。

我们想要构建一个可以用不同语言打印 “Hello World” 的命令行应用程序。 将来,我们可能希望支持更多的短语和语言,因此有一个文件包含每种受支持语言的所有面向用户的字符串的翻译。

让我们为我们想要支持的一些语言创建一些示例模块:

// strings-el.js
export const HELLO = 'Γεια σου κόσμε'

// strings-en.js
export const HELLO = 'Hello World'

// strings-es.js
export const HELLO = 'Hola mundo'

// strings-it.js
export const HELLO = 'Ciao mondo'

// strings-pl.js
export const HELLO = 'Witaj świecie'

现在让我们创建主脚本,该脚本从命令行获取语言代码并以所选语言打印 “Hello World” :

// main.js
const SUPPORTED_LANGUAGES = ['el', 'en', 'es', 'it', 'pl'] // (1)
const selectedLanguage = process.argv[2] // (2)

if (!SUPPORTED_LANGUAGES.includes(selectedLanguage)) { // (3)
    console.error('The specified language is not supported')
    process.exit(1)
}

const translationModule = `./strings-${selectedLanguage}.js` // (4)
import(translationModule) // (5)
    .then((strings) => { // (6)
        console.log(strings.HELLO)
    }

脚本的第一部分非常简单。 我们所做的是:

  1. 定义支持的语言列表。

  2. 从命令行中传递的第一个参数读取所选语言。

  3. 最后,我们处理不支持所选语言的情况。

代码的第二部分是我们实际使用动态导入的地方:

  1. 首先,我们根据所选语言动态构建要导入的模块的名称。 请注意,模块名称必须是模块文件的相对路径,这就是我们在文件名前面添加 ./ 的原因。

  2. 我们使用 import() 运算符来触发模块的动态导入。

  3. 动态导入是异步发生的,因此我们可以在返回的 Promise 上使用 .then() 挂钩,以便在模块准备好使用时收到通知。 传递给 then() 的函数将在模块完全加载时执行,字符串将是动态导入的模块命名空间。 之后,我们可以访问 strings.HELLO 并将其值打印到控制台。

现在我们可以像这样执行这个脚本:

node main.js it

我们应该看到 Ciao mondo 被打印到我们的控制台上。

深入模块加载

为了了解 ESM 的实际工作原理以及它如何有效地处理循环依赖,我们必须更深入地了解使用 ES 模块时如何解析和执行 JavaScript 代码。

在本节中,我们将学习如何加载 ECMAScript 模块,我们将介绍只读实时绑定的想法,最后,我们将讨论一个具有循环依赖项的示例。

加载阶段

解释器的目标是构建所有必要模块的图(依赖图)。

一般来说,依赖图可以定义为表示一组对象的依赖关系的有向图(nodejsdp.link/directed-graph)。 在本节的上下文中,当我们引用依赖关系图时,我们想要指示 ECMAScript 模块之间的依赖关系。 正如我们将看到的,使用依赖图可以让我们确定在给定项目中加载所有必需模块的顺序。

本质上,解释器需要依赖图来弄清楚模块如何相互依赖以及代码需要以什么顺序执行。 当节点解释器启动时,它会传递一些要执行的代码,通常以 JavaScript 文件的形式。 该文件是依赖关系解析的起点,称为入口点。 从入口点开始,解释器将以深度优先的方式递归地查找并遵循所有导入语句,直到搜索并执行所有必要的代码。

更具体地说,这个过程分三个不同的阶段进行:

  • 第 1 阶段 - 构建(或解析):查找所有导入并从相应文件中递归加载每个模块的内容。

  • 第 2 阶段 - 实例化:对于每个导出的实体,在内存中保留命名引用,但暂时不要分配任何值。 此外,还会为所有导入和导出语句创建引用,跟踪它们之间的依赖关系(链接)。 此阶段尚未执行任何 JavaScript 代码。

  • 第 3 阶段 - 执行:Node.js 最终执行代码,以便所有先前实例化的实体都能获得实际值。 现在可以从入口点运行代码,因为所有空白都已被填充。

简单来说,我们可以说第一阶段是找到所有的点,第二阶段连接那些创建路径,最后第三阶段以正确的顺序遍历路径。

乍一看,这种方法似乎与 CommonJS 的做法没有太大不同,但有一个根本的区别。 由于其动态特性,CommonJS 将在搜索依赖关系图时执行所有文件。 我们已经看到,每次找到新的 require 语句时,之前的所有代码都已被执行。 这就是为什么您甚至可以在 if 语句或循环中使用 require,并从变量构造模块标识符。

在 ESM 中,这三个阶段是完全独立的,在依赖图完全构建之前无法执行任何代码,因此模块导入和导出必须是静态的。

只读实时绑定

ES 模块的另一个基本特征有助于解决循环依赖问题,即导入的模块实际上是对其导出值的只读实时绑定。

让我们用一个简单的例子来阐明这意味着什么:

// counter.js
export let count = 0
export function increment () {
    count++
}

该模块导出两个值:一个称为 count 的简单整数计数器和一个将计数器加一的增量函数。

现在让我们编写一些使用该模块的代码:

// main.js
import { count, increment } from './counter.js'
console.log(count) // prints 0
increment()
console.log(count) // prints 1
count++ // TypeError: Assignment to constant variable!

在这段代码中我们可以看到,我们可以随时读取 count 的值并使用 increment() 函数更改它,但是一旦我们尝试直接改变 count 变量,我们就会得到一个错误,就好像我们尝试改变 const 绑定。

这就证明,当在作用域中导入一个实体时,与其原始值的绑定是不能改变的(只读绑定),除非绑定的值在原始模块本身的作用域中发生变化(实时绑定),而这是消费者代码无法直接控制的。

这种方法与 CommonJS 根本不同。 事实上,在 CommonJS 中,当模块需要时,整个导出对象都会被复制(浅复制)。 这意味着,如果稍后更改数字或字符串等基本变量的值,则请求模块将无法看到这些更改。

循环依赖解析

现在,为了结束这个循环,让我们使用 ESM 语法重新实现我们在 CommonJS 模块部分中看到的循环依赖示例:

image 2024 05 05 22 02 33 811
Figure 1. 图 2.3:循环依赖的示例场景

我们先看一下模块 a.js 和 b.js:

// a.js
import * as bModule from './b.js'
export let loaded = false
export const b = bModule
loaded = true

// b.js
import * as aModule from './a.js'
export let loaded = false
export const a = aModule
loaded = true

现在让我们看看如何在 main.js 文件(入口点)中导入这两个模块:

// main.js
import * as a from './a.js'
import * as b from './b.js'
console.log('a ->', a)
console.log('b ->', b)

请注意,这次我们没有使用 JSON.stringify,因为它会导致 TypeError 失败: 将循环结构转换为 JSON,因为 a.js 和 b.js 之间存在实际的循环引用。

运行 main.js 时,我们将看到以下输出:

a -> <ref *1> [Module] {
    b: [Module] { a: [Circular *1], loaded: true },
    loaded: true
}
b -> <ref *1> [Module] {
    a: [Module] { b: [Circular *1], loaded: true },
    loaded: true
}

这里有趣的是,模块 a.js 和 b.js 具有彼此的完整信息,这与 CommonJS 不同,它们只保存彼此的部分信息。 我们可以看到,因为所有加载的值都设置为 true。 另外,a 中的 b 是对当前作用域中可用的同一个 b 实例的实际引用,b 中的 a 也是如此。 这就是为什么我们不能使用 JSON.stringify() 来序列化这些模块的原因。 最后,如果我们交换模块 a.js 和 b.js 的导入顺序,最终结果不会改变,这是与 CommonJS 工作方式相比的另一个重要区别。

对于这个具体示例,值得花更多时间观察模块解析的三个阶段(解析、实例化和执行)中发生的情况。

阶段1:解析

在解析阶段,从入口点 (main.js) 开始搜索代码。 解释器仅查找 import 语句来查找所有必需的模块并从模块文件加载源代码。 依赖图以深度优先的方式进行探索,每个模块仅被访问一次。 这样解释器就构建了一个看起来像树结构的依赖关系视图,如图 2.4 所示:

image 2024 05 05 22 20 19 319
Figure 2. 图 2.4:使用 ESM 解析循环依赖关系

给定图 2.4 中的示例,我们来讨论解析阶段的各个步骤:

  1. 从 main.js 中,找到的第一个导入直接引导我们进入 a.js。

  2. 在 a.js 中我们找到一个指向 b.js 的导入。

  3. 在 b.js 中,我们也有一个导入回到 a.js(我们的循环),但由于 a.js 已经被访问过,所以不再搜索这条路径。

  4. 此时,搜索开始回归:b.js 没有其他导入,因此我们回到 a.js; a.js 没有其他 import 语句,所以我们回到 main.js。 在这里我们发现另一个指向 b.js 的导入,但是这个模块已经被搜索过,所以这个路径被忽略。

至此,我们对依赖图的深度优先访问已经完成,我们有了模块的线性视图,如图2.5所示:

image 2024 05 05 22 22 52 972
Figure 3. 图 2.5:模块图的线性视图,其中循环已被删除

这个特殊的观点非常简单。 在具有更多模块的更现实的场景中,视图将看起来更像树结构。

阶段2:实例化

在实例化阶段,解释器将从上一阶段获得的树视图从底部到顶部遍历。 对于每个模块,解释器将首先查找所有导出的属性,并在内存中构建导出名称的映射:

image 2024 05 05 22 28 38 463
Figure 4. 图 2.6:实例化阶段的直观表示

图2.6描述了每个模块实例化的顺序:

  1. 解释器从 b.js 开始,发现模块导出 loaded 和 a。

  2. 然后,解释器转到 a.js,它导出 loaded 和 b。

  3. 最后,它转移到 main.js,它不导出任何功能。

  4. 请注意,在此阶段,导出映射仅跟踪导出的名称; 它们的关联值目前被认为是未初始化的。

在这一系列步骤之后,解释器将执行另一遍将导出的名称链接到导入它们的模块,如图 2.7 所示:

image 2024 05 05 22 30 15 288
Figure 5. 图 2.7:跨模块链接导出和导入

我们可以通过以下步骤描述图 2.7 中所看到的内容:

  1. 模块 b.js 将链接 a.js 的导出,将它们称为 aModule。

  2. 反过来,a.js 将链接到 b.js 的所有导出,将它们称为 bModule。

  3. 最后,main.js 将导入 b.js 中的所有导出,将它们称为 b; 同样,它将从 a.js 导入所有内容,并将它们称为 a。

  4. 再次强调,所有值都尚未初始化。 在此阶段,我们仅将引用链接到下一阶段结束时可用的值。

阶段3:执行

最后一步是执行阶段。 在这个阶段,每个文件中的所有代码都会被最终执行。 执行顺序再次是自下而上的,尊重我们原始依赖图的后序深度优先访问。 通过这种方法,main.js 是最后执行的文件。 这样,我们可以确保在开始执行主要业务逻辑之前所有导出的值都已初始化:

image 2024 05 05 23 32 55 613
Figure 6. 图 2.8:评估阶段的直观表示

按照图 2.8 中的图,会发生以下情况:

  1. 从 b.js 开始执行,要执行的第一行将模块的加载导出初始化为 false。

  2. 同样,这里对导出的属性 a 进行求值。 这次,它将被执行为对表示模块 a.js 的模块对象的引用。

  3. 加载的属性的值更改为 true。 至此,我们已经全面执行了模块 b.js 的导出状态。

  4. 现在执行转移到 a.js。 再次,我们首先将 loaded 设置为 false。

  5. 此时,b 导出被执行为对模块 b.js 的引用。

  6. 最后将 loaded 属性改为 true。 现在我们终于执行了 a.js 的所有导出。

完成所有这些步骤后,main.js 中的代码就可以执行了,此时,所有导出的属性都已完全执行。 由于导入的模块作为引用进行跟踪,因此即使存在循环依赖关系,我们也可以确保每个模块都具有其他模块的最新图片。

修改其他模块

我们看到通过 ES 模块导入的实体是只读实时绑定,因此我们无法从外部模块重新分配它们。

但有一点需要注意。我们的确不能改变现有模块的默认导出或命名导出与其他模块的绑定,但如果这些绑定之一是一个对象,我们仍然可以通过重新分配对象的某些属性来改变对象本身。

这个警告可以给我们足够的自由来改变其他模块的行为。为了演示这个想法,让我们编写一个可以改变核心 fs 模块行为的模块,以便阻止该模块访问文件系统并返回模拟数据。在为依赖于文件系统的组件编写测试时,这种模块可能很有用:

// mock-read-file.js

import fs from 'fs' // (1)

const originalReadFile = fs.readFile // (2)
let mockedResponse = null

function mockedReadFile (path, cb) { // (3)
    setImmediate(() => {
        cb(null, mockedResponse)
    })
}

export function mockEnable (respondWith) { // (4)
    mockedResponse = respondWith
    fs.readFile = mockedReadFile
}

export function mockDisable () { // (5)
    fs.readFile = originalReadFile
}

我们回顾一下前面的代码:

  1. 我们要做的第一件事是导入 fs 模块的默认导出。 我们稍后会回到这个问题,现在请记住,fs 模块的默认导出是一个对象,其中包含允许我们与文件系统交互的函数集合。

  2. 我们想用模拟实现替换 readFile() 函数。 在此之前,我们保存对原始实现的引用。 我们还声明了一个稍后将使用的 mockedResponse 值。

  3. 函数 mockedReadFile() 是我们要用来替换原始实现的实际模拟实现。 该函数使用 mockedResponse 的当前值调用回调。 请注意,这是一个简化的实现; 真实函数在回调参数之前接受可选的选项参数,并且能够处理不同类型的编码。

  4. 导出的 mockEnable() 函数可用于激活模拟的功能。 原始实现将与模拟实现交换。 模拟的实现将返回与通过 respondWith 参数传递到此处的相同值。

  5. 最后,导出的 mockDisable() 函数可用于恢复 fs.readFile() 函数的原始实现。

现在让我们看一个使用此模块的简单示例:

// main.js
import fs from 'fs' // (1)
import { mockEnable, mockDisable } from './mock-read-file.js'

mockEnable(Buffer.from('Hello World')) // (2)

fs.readFile('fake-path', (err, data) => { // (3)
    if (err) {
        console.error(err)
        process.exit(1)
    }
    console.log(data.toString()) // 'Hello World'
})

mockDisable()

让我们逐步讨论此示例中发生的情况:

  1. 我们要做的第一件事是导入 fs 模块的默认导出。 再次请注意,我们正在专门导入默认导出,就像我们在 mock-read-file.js 模块中所做的那样,但稍后会详细介绍。

  2. 这里我们启用模拟功能。 我们希望,对于每个读取的文件,模拟该文件包含字符串“Hello World”。

  3. 最后,我们使用假路径读取文件。 此代码将打印 “Hello World”,因为它将使用 readFile() 函数的模拟版本。 请注意,调用此函数后,我们通过调用mockDisable()恢复原始实现。

这种方法有效,但非常脆弱。 事实上,有很多方法可能无法实现这一点。

在 mock-read-file.js 方面,我们可以尝试对 fs 模块进行以下两种导入:

import * as fs from 'fs' // then use fs.readFile

或者

import { readFile } from 'fs'

它们都是有效的导入,因为 fs 模块将所有文件系统函数导出为命名导出(默认导出除外,默认导出是具有与属性相同的函数集合的对象)。

前面两个 import 语句存在一些问题:

  • 我们将获得一个只读实时绑定到 readFile() 函数中,因此,我们将无法从外部模块更改它。 如果我们尝试这些方法,当尝试重新分配 readFile() 时,我们将收到错误。

  • 另一个问题是在我们的 main.js 中的消费者方面,我们也可以使用这两种替代导入样式。 在这种情况下,我们最终不会使用模拟功能,因此代码在尝试读取不存在的文件时将触发错误。

之所以使用上述两条导入语句中的一条不起作用,是因为我们的模拟实用程序只修改了作为默认导出的对象中注册的 readFile() 函数副本,而没有修改模块顶层作为命名导出的函数副本。

这个特殊的例子向我们展示了猴子补丁在 ESM 的背景下如何变得更加复杂和不可靠。 因此,Jest (nodejsdp.link/jest) 等测试框架提供了特殊功能,以便能够更可靠地模拟 ES 模块 (nodejsdp.link/jest-mock)。

另一种可用于模拟模块的方法是依赖称为 module (nodejsdp.link/module-doc) 的特殊 Node.js 核心模块中可用的钩子。 一个利用该模块的简单库是 mocku (nodejsdp.link/mocku)。 如果您好奇的话,请查看其源代码。

我们还可以使用模块包中的 syncBuiltinESMExports() 函数。 调用此函数时,默认导出对象中的属性值将再次映射到等效的命名导出中,从而有效地允许我们传播应用于模块功能的任何外部更改,甚至传播到命名导出:

import fs, { readFileSync } from 'fs'
import { syncBuiltinESMExports } from 'module'

fs.readFileSync = () => Buffer.from('Hello, ESM')
syncBuiltinESMExports()

console.log(fs.readFileSync === readFileSync) // true

我们可以使用它来使我们的小型文件系统模拟实用程序更加灵活,方法是在启用模拟后或恢复原始功能后调用 syncBuiltinESMExports() 函数。

请注意,syncBuiltinESMExports() 仅适用于内置 Node.js 模块,例如我们示例中的 fs 模块。

我们对 ESM 的探索到此结束。 至此,我们应该能够理解 ESM 是如何工作的,它是如何加载模块的,以及它是如何处理循环依赖的。 为了结束本章,我们现在准备讨论 CommonJS 和 ECMAScript 模块之间的一些关键差异和一些有趣的互操作性技术。