功能背景
首先来看下目前的 Node.js 官网对于各个版本维护情况:
可以看到目前最老的 Node.js LTS 版本是 v12.x,这意味着在当前的官方仍在长期支持的版本中,ES Module 这个能力已经稳定下来(Stability: 2 - Stable)。
因此,在即将进入 release
的 TypeScript 4.5 版本中,给 compilerOptions
的模块能力增加了两个新的属性:
{ "compilerOptions": { "module": "nodenext" } } // Or { "compilerOptions": { "module": "node12" } }
这个属性主要用来控制 TS 项目的模块系统究竟采用哪种,详细可以参见:具体配置项(https://www.typescriptlang.org/tsconfig#module)。
Node.js 的模块演进
模块入口定义
TS 4.5 新增的这个能力和 Node.js 的模块系统演进息息相关,实际上在 node-v12.7.0
之前,对于一个 npm
包我们只需要在 package.json
中定义 main
字段即可约束模块的入口:
{ "name": "demo", "main": "demo.js" }
而从 node-v12.7.0
开始,新增了 exports
属性来进行更加精准的导出定义:
{ "name": "demo", "exports": "./demo.js" }
它的好处是可以限制外部对于 npm
包内任意文件的引用,以上面的入口定义为例:
// 正确 require('demo'); // 错误:'./foo' 在 exports 中未定义 require('demo/foo')
最后就是 exports
相比 main
支持了条件入口:
{ "name": "demo", "exports": { ".": { "import": "./foo.mjs", "require": "./foo.js" } } }
这样就实现了同一个包可以同时定义 commonjs
和 esmodule
入口,换言之可以使用一种相对优雅的方式支持模块以 commonjs
的方式加载或者是 esmodule
的方式进行加载。
模块机制定义
在 node-v12.x 之前,runtime 开始支持 esmodule
后,最初仅依靠文件后缀 .mjs
来区分使用哪种方式来加载模块,而从 v12.0.0 开始,新增了 type
属性来决定项目中的模块导入采用哪种机制:
{ "name": "demo", "type": "module" }
值得注意的是,定义为 module
后整个项目中的 .js
文件都会采用 esmodule
的方式作为模块机制,此时 module
,exports
和 require
等关键字都无法使用,取代的是 import
和 export
。
这样就产生了第一个容易让人迷惑的场景:commonjs
和 esmodule
互相调用。
实际上在绝大部分情况下,esmodule
可以通过 import
来加载正确配置的 commonjs
模块:
// foo.js module.exports = function() { console.log('foo'); } // bar.mjs import foo from './foo.js'; foo(); // 'foo'
反过来则不行:
// foo.mjs export default function() { console.log('foo'); } // bar.js const foo = require('./foo.mjs'); //错误 Must use import to load ES Module foo();
我们在 commonjs
中只能使用动态 import
来加载 esmodule
模块:
// foo.mjs export default function() { console.log('foo'); } // bar.js import('./foo.mjs').then(({ default: foo }) => foo());
这样加载首先带来的问题就是 import
函数必须异步,而异步的传染性懂得都懂(手动 /doge)。
可以看到同一门语言下设计两套不互相独立的模块系统会带来了相当劝退的开发体验 - -!
TS 中使用 ESM 模块
4.5 之前的方式
回到本文的主题,由于 Node.js 的 esmodule
支持实际上是对标准的扩充:主要体现在模块寻址上,node_modules
的默认寻址方式并不是标准提供的。
这就导致 TypeScript 在 4.5 正式支持 node12 / nodenext
flag 之前,使用 ts 编写 Node.js 应用无法直接引入纯 ESM 编写的模块,可以参见如下例子。
考虑编写一个仅支持 esmodule 加载的模块 pure-esm
,目录结构如下:
node_modules └---- pure-esm |---- esm.js |---- esm.d.ts └----- package.json
其中 package.json
定义如下:
{ "name": "pure-esm", "exports": "./esm.js", "type": "module", "types": "./esm.d.ts" }
接着设置 "module": "esnext"
,在 ts 文件中导入:
// 会提示错误:Cannot find module 'pure-esm',原因见上 import * as pure from "pure-esm";
此时需要手动设置 "moduleResolution": "node"
来将 node_modules
加入寻址路径方可编译通过。
使用存在的问题
其实跟着上面例子看下来的同学很容易发现,因为 TS 上游没有原生支持 Node.js 实现的扩充版 ESM 路径寻址,需要一个额外的组合配置来完成语法层面和实现层面的统一,以进行纯 ESM 模块的使用。
相比多写一两个配置,这里缺乏上游实现带来的另一个问题则严重多了,继续看例子。
将上面的文件结构稍加改造:
node_modules └---- pure-esm |---- esm.js |---- esm.d.ts |---- notshow.js |---- notshow.d.ts └----- package.json
我们新增了一个 notshow.js
与其对应的声明文件,此时在 TS 文件中修改引入的逻辑:
import * as pure from "pure-esm/notshow.js";
此时 TS 类型检查系统并不会提示错误,接着用 tsc
编译生成 js 代码执行则会提示:
Package subpath './notshow.js' is not defined by "exports" in xxxxx.
回想第二节中介绍的,从 node-v12.7.0
开始引入的 exports
属性,相比以前的 main
属性多一层限制:使用者仅可见 exports
属性定义的导出路径!
因此原来的 “取巧” 方式在 TS 中使用 ESM 模块会产生因为上游实现不一致导致的 BUG 场景,这也是 4.5 中引入原生的 node12 / nodenext
这两个 module
类型的原因。
TS4.5 中的 ESM 模块
回到上面的例子,在 4.5 中我们需要引入这个 pure-esm
模块,仅需在 tsconfig
中配置:
{ "compilerOptions": { "module": "node12" } }
此时,直接引入模块不会提示错误:
// 正确导入 import * as pure from "pure-esm";
而错误引入未定义导出的文件则会提示错误:
// 错误 Cannot find module 'pure-esm/notshow.js' or // its corresponding type declarations. import * as pure from "pure-esm/notshow.js";
为了进一步验证 exports
属性确实被 TS 4.5 识别了,修改 package.json
:
{ "name": "pure-esm", "exports": { ".": "./esm.js", "./notshow.js": "./notshow.js" }, "type": "module", "types": "./esm.d.ts" }
等于将 notshow.js
文件也进行导出,此时不会再提示错误:
// 正确导入 import * as pure from "pure-esm/notshow.js";
可以看到,TS 4.5 在上游实现了对 node-v12.x
引入的 exports
属性能力,极大增强了 TS 下使用 ESM 模块的开发体验。
另外需要关注的是,虽然在 TS 中都是 import
,但是因为配置的 module
的不同,会决定生成 target 时采用哪种模块机制,两种模块间寻址是存在一些区别的:
// ./foo.ts export function helper() { // ... } // ./bar.ts import { helper } from "./foo"; // 仅在 commonjs 下生效 helper();
ESM 则必须采用全路径的形式引入:
// ./foo.ts export function helper() { // ... } // ./bar.ts import { helper } from "./foo.js"; // 同时支持 commonjs & esmodule helper();
这也导致使用 commonjs
的 TS 项目无法直接通过更改配置中的 "module": "node12/nodenext"
来直接切换编译产物。
注意 Break Change!
在 4.5 之前,按照第三节中使用的组合定义来在下游 TS 编程中使用 ESM 模块,一般来说仅需要对应的 ESM 模块实现如下之一即可:
- 定义一个入口声明文件
index.d.ts
package.json
中指定一个入口声明(types
属性)
然而一部分npm
模块因为历史原因或者是想利用 conditional exports
同时兼容 commonjs
和 esmodule
两套模块机制。
此类同时兼容的模块无法在自身的定义中设置 "type": "module"
,因此依旧需要使用后缀 .mjs
来和默认采用 commonjs
的 .js
文件进行区分。
因此这些包往往会使用如下的 “条件” 导出形式进行模块的导出:
{ "exports": { ".": { "import": "./foo.mjs", "require": "./foo.js" } }, "types": "./foo.d.ts" }
案例:模块导入失败
这种模块在 4.5 之前的组合配置下可以正常导入,但是升级到 4.5 后继续使用则会报错,依旧以一个例子说明,考虑如下结构的一个同时支持两种机制的包 multi-mod
:
node_modules └---- multi-mod |---- foo.d.ts |---- foo.mjs |---- foo.js └----- package.json
首先在 foo.js
中导出一个方法:
// foo.js exports.hello = function() { console.log('module loaded!'); }
在 foo.mjs
中我们可以很方便将 foo.js
中的 commonjs
模块继续导出使用:
// foo.mjs export * from './foo.js';
接着是声明文件 foo.d.ts
:
export function hello(): void;
最后定义下 package.json
:
{ "exports": { ".": { "import": "./foo.mjs", "require": "./foo.js" } }, "types": "./foo.d.ts" }
这个构造的模块 multi-mod
在 TS 4.4 下面可以被正常识别,包括函数声明,但是切到 TS 4.5 后就会提示错误信息:
// Could not find a declaration file for module 'multi-mod'. // 'xxxx/node_modules/multi-mod/foo.mjs' implicitly has an 'any' type. import * as multi from "multi-mod"; multi.hello();
实际上这里是因为在 4.5 中, TS 专门针对采用 .mjs
和 .cjs
的后缀来区分不同模块机制的行为增加了对应的专属声明文件 .mts
和 .cts
。
因此使用 node12 / nodenext
方式在 TS 中编写 import xxx fom xxx
会被解析成 esmodule
,在这个例子中对应了入口 foo.mjs
。
那么显而易见的是,multi-mod
中没有对应的 foo.d.mts
文件,因此导致了类型检查失败。
这里也会有同学要问, package.json
中定义的 "types": "./foo.d.ts"
为什么也不生效呢
这是因为 TS 4.5 中完全实现了 node-v12.x
里的 exports
属性相关能力(包括限制),还记得上一节中提到的 exports
支持导出多个入口,而不同入口的声明一般是不同的,因此和 exports
平级的 types
属性无法作用于不同入口。
这就导致虽然我们仅仅是为了支持两种模块机制才使用了 conditional exports
,而且在 exports
对象中只定义了一个入口,但是类型检查器认为必须要对每一个入口定义单独的声明文件,因此抛错。
解决导入失败的问题
明白了产生错误的原因,解决起来就比较容易了,这里可以用两种方式进行解决。
第一种是增加 foo.d.mts
的声明:
// foo.d.mts export function hello(): void;
增加后的目录结构如下所示:
node_modules └---- multi-mod |---- foo.d.mts |---- foo.mjs |---- foo.d.ts |---- foo.js └----- package.json
此时可以正确读取到模块的类型,不会再提示错误
第二种解决方案是在 package.json
中增加导出条件平级的类型声明文件路径:
{ "exports": { ".": { "import": "./foo.mjs", "require": "./foo.js", "types": "./foo.d.ts" } }, "types": "./foo.d.ts" }
其实可以看到这两种方式都是为了类型检查器能够定位到导出模块的声明文件。
更多能力
TS 4.5 中除了对 node-v12.x
开始的模块机制进行原生支持外,还新增了不少其它的能力,本文限于篇幅不一一展开,有兴趣的同学可以参阅官方发布的文章进行查阅。
官方文档地址:TypeScript 4.5 Beta(https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html)。