ESM in Node.js
在过去几年中,Node.js一直在努力支持运行ECMAScript模块(ESM)。这是一个非常困难的功能,因为Node.js生态系统的基础是建立在另一种称为CommonJS(CJS)的模块系统上。
在两个模块系统之间进行互操作带来了很大的挑战,涉及许多新功能;然而,Node.js现在已经实现了对ESM的支持,并且尘埃已经开始落定。
这就是为什么TypeScript引入了两个新的module和moduleResolution配置:Node16和NodeNext。
{
"compilerOptions": {
"module": "NodeNext"
}
}
type配置项
type可以被设置为module或commonjs,此设置控制是否将.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.json的type字段被解释为CommonJS模块或ES模块,此检测到的模块类型必须与Node将为相应的JavaScript文件检测的模块类型匹配,以便类型检查正确。尝试使用单个.d.ts文件键入ES模块入口点和CommonJS入口点将导致TypeScript认为其中只有一个入口点存在,从而导致软件包用户的编译器错误。