Node.js 原生运行 TypeScript 深度体验、原理和 Roadmap 分享

1,684 阅读6分钟

image.png

欢迎关注公众号『JavaScript与编程艺术』高质量文章优先公众号发布。

Node.js V22.7.0 无需编译可以直接运行 TS 文件,可以加速我们的开发过程,这是伟大的进步。原理是运行时将类型剥离(strip-types 类型被等量空格替代,非常巧妙的方式后文会讲到为何巧妙),但并不支持所有 TS 类型或特性,比如需要编译的就不会支持比如 enum,原因详见下文 roadmap。

执行 ts 有多种运行时,本文采用最快的 bun 作为对比。

如何最快速最节约磁盘安装 bun 详见 一个专业的前端如何在国内安装 bun]。

Node.js 执行 ts 文件需要增加一个实验性的 flag --experimental-strip-types,Node.js 🟢 v22.18.0 无需因为已稳定。

错误姿势 🚫:

node index.ts --experimental-strip-types

正确姿势 ✔️:

node --experimental-strip-types index.ts 

1. 支持简单类型

function greet(name: string) {
  return `Hello, ${name}!`;
}

console.log(greet('World'));

效果:

❯ bun index.ts && node --experimental-strip-types index.ts

Hello, World!
Hello, World!

2. 支持类型别名 type

function count(list: any[]): int {
  return list.length;
}

console.log(
  'There are', count(['James']), 'items in the list'
);

type int = number;

效果:

❯ bun index.ts && node --experimental-strip-types index.ts

There are 1 items in the list
There are 1 items in the list

3. 支持 interface

interface Info {
  girlFriends: string[];
}

function introduce(name: string, { girlFriends }: Info): number {
  console.log(name, 'has', girlFriends.length, 'girlFriends:', girlFriends);
}

introduce('James', {
  girlFriends: ['Mary', 'Margaret', 'Martha', 'Marie', 'Marion'],
});

效果:

❯ bun index.ts && node --experimental-strip-types index.ts

James has 5 girlFriends: [ "Mary", "Margaret", "Martha", "Marie", "Marion" ]
James has 5 girlFriends: [ 'Mary', 'Margaret', 'Martha', 'Marie', 'Marion' ]

4. 支持泛型

function count<T extends any>(list: T[]): int {
  return list.length;
}

console.log('There are', count(['James']), 'items in the list');

type int = number;

效果:

❯ bun index.ts && node --experimental-strip-types index.ts

There are 1 items in the list
There are 1 items in the list

5. 支持类型体操 和 as

即类型之间的交互和转换生成新的类型。因为本质上这些类型都可以被擦除,故可以支持。


type ExtractK<
  T extends string,
  Keys extends any[] = []
> = T extends `${string}{{${infer K}}${infer T}`
  ? ExtractK<T, [...Keys, K]>
  : Keys;

type t1 = ExtractK<'hello {{foo}} {{bar}}'>; // ['foo', 'bar']

type ArrToInterface<A extends string[]> = { [P in A[number]]: string };

type t2 = ArrToInterface<t1>; // { foo: string; bar: string }

function extractKeys<T extends string>(str: T): ArrToInterface<ExtractK<T>> {
  return { foo: str, bar: str } as ArrToInterface<ExtractK<T>>;
}

const baz = extractKeys('hello {{foo}} {{bar}}');

console.log('baz:', baz);

效果:

❯ bun index.ts && node --experimental-strip-types index.ts

baz: {
  foo: "hello {{foo}} {{bar}}",
  bar: "hello {{foo}} {{bar}}",
}
baz: { foo: 'hello {{foo}} {{bar}}', bar: 'hello {{foo}} {{bar}}' }

可以看到 bun 对输出更加友好。

6. 不支持 enum

enum Gender {
  Male = 'male',
  Female = 'female',
}

function introduce(name: string, gender: Gender): void {
  console.log(name, 'is', gender);
}

introduce('James', Gender.Male);

效果:

❯ bun index.ts

James is male
❯ node --experimental-strip-types index.ts

