且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

TypeScript 4.5 — 浅谈模块能力增强

更新时间:2022-08-14 19:37:09

TypeScript 4.5 — 浅谈模块能力增强

功能背景

首先来看下目前的 Node.js 官网对于各个版本维护情况:

TypeScript 4.5 — 浅谈模块能力增强

可以看到目前最老的 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"
    }
  }
}

这样就实现了同一个包可以同时定义 commonjsesmodule 入口,换言之可以使用一种相对优雅的方式支持模块以 commonjs 的方式加载或者是 esmodule 的方式进行加载。

模块机制定义

在 node-v12.x 之前,runtime 开始支持 esmodule 后,最初仅依靠文件后缀 .mjs 来区分使用哪种方式来加载模块,而从 v12.0.0 开始,新增了 type 属性来决定项目中的模块导入采用哪种机制:

{
  "name": "demo",
  "type": "module"
}

值得注意的是,定义为 module 后整个项目中的 .js 文件都会采用 esmodule 的方式作为模块机制,此时 moduleexportsrequire 等关键字都无法使用,取代的是 importexport

这样就产生了第一个容易让人迷惑的场景:commonjsesmodule 互相调用。

实际上在绝大部分情况下,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 同时兼容 commonjsesmodule 两套模块机制。

此类同时兼容的模块无法在自身的定义中设置 "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)。