# Node.js 中的 ECMAScript 模块
在过去的几年里,Node.js 一直致力于支持运行 ECMAScript 模块 (ESM)。这是一个非常难以支持的特性,因为 Node.js 生态系统的基础是建立在一个名为 CommonJS (CJS) 的不同模块系统上。
两个模块系统之间的互操作带来了巨大的挑战,需要兼顾许多新功能;然而,Node.js 中对 ESM 的支持现在已经在 Node.js 中实现,尘埃落定。
这就是 TypeScript 带来两个新的 module
和 moduleResolution
设置的原因:node16
和 nodenext
。
{
"compilerOptions": {
"module": "nodenext",
}
}
这些新模式带来了一些高级功能,我们将在这里进行探讨。
# package.json 中的 type 和新扩展
Node.js 支持 package.json 中的新设置
称为 type
。"type"
可以设置为 "module"
或 "commonjs"
。
{
"name": "my-package",
"type": "module",
"//": "...",
"dependencies": {
}
}
此设置控制 .js
文件被解释为 ES 模块还是 CommonJS 模块,未设置时默认为 CommonJS。当一个文件被认为是一个 ES 模块时,与 CommonJS 相比,一些不同的规则开始发挥作用:
- 可以使用
import
/export
语句和顶层await
- 相对导入路径需要完整扩展(例如,我们必须写
import "./foo.js"
而不是import "./foo"
) - 导入的解析可能与
node_modules
中的依赖不同 - 某些类似全局的值,如
require()
和__dirname
不能直接使用 - CommonJS 模块在某些特殊规则下被导入
我们将回到其中一些。
为了覆盖 TypeScript 在此系统中的工作方式,.ts
和 .tsx
文件现在以相同的方式工作。当 TypeScript 找到 .ts
、.tsx
、.js
或 .jsx
文件时,它会查找 package.json
以查看该文件是否为 ES 模块,并使用它来确定:
- 如何找到该文件导入的其他模块
- 以及如果产生输出如何转换该文件
当 .ts
文件被编译为 ES 模块时,ECMAScript import
/export
语法在 .js
输出中被单独保留;当它被编译为 CommonJS 模块时,它将产生与您今天在 module
:commonjs
下获得的相同的输出。
这也意味着作为 ES 模块的 .ts
文件和作为 CJS 模块的文件之间的路径解析不同。例如,假设您今天有以下代码:
// ./foo.ts
export function helper() {
// ...
}
// ./bar.ts
import { helper } from "./foo"; // only works in CJS
helper();
此代码在 CommonJS 模块中有效,但在 ES 模块中会失败,因为相对导入路径需要使用扩展。因此,它必须被重写以使用 foo.ts
的输出的扩展 - 所以 bar.ts
将不得不从 ./foo.js
导入。
// ./bar.ts
import { helper } from "./foo.js"; // works in ESM & CJS
helper();
一开始这可能会让人觉得有点麻烦,但是像自动导入和路径补全这样的 TypeScript 工具通常会为你做这件事。
另一件事要提到的是,这也适用于 .d.ts
文件。当 TypeScript 在包中找到一个 .d.ts
文件时,是否将其视为 ESM 或 CommonJS 文件取决于包含的包。
# 新文件扩展名
package.json
中的 type
字段很好,因为它允许我们继续使用 .ts
和 .js
文件扩展名,这很方便;但是,您有时需要编写与 type
指定的文件不同的文件。你也可能更喜欢总是明确的。
Node.js 支持两个扩展来帮助解决这个问题:.mjs
和 .cjs
。.mjs
文件总是 ES 模块,.cjs
文件总是 CommonJS 模块,没有办法覆盖这些。
反过来,TypeScript 支持两个新的源文件扩展名:.mts
和 .cts
。当 TypeScript 将这些发送到 JavaScript 文件时,它将分别发送到 .mjs
和 .cjs
。
此外,TypeScript 还支持两个新的声明文件扩展名:.d.mts
和 .d.cts
。当 TypeScript 为 .mts
和 .cts
生成声明文件时,它们对应的扩展名将是 .d.mts
和 .d.cts
。
使用这些扩展是完全可选的,但即使您选择不将它们用作主要工作流程的一部分,它们通常也会很有用。
# CommonJS 互操作
Node.js 允许 ES 模块导入 CommonJS 模块,就好像它们是具有默认导出的 ES 模块一样。
// @filename: helper.cts
export function helper() {
console.log("hello world!");
}
// @filename: index.mts
import foo from "./helper.cjs";
// prints "hello world!"
foo.helper();
在某些情况下,Node.js 还会从 CommonJS 模块中合成命名导出,这样会更方便。在这些情况下,ES 模块可以使用 "namespace-style" 导入(即 import * as foo from "..."
)或命名导入(即 import { helper } from "..."
)。
// @filename: helper.cts
export function helper() {
console.log("hello world!");
}
// @filename: index.mts
import { helper } from "./helper.cjs";
// prints "hello world!"
helper();
TypeScript 并不总是有办法知道这些命名导入是否会被合成,但是 TypeScript 会在从绝对是 CommonJS 模块的文件中导入时犯错,并使用一些启发式方法。
关于互操作的一个特定于 TypeScript 的注释是以下语法:
import foo = require("foo");
在 CommonJS 模块中,这只是归结为 require()
调用,而在 ES 模块中,这会导入 createRequire
来实现相同的目的。这将使代码在诸如浏览器(不支持 require()
)之类的运行时上的可移植性降低,但通常对互操作性很有用。反过来,您可以使用以下语法编写上述示例:
// @filename: helper.cts
export function helper() {
console.log("hello world!");
}
// @filename: index.mts
import foo = require("./foo.cjs");
foo.helper()
最后,值得注意的是,从 CJS 模块导入 ESM 文件的唯一方法是使用动态 import()
调用。这可能会带来挑战,但这是当今 Node.js 中的行为。
你可以在此处阅读有关 Node.js 中 ESM/CommonJS 互操作的更多信息。
# package.json 导出、导入和自引用
Node.js 支持 用于在 package.json 中定义入口点的新字段,称为 "exports"
。该字段是在 package.json
中定义 "main"
的更强大的替代方法,并且可以控制您的包的哪些部分暴露给消费者。
这是一个支持 CommonJS 和 ESM 的单独入口点的 package.json
:
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": "./esm/index.js",
// Entry-point for `require("my-package") in CJS
"require": "./commonjs/index.cjs",
},
},
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs",
}
这个功能有很多,您可以在 Node.js 文档中了解更多信息。在这里,我们将尝试关注 TypeScript 如何支持它。
使用 TypeScript 的原始 Node 支持,它会查找 "main"
字段,然后查找与该条目对应的声明文件。例如,如果 "main"
指向 ./lib/index.js
,TypeScript 会查找一个名为 ./lib/index.d.ts
的文件。包作者可以通过指定一个名为 "types"
(例如 "types": "./types/index.d.ts"
)的单独字段来覆盖它。
新的支持与 导入条件
类似。默认情况下,TypeScript 使用导入条件覆盖相同的规则 - 如果您从 ES 模块编写 import
,它将查找 import
字段,而从 CommonJS 模块中,它将查找 require
字段。如果找到它们,它将查找一个位于同一位置的声明文件。如果需要为类型声明指向不同的位置,可以添加 "types"
导入条件。
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for TypeScript resolution - must occur first!
"types": "./types/index.d.ts",
// Entry-point for `import "my-package"` in ESM
"import": "./esm/index.js",
// Entry-point for `require("my-package") in CJS
"require": "./commonjs/index.cjs",
},
},
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs",
// Fall-back for older versions of TypeScript
"types": "./types/index.d.ts"
}
TypeScript 也以类似的方式支持 package.json 的 "imports" 字段
(在相应文件旁边查找声明文件),并支持 包自引用自己
。这些功能通常不涉及,但受支持。