node:internal/deps/amaro/dist/index:226
          throw takeObject(r1);
          ^
  x TypeScript enum is not supported in strip-only mode
    ,-[73:1]
 70 |     //   girlFriends: ['Mary', 'Margaret', 'Martha', 'Marie', 'Marion', ]
 71 |     // }))
 72 |
 73 | ,-> enum Gender {
 74 | |     Male = 'male',
 75 | |     Female = 'female',
 76 | `-> }
 77 |
 78 |     function introduce(name: string, gender: Gender): void {
 79 |       console.log(name, 'is', gender);
    `----

(Use `node --trace-uncaught ...` to show where the exception was thrown)

Node.js v22.7.0

可以看到关键词 strip-only mode,后续应该不会有 compile-mode(后文 roadmap 会提及)。 为什么不支持 enum,因为 enum 不仅仅是类型,运行时会存在,比如

enum Gender {
  Male = 'male',
  Female = 'female',
}

被 TS 编译成:

var Gender;
(function (Gender) {
    Gender["Male"] = "male";
    Gender["Female"] = "female";
})(Gender || (Gender = {}));

其次 const num 也不支持,因为同样需要编译(只是编译成 inline 形式):

function introduce(name, gender) {
    console.log(name, 'is', gender);
}
introduce('James', "male" /* Gender.Male */);

为何需要编译的不支持,详见下文。

小结

优点

  • 无编译,加快开发速度
  • 无需将类型写到 tsdoc 注释中或者 .d.ts,类型变成一等公民
  • 支持大部分类型

不足或者目前的限制

  • 需要转换的,或运行时需要留痕的不支持(enum、decorator、namespace……)
  • 当然类型检查是没有的,roadmap 也不会支持。
  • .js 文件不支持类型。
  • node_modules 里面的 ts 不会运行。
  • 没有 sourcemap 也无需。

解读

需转换的特性不支持

enum 等需要转换的特性不支持的原因:转换意味着需要 sourcemap 这是很大的工作量、其次支持越多更新越快意味着 breaking change 也会更多,对 Node.js 本身稳定性存在负面影响。

enum 的 workaround,我们可以使用对象 + as const 来实现 enum,之前的示例代码可以改写成如下:

// mimic enum use plain object and `as const`
const Gender = {
  Male: 'male',
  Female: 'female',
} as const;

type Gender = (typeof Gender)[keyof typeof Gender];

function introduce(name: string, gender: Gender): void {
  console.log(name, 'is', gender);
}

introduce('James', Gender.Male);

不支持运行 node_modules 里的 ts 文件

当前不支持是为了避免出现仅有 TS 的 npm 包。估计是还不够成熟。

为什么无需 sourcemap

因为会用等量空格或空行替代类型,源码行数保持不变。

我们用 Nodejs 使用的类型擦除包验证下。包 amaro 的介绍和为何出现下文会讲到。

const amaro = require('amaro');

const codeWithTypes = "const foo: string = 'bar';";
const { code } = amaro.transformSync(codeWithTypes, {
  mode: 'strip-only',
});

console.log(code);
console.log(codeWithTypes);
// "const foo         = 'bar';"
// “const foo: string = 'bar';”

执行结果:

❯ node amaro-test.js

const foo         = 'bar';
const foo: string = 'bar';

可以看到类型被等量空格替代,保持了源码的位置故无需 sourcemap。我们再来看一个稍微复杂的例子:

function count<T extends any>(list: T[]): int {
  return list.length;
}

type int = number;

console.log('There are', count(['James']), 'items in the list');

被 amaro 转换后的代码:

function count               (list     )      {
  return list.length;
}



console.log('There are', count(['James']), 'items in the list');

可以看到内联的类型被等量空格替换,包括泛型 <T extends any>、入参 : T[]、返回值类型 : int;独立的类型比如类型别名 type int = number; 被空行替换。转换前后行数都是 7 行,而且代码行列号都保持了和源码一模一样,故无需 sourcemap。

小结

虽然存在这些不足,但原生支持类型后确实很方便,无需编译(我最不想要的就是编译!),我日常写代码也一定会写类型,所以这对我帮助很大。我的开发模式:mjs + tsdocmjs + index.d.ts,这是我最近写 node.js 工具的标配,感兴趣的同学可以看看 swaggered ~ githubydd,类型少则写到注释中,否则写到 .d.ts

其次类型检查不支持也不是致命缺点,其实也无需,编辑器可以帮我们做。

社区担心

  • 担心会和 TS 割裂,导致兼容性问题。为什么 deno 不会有这个担忧?
  • Node.js 的版本更新会被 TS 绑架。TS 有新语法必须跟进,会阻碍对 LTS 的演进

“不可阻挡的光明的”未来

虽然有这么多担忧,但是 JS 原生支持类型大势所趋,浏览器支持类型已经有了提案 TC39 Proposal for Type Annotations。TypeScript 的核心成员和 Node.js 的开发者也都在着手解决这些担忧,有条件的可以看看 YouTube 视频 meeting on 24th of July。JS 和 TS 的鸿沟相信会被顺利抹平,所以未来仍然是“不可阻挡的光明的”。

Node.js 类型 Roadmap

  • ✅ 第 1 步:通过简单的类型剥离让 ts 文件跑起来
  • ✅ 第 2 步:解耦。将核心功能发布到 npm,已发布即包 amaro
  • 第 3 步:性能提升
  • 第 4 步:增加更多功能

amaro 介绍

  1. 让 Node.js 支持类型的核心包,定位是 TypeScript transpiler。通过将其独立成包,可以和 Node.js 解耦达到新功能可以快速开发和升级,以及有最新特性如果当前 Node.js 版本不支持,可以单独下载最新的 amaro 包。
  2. 使用 swc 实现。因为 @swc/wasm-typescript 简单、经过充分验证 Deno 也是使用 swc。未来可能在 Native 层实现。
  3. 该包目前仅支持 strip-mode 也就是如果我们前例中运行 enum 代码报错里面提到过。后续可能支持更多模式。
  4. 后续可能支持 tsconfig。

参考