C# 编译成 JavaScript:一个基于 Roslyn IOperation 的 Lowering 实践

把 C# 编译成 JavaScript:一个基于 Roslyn IOperation 的 Lowering 实践

从 IOperation 到 ESTree AST,以及这个过程中踩过的坑。

一、问题的起点

用 C# 写前端逻辑,然后直接变成 JavaScript,不依赖任何运行时——这个想法听起来有点疯狂。

市面上已有的方案无非两条路:

  1. 走 WebAssembly,带一个完整的 .NET 运行时(Blazor 模式)
  2. 语法级翻译,基于正则或 SyntaxTree 做替换

前者太重,后者太浅。我想要的是第三条路:基于完整语义分析的编译,输出零运行时的 JavaScript。

Roslyn 提供了 IOperation 层——那是一棵已经完成重载决议、类型推断、扩展方法展开的语义树。如果你要做一个真正的编译器,不是玩具级别的翻译器,IOperation 是唯一合理的起点。

这篇文章记录了基于 IOperation 做 lowering 的过程中,我遇到的那些非平凡的问题,以及各自的设计取舍。

二、架构:四层管线

整个编译过程分四层,每一层只做一件事。

Layer 1: Roslyn 编译
    C# → IOperation(语义树)

Layer 2: SemanticWalker
    遍历 IOperation,产出零散的 ESTree 节点
    外部 API 分派:Compile → Alias → Inline → Import → 常规 lowering

Layer 3: AstConverter
    收集 import 依赖 → 去重 → 排序 → 组装模块

Layer 4: ESGenerator
    ESTree → JavaScript 文本

核心是第二层。下文重点讲这层里那些值得展开的设计决策。

三、CLR 类型的映射:白名单与四层策略

零运行时意味着你不能把 List<T> 的完整实现带到 JS 里。但 C# 代码又天然依赖 BCL。矛盾怎么解?

白名单 + 四层映射。

白名单:只有显式用 [WhiteList] 标记的类型/成员才能使用。不在白名单里的,编译报错——静默擦除是不允许的。

映射策略分为四层,按以下顺序分派:

适用场景 例子
Compile AST 级宿主语义,最复杂 构造函数重载分派、继承的 super 映射
Alias 简单名称替换 string.Empty""
Inline 可内联的表达式模板 string.IsNullOrEmpty(s) → 内联 JS 表达式
Import 需要跨模块共享的 helper List<T>.Add → 运行时 helper 模块

一个关键原则:Compile 失败必须报编译错误,不能回退到常规 lowering。这是为了确保映射的准确性——如果声明了自定义逻辑却没生效,那是 bug,不是功能。

InlineImport 之间的选择也有一条清晰的线:简单且无控制流需求 → Inline;需要非平凡控制流或跨模块共享 → Import。但要注意,如果编译器语义分析已经能证明某个 guard 条件(比如调用点保证非空),那么即使原本用 Import 的场景,也可以降级为 Inline——前提是保持同一 API 的策略一致性,不能出现同一个方法在这个调用点内联、那个调用点导入的情况。

四、几个非平凡的 lowering 案例

4.1 模式匹配

C# 的模式匹配是我花时间最多的部分。不是因为复杂,而是因为组合爆炸。

单种模式容易处理:

C# JS 等价
x is int n typeof x === "number" + 赋值
x is string { Length: > 0 } typeof x === "string" && x.length > 0
x is [1, .., 3] 数组长度 + 索引比较 + slice

难点在于嵌套和组合。比如 x is [> 0, .. var rest],需要同时做长度检查、首元素类型/值检查、剩余元素 slice 并绑定。顺序很重要:先检查结构和类型,再访问属性,最后解构绑定。Roslyn 的 IOperation 已经把这种嵌套关系表达清楚了,但翻译成 JS 时仍然要小心求值顺序——不能因为把 guard 条件提前而导致副作用改变语义。

另外,or / and 模式会引入短路求值。JS 的 || / && 天然支持短路,但要注意模式内部可能包含解构赋值,解构不应该在短路消失时执行。这个细节花了好几个测试用例才稳定下来。

4.2 元组:擦除后的值组合

元组的 lowering 我做过一个内核决策:不保留 System.ValueTuple 的运行时身份。

也就是说:

  • (1, "a") 编译成 [1, "a"]
  • t.Item1 变成 t[0]
  • (x, y) = t 变成 [x, y] = t
  • t == (1, "a") 变成逐元素比较

为什么不保留?因为一旦保留运行时身份,就需要引入 ValueTuple 的完整实现(EqualsGetHashCodeIComparable 等),那就不是零运行时了。擦除是我为了保持设计边界而主动选择的取舍。代价是:C# 中元组的结构化相等在 JS 里需要逐元素比较,性能不是重点,语义一致才是。

4.3 构造函数重载 → 单一 constructor

C# 允许多个构造函数,JS 只有一个 constructor。解决方法是生成一个分派器:

class Foo {
  constructor() {
    const $ctor = arguments[0];
    if ($ctor === "$ctor_a") return this.$ctor_a();
    if ($ctor === "$ctor_b") return this.$ctor_b(arguments[1]);
    throw new Error("No matching constructor");
  }
  $ctor_a() { /* ... */ }
  $ctor_b(x) { /* ... */ }
}

但这里有几个必须承认的约束:

  • 每个构造函数生成稳定的 $ctor_<hash> helper,调用点使用 Roslyn 已绑定的 selector,因此相同参数个数、optional 参数重叠也可以精确分派,不依赖 arguments.lengthtypeof 猜测。
  • 派生类在对应分支先调用 super(...),再执行 helper;没有显式构造函数时会合成 super()
  • this(...)、外部基类构造协议,以及由 ref/out/in/params 驱动的构造函数分派仍明确拒绝。

补充说明:普通方法重载使用稳定的签名哈希命名;调用点根据 Roslyn 绑定结果选择目标,不依赖运行时 typeof 分支猜测。

4.4 跨模块调用的自动 import

当你调用另一个 [ECMAScriptModule] 上的方法时,编译器会自动收集依赖并生成 import 语句。

[ECMAScriptModule("math.mjs")]
static class MathModule {
    public static int Square(int x) => x * x;
}

[ECMAScriptModule("app.mjs")]
static class App {
    public static void Run() => MathModule.Square(5);
}

编译 App 模块时,编译器发现 MathModule.Square 是跨模块调用,于是在文件头部自动生成:

import { square } from "./math.mjs";
export function run() { return square(5); }

这个机制有三点值得注意:

  1. 收集发生在 lowering 阶段,SemanticWalker 在访问 IInvocationOperation 时就能判断目标是否属于另一个模块。
  2. 去重和排序由 AstConverter 做,排序键是确定性生成的,保证两次编译输出完全一致(byte-for-byte identical)。
  3. 循环依赖的处理:目前要求调用图不能有循环,否则编译报错。这是故意的简化,因为循环依赖在 ES 模块中虽然语法允许,但会导致初始化顺序问题。与其让运行时 behavior 不确定,不如编译期拒绝。

五、那些我主动拒绝的功能(以及理由)

很多时候,一个编译器的质量不取决于它支持了多少特性,而在于它能否清晰地定义自己不支持什么,并且报出明确的错误。

下面这些是 Jazor 明确拒绝的:

特性 拒绝理由
完整的 BCL 只支持白名单内的类型。ConcurrentDictionarySystem.IO.File 等不在列表中,编译直接报错。
泛型运行时特化 List<int>List<string> 编译后都是同一个 JS 数组。泛型擦除,不支持运行时类型测试。
反射 需要元数据,与零运行时冲突。
任意异常跨语言边界 JS 的异常和 .NET 异常模型不同,只支持简单的 throw/catch,不保证完整栈轨迹。
构造函数重叠重载分派 由 selector 精确分派;只有未实现的构造协议才明确报错。

这些限制都不是"还没实现",而是架构上的边界。每一条都在白名单验证阶段被捕获,不会静默失败。

六、测试驱动下的一人开发

具体提交数、代码行数和测试数量会随仓库演进,不作为编译器契约。当前行为以 src/Jazor.Compiler/ImplementationPrinciples.md、编译器 README 和测试套件为准。

七、总结:语义级编译的代价与收益

基于 IOperation 做 lowering 的好处是——不需要重新实现重载决议、类型推断、扩展方法展开,这些 Roslyn 都做好了。代价是——你必须理解 IOperation 的每一个细节,包括它的生命周期、不可变性限制,以及某些场景下语义信息仍然不足(比如无法区分 checkedunchecked 上下文,但不影响 JS 输出)。

零运行时的收益是——输出的 JS 干净、可读、无依赖。代价是——你无法携带 .NET 运行时模型,必须显式设计白名单和映射策略,并接受一系列功能边界。

对我来说,这是一个值得的交换。

如果你正在做类似的跨语言编译工作,我希望这篇里的几个案例——模式匹配的求值顺序、元组的运行时擦除、构造函数的分派约束、自动 import 的稳定性——能为你省下一些调试时间。关于普通方法重载的分派细节,或者其他某些具体实现,如果你感兴趣,可以去项目仓库的 Issue 或 Wiki 里继续讨论。

项目地址:https://github.com/devhxj/Jazor

posted @ 2026-05-11 13:22  形而下晓寒  阅读(9)  评论(0)    收藏  举报