分类「 EcznScript
2025-03-19
EcznScript ?
18 年来写 TS 算有 7 年了,近期一个大消息:
ts 官方宣布决定迁移到 tsgo,用 go 移植了 tsc,性能提高约 10 倍
老实说一点也不惊讶[1]我天天喷 ts/js,可以看看往年的博文,但从另外一个角度击碎了我:ts 只是 js 的孙子[2]说好听点叫超集,不做一门严肃的编程语言 —— 更深度的 codegen 以及 all-in-one 打包等几乎都没咋做,说难听点就是操着类型体操最后写出来 php 的性能:静态类型没有任何性能上的加成,最后我不得不面对这个事实:tsgo 之后再写 ts 只能渡过一个相对失败的人生,官方自己都放弃 ts 自举,语言其实有重大缺陷,作为用户还是尽早放弃比较好

# ts 最大的问题 —— 做 js 的孙子

TS 由于只做 js 的孙子 —— 好听点叫超集,因此各种严肃的编程语言该做的没做,或只做了半成品,尤其是工程化/打包相关的,而这些匮乏又最终导致源码 ts 仓库变得极其扭曲,我在下面这篇文章有具体细节分析:
总的来说最主要的问题:
  1. 1.
    namespace 放弃了,也不打算自己做打包,但是市面上的打包都没有想过
    obj.xxx
    的点读开销和 jit 优化开销,这是 ts 仓库变得扭曲的核心原因
  2. 2.
    没有做静态编译优化,比如说
    const a = 1 + 1
    应该在编译期计算成
    const a = 2
    目前是没有的
  3. 3.
    静态类型被擦掉了,没有参与代码优化,但任何一门市面上流行的语言里依据静态类型做 codegen 优化是基础操作
上述第二点我在公司内已经通过自行编写 ts 的 custom transformer 插件方式就能实现基础的常量合并了(不超过 400 行就能实现),更高级的优化策略其实并没有系统性的阻碍 —— 只要官方想搞分分钟就能上
事实上 codegen 优化 ts 一直有做,比如说 downleveliterator、const-enum、和 decorator 的 meta 元信息输出[3]但是配合 webpack + ts-loader/esbuild 时,通常都开着 transpileOnly:true 和 isolatedModule:true 种种原因都会导致这些 codegen 优化的能力失效 —— 这其中有很大原因是 TC39 的 ESM 方案没有考虑这种 JIT 优化场景,当然也跟 ts-loader 的性能有关系,这些都涉及 codegen 和运行时的修改

# JS runtime 慢在哪里?

首先依据之前写的 v8 的研究,里面有条结论:只要构造合适,V8 JIT 可以有媲美原生 AOT 的性能,当然也容易出现非常夸张的性能劣化。(这里的媲美指的是没有数量级差距)
总的来说, js 遇到下述情况的时候会变慢
  1. 1.
    键值顺序 —— 纯靠人肉不可能保证每次构造对象的时候都保证一样的 key 顺序的,这会让 runtime 的 ICs 失效
  2. 2.
    基于原型链的 class 实际上是动态的 —— 这导致很多时候 runtime 都优化不了
  3. 3.
    语言标准的性能 —— 这里特指 js string 的性能,相当糟糕
  4. 4.
    TC39 特性以及社区实践不一定高性能 —— 比如
    ...
    for-of
    这些,可以看看编译成 es5 后这些特性会变成什么鬼样
  5. 5.
    打包与性能 —— 读取模块内暴露的方法带的点读操作是有开销的,对象 key 多的时候,点读开销不可忽略

# EcznScript

我从职业生涯的一开始就对编程语言的构造相当感兴趣并且有一些学习 & 开发经验,趁着这次 tsgo 发布之际,我计划业余开发一门编译为 js 的语言,目的是解决上面提到的问题,叫做 EcznScript,首先它不是 js 超集 (孙子),是完全不同于 js 的语言,而且我将直接抄来自 rust / golang / ts 的优秀的设计,并且摒弃其中 js 的一些垃圾鸡肋特性

如何保证键值顺序

键值顺序对于 JIT 性能来说相当重要,语言设计之处就要考虑这个,因此我决定用 struct 声明类型,并用 struct 来做字面量构造:
00//
01// EcznScript
02// 
03
04
05struct UserInfo {
06  uid: string,
07  name: string,
08}
09
10
11let user = UserInfo {
12  uid: '001',
13  name: 'eczn',
14}
15
16console.log(user)
00//
01// 编译输出 JS
02//
03
04// 自带构造器 & 类型 tag
05const UserInfo$$Tag = {};
06const UserInfo = (uid, name) => ({
07  tag: UserInfo$$Tag,
08  uid, name,
09})
10
11let user = UserInfo(
12  /* uid  */ '001',
13  /* name */ 'eczn',
14);
15
16console.log(user);
struct 编译期即可确定键值顺序,因此即便是顺序不一样了也可以通过编译修正
00//
01// EcznScript
02// 
03
04
05struct UserInfo {
06  uid: string,
07  name: string,
08}
09
10
11
12
13let user = UserInfo {
14  name: 'eczn', // 顺序不一样
15  uid: '001',
16}
17
18console.log(user)
00//
01// 编译输出 JS
02//
03
04// 自带构造器 & 类型 tag
05const UserInfo$$Tag = {};
06const UserInfo = (uid, name) => ({
07  tag: UserInfo$$Tag,
08  uid, name,
09})
10
11const __$0 = 'eczn';
12const __$1 = '001';
13let user = UserInfo(
14  /* uid  */ __$1,
15  /* name */ __$0,
16);
17
18console.log(user);

用 trait 解决 class 的问题

class 最大的问题:为了复用代码,但是对于 js 这种动态语言来说 class 反而是一种束缚,而且复杂的 class 几乎无法做高性能 JIT (因为对象是多态的、甚至是巨态的),因此不论是对性能还是可读性,必须要切割 class
对于 class 的问题业界已有被广泛接受的方式,那就是基于 interface / trait 来做组合式编程,对应的代表是 golang 和 rust,两者都有很不错的生态和接受度,因此我认为抛弃 class 没有任何问题,下面是一段例子,其中我借鉴了 rust 里对 dyn trait 处理的优秀经验,这使得动态派发实现成为可能,而且将调用开销降到了最低[4]动态分发那段 JIT 后会相当高效,简单试了下,m4 pro 跑一亿次分发只需要 30ms
00//
01// EcznScript
02// 
03
04struct UserInfo {
05  uid: string,
06  name: string,
07}
08
09
10
11
12trait Printable {
13  print(arg: i32): void
14}
15
16
17
18
19
20
21
22
23impl Printable for UserInfo {
24  print(arg) {
25    print(self, arg)
26  }
27}
28// 这里还有用很多细节没想好
29func gg(): impl Printable {
30  return UserInfo {
31    uid: '001',
32    name: 'eczn'
33  }
34}
35
36let r = gg()
37
38r.print(2025)
00//
01// 编译输出 JS
02// 
03
04const UserInfo$$Tag = {};
05// 自带构造器
06const UserInfo =
07  (uid, name) => ({
08    tag: UserInfo$$Tag,
09    uid, name,
10  });
11
12const Printable$$Print$$Tag = {};
13// 此处实现了跟 rust 一样的动态分发 (dynamic dispatch)
14// https://doc.rust-lang.org/std/keyword.dyn.html
15function impl$$Printable$$query(objTag, traitTag) {
16  if (objTag === UserInfo$$Tag) {
17    switch (traitTag) {
18      case Printable$$Print$$Tag: return UserInfo$$impl$$Printable$$print
19    }
20  }
21}
22
23function UserInfo$$impl$$Printable$$print(self, arg) {
24
25  console.log(self, arg);
26
27}
28
29function gg() {
30  return UserInfo (
31    /* uid  */ '001',
32    /* name */ 'eczn'
33  )
34}
35
36const r = gg()
37// 有静态类型,因此可以实现动态分发
38impl$$Printable$$query(r.tag, Printable$$Print$$Tag)(r, 2025)

语言标准的性能

js 里字符串是不可变的,v8 里对中文字符串的操作几乎都是会产生新的,比如说
"你"[0]
这样就会复制一次 index=0 的地方,只有英文才不会有复制 —— 而一个长度为 1 的字符串装箱后占用 12 字节,这会造成一定的性能折损 —— 当然多数情况下并不需要特别关心,除非这类操作变成高频操作,比如正在实现编译器的时候就涉及大量的字符串操作,此时会有大量字符串操作,会相当影响性能
golang 里对数组 / 字符串引入了切片这种构造来优化使用效果,我计划也整一个基于 js 的看看效果
00//
01// EcznScript
02// 
03let str = "你好,世界!"
04let str2 = str[:]
05let str3 = str[0]
00//
01// 编译输出 JS
02//
03let str = '你好,世界!'
04let str2 = _$$slice(str, 0, str.length)
05let str3 = _$$slice(str, 0, 0 + 1)
06
07const Slice$$Tag = {};
08function __$$slice(obj, start, end) {
09  // 这个构造在 v8 下是 12~16 字节,不会复制原字符串
10  return { tag: Slice$$Tag, obj, start, end }
11}

TC39 特性以及社区实践不一定高性能

js0 是近期社区提的概念,来自于 tc39 的特性分为两类:一种是语法糖,比如可选链这些,还有一种是 runtime 机制,比如 generator 这种,因此基于这个分类可以吧 js 标准拆成两份,一份是类似纯 ES5 的 js,只有核心特性,还有一份是各种语法糖等等; 显然一个静态的 js0 性能不会差到跟 native 有数量级差距的,而加入了动态性质后的 js 才是真正慢的根源 (比如 proxy、getter/setter、delete 等等,proxy 我在几年前手动测过:毫秒级的操作可以劣化到秒级
for-of 实际上会引入重型无栈协程,实际性能可能相当糟糕,尤其是编译成 es5 后会慢好几倍,再比如
fn(...args)
这种方式,实际上会遍历一次 args,性能极其糟糕,再比如
function fn(options)
然后 options 上放一堆东西,然后到处
...options
复制 & 合并,实际性能相当糟糕:
但是上面这些问题很大程度上可以通过现代的编译技术解决,比如 named parameters 以及调用参数调整可以最大程度减少调用开销、静态 struct 和 trait 分发解决方法点读的开销等等

最后一个,打包问题

前端打包这个概念接近于 native 语言的链接操作 —— 将所有符号和编译后的目标文件 all-in-one 在一个文件内。目前社区内流行的 js 打包方案均没有考虑点读性能问题,EzcnScript 最后还是会打包为单文件的,这个也是必须要解决的一个问题
其实曾经 ts 也是可以做打包的:配合 namespace 和 outFile 填为一个文件可以实现,虽然做得很薄,但在 4.x 时代的 ts 源码一直都是这样做的,效果也还可以,EcznScript 也要介入打包成 js 的处理,并且提供 .d.ts 文件供外部 js 做 ffi 使用。

# 几时完成?

注意,EcznScript 只是一个初步的想法[5]💡 补充下:
只能说确实是很稚嫩的想法,本文写完后我又看了一些 C / moonbit / wasm 的处理细节,发现如果有泛型的话 trait 的类型可以在编译期确定,此处对 trait 的处理更接近 golang 的处理,即包装成一个 go interface 对象来做 —— 只能说有各有好有坏吧,当然更重要的是:如果我真的要实现这个语言,那我为什么不直接用 moonbit 呢?那可是 Rust with GC ? 所以我预感到后面大概率会弃坑这个 233
,这里还是简单列几个阶段性的目标,后续我也会在本站持续更新开发进度,当然也可能随时弃坑:
  1. 1.
    实现 lexing & parsing,语法上计划直接抄 rust、go、ts 这三个,runtime 上会参考 rust 和 go 的一些处理,比如 trait,泛型走擦除路线(? 目前还不知道配合 trait 可不可行)
  2. 2.
    实现基础类型检查 (struct 引用和原始类型)
  3. 3.
    完成 js codegen 管线, 输出的 js 需要尽可能的静态
  4. 4.
    基础 LSP + VSCode 适配
  5. 5.
    完成 trait 系统: 声明、实现、动态分发
  6. 6.
    高级类型系统:enum-adt、类型推导、泛型等
  7. 7.
    ...




回到顶部