Skip to main content

ESM in Node.js

在过去几年中,Node.js一直在努力支持运行ECMAScript模块(ESM)。这是一个非常困难的功能,因为Node.js生态系统的基础是建立在另一种称为CommonJS(CJS)的模块系统上。

在两个模块系统之间进行互操作带来了很大的挑战,涉及许多新功能;然而,Node.js现在已经实现了对ESM的支持,并且尘埃已经开始落定。

这就是为什么TypeScript引入了两个新的modulemoduleResolution配置:Node16NodeNext

{
"compilerOptions": {
"module": "NodeNext"
}
}

type配置项

type可以被设置为modulecommonjs,此设置控制是否将.js.d.ts文件解释为ES模块还是CommonJS模块,并在未设置时默认为CommonJS,此配置在package.json文件中。

{
"name": "package-name",
"type": "module",
}

当一个文件被视为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模块,并且会使用它去决定:

  • 如何查找该文件导入的其他模块
  • 如果产生输出,该如何转换此文件

当一个文件作为一个ES模块编译时,ECMAScriptimport/export语法会被保留在输出的.js文件中;当一个文件作为一个CommonJS模块编译时,它将产生与CommonJS下获得的相同输出。

// foo.ts
export function helper() {
// ...
}

// bar.ts
// only works in cjs
import { helper } from './foo';

// works in ESM and cjs
import { helper } from './foo.js'

helper()

这也许感觉有点麻烦,但是TypeScript工具如自动导入和路径补全将为你做这些事情。

这种方式也适用于.d.ts文件。当TypeScript在包中发现.d.ts文件时,是否将其视为 ESM 还是 CommonJS 文件取决于包含的包。

新文件后缀

package.json文件中type字段非常的友好,因为它允许我们继续方便地使用.ts.js文件后缀;然而,偶尔需要编写一个与指定类型不同的文件。

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模块可以使用命名空间或命名导入:

// @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模块的文件导入时会错误地允许并使用一些启发式方法。

关于interop的一个特定于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');

// prints "hello world!"
foo.helper();

值得注意的是,从CJS模块导入ESM文件的唯一方法是使用动态import()调用。这可能会带来挑战,但这是目前Node.js中的行为。

Exports

Node.js支持一个名为exports的新字段,用于定义package.json中的入口点。这个字段是在package.json中定义main字段的更强大的替代方案,并且可以控制包裹的哪些部分暴露给使用者。

这里有一个package.json,支持CommonJS和ESM的单独入口点:

{
"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",
}

有了TypeScript原生Node支持,它将查找main字段,然后查找与与之对应的声明文件。例如,如果main指向./lib/index.js,TypeScript将查找一个名为./lib/index.d.ts的文件。软件包作者可以通过指定一个名为types的单独字段来覆盖它。

新的支持与导入条件类似。默认情况下,TypeScript用导入条件覆盖相同的规则:如果从ES模块编写导入,它将查找import字段,从CommonJS模块中,它将查找require字段。如果它找到它们,它将寻找一个同位置的声明文件。如果需要为类型声明指向不同的位置,您可以添加types导入条件。

{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": {
// Where TypeScript will look.
"types": "./types/esm/index.d.ts",
// Where Node.js will look.
"default": "./esm/index.js"
},
// Entry-point for `require("my-package")` in CJS
"require": {
// Where TypeScript will look.
"types": "./types/commonjs/index.d.cts",
// Where Node.js will look.
"default": "./commonjs/index.cjs"
},
}
},
// Fall-back for older versions of TypeScript
"types": "./types/index.d.ts",
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs"
}

需要注意的是,CommonJS入口点和ES模块入口点都需要自己的声明文件,即使它们之间的内容相同。每个声明文件都根据其文件扩展名和package.jsontype字段被解释为CommonJS模块或ES模块,此检测到的模块类型必须与Node将为相应的JavaScript文件检测的模块类型匹配,以便类型检查正确。尝试使用单个.d.ts文件键入ES模块入口点和CommonJS入口点将导致TypeScript认为其中只有一个入口点存在,从而导致软件包用户的编译器错误。