# Node.js 中的 ECMAScript 模块

在过去的几年里,Node.js 一直致力于支持运行 ECMAScript 模块 (ESM)。这是一个非常难以支持的特性,因为 Node.js 生态系统的基础是建立在一个名为 CommonJS (CJS) 的不同模块系统上。

两个模块系统之间的互操作带来了巨大的挑战,需要兼顾许多新功能;然而,Node.js 中对 ESM 的支持现在已经在 Node.js 中实现,尘埃落定。

这就是 TypeScript 带来两个新的 modulemoduleResolution设置的原因:node16nodenext

{
    "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" 字段 (在相应文件旁边查找声明文件),并支持 包自引用自己 。这些功能通常不涉及,但受支持。

Last Updated: 5/25/2023, 2:35:11 PM