Jazor 的 WhiteList:编译器里的四层 API 映射系统

Jazor 的 WhiteList:编译器里的四层 API 映射系统

Alias、Inline、Import、Compile——不是你说"这个方法叫 Math.abs"就完了,编译器要知道怎么把调用点变成正确的 JavaScript。

一、为什么需要一个白名单?

Jazor 的任务是把 C# 编译成 JavaScript。但 C# 代码天然依赖 BCL——string.IsNullOrEmptyList<T>.AddConsole.WriteLine,这些方法在 .NET 运行时里都有完整实现。Jazor 不包含 .NET 运行时,输出的是零依赖的纯 JavaScript。

所以 Jazor 面临一个根本问题:当编译器遇到一个不属于当前模块的方法调用时,它怎么知道这个方法在 JavaScript 里对应什么?

答案是一个叫 WhiteList 的映射表。它记录了每一个"外部 API"在 JavaScript 端的长相——方法名、所在的宿主对象、参数怎么传递。

二、四层映射策略:从简单到复杂

WhiteList 里的每个条目都标记了它属于哪一层映射。消费端的分派顺序是:

Compile → Alias → Inline → Import → 常规 lowering

每一层解决一个问题类型。下面逐一展开。

Alias:纯名字替换

最简单的映射。C# 端叫 A,JavaScript 端叫 B。没有任何逻辑变化。

// WhiteList 声明(在 ECMAScript.Contract 中):
[Jazor(Op.Alias, "")]
string.Empty  // → ""

Alias 适合的场景:常量映射、简单名称转换。string.Empty"" 是典型。Math.PIMath.PI 也是一样——后端已经知道宿主是 Math(通过 extension(Math)),这里只需要确认名字。

Alias 的语义是:生成代码时,用目标名字替换源名字。没有参数处理,没有逻辑注入。

Inline:可内联的表达式模板

当映射不只是换名字,而是需要参数参与时,Alias 不够用了。Inline 允许定义一个 JavaScript 表达式模板,把调用点的参数填入模板。

// WhiteList:
[Jazor(Op.Inline, "($0 == null || $0.length === 0)")]
bool string.IsNullOrEmpty(string value)
// 调用端 string.IsNullOrEmpty(s) → (s == null || s.length === 0)

模板里的 $0 指代第一个参数,$1 指代第二个,以此类推。

Inline 有两个重要限制:

  1. 模板必须能放进一个表达式位置。 不能包含语句(ifforreturn),只能是表达式。
  2. 模板必须简单。 如果模板开始膨胀——多行、嵌套、局部变量——就该考虑升级到 Import 或 Compile。

Jazor 的文档里有一条明确的迁移线:Inline 模板应该短且可读。如果需要非平凡控制流或跨模块共享,就应该升级。

Import:跨模块的 helper 方法

当逻辑复杂到不适合内联时,就轮到 Import。Import 会在编译输出中引入一个 import 语句,从指定的运行时模块引入一个 helper 函数。

// Jazor.CLR 声明:
[Jazor(Op.Import, "defaultRuntime", "contains")]
bool List<T>.Contains(T item)

编译结果:

import { contains } from "./defaultRuntime.mjs";

// 调用端:
list.contains(item);

Import 适合的场景:

  • 逻辑涉及控制流(if/else、循环)
  • 需要在多处复用的通用逻辑
  • 和 JavaScript 原生行为的语义差异需要额外处理

Import 和 Inline 之间的选择还有一个重要原则:同一个 API 的所有调用点必须走同一层映射。 不能在模块 A 里对 List<T>.Contains 走 Inline、在模块 B 里走 Import——输出一致性是编译器的一个硬要求。

Compile:AST 级的宿主语义

最高一层的映射——Compile。它完全接管了某个 API 的代码生成,不是做模板替换,而是直接用 C# 代码构造目标 AST 节点

Compile 的注册签名是一个委托:

Func<ISymbol, SenseArgument, Expression?, Expression?[], IOperation?, Expression?>

参数分别是:调用的符号、语义上下文、实例表达式(静态调用的为 null)、参数表达式数组、原始 IOperation。

Compile 适合的场景:

  • 需要根据上下文做不同处理的调用(如 base.Method()super.method()
  • 需要生成复杂 AST 结构的方法(如构造函数重载分派)
  • 需要在生成的代码中插入额外的语义检查(如类型转换、空值保护)

一个典型的 Compile 场景是 base.Property = value 的 lowering。C# 里访问基类属性用的是 base.Property,但 JS 里要写 super.Property = value。这不是简单的名字替换——编译器需要在 lowering 时识别调用上下文,构造不同的 AST 节点。Inline 做不到这一点,Compile 可以。

什么情况下升级到 Compile

ImplementationPrinciples.md 里有一条清晰的升级判据:

如果某个宿主能力继续停留在 Inline 会导致 AST 结构脆弱、sourcemap 难以维持、调试体验变差、规则组合性下降,那么它就应该升级到 Compile

Compile 不是"Inline 不够用了的兜底",它是一种等价的、更结构化的宿主语义表达方式。升级的标准不是"模板太长",而是"AST 层面需要上下文信息来做出正确的决策"。

三、白名单的生成流程:从 Attribute 到代码

白名单不是手写的。Jazor 有一个代码生成器(Jazor.Compiler.Generator)负责从 Attribute 标注中自动产生 WhiteList。

流程是:

  1. 开发者用 [Jazor(Op.*, ...)] 标注 CLR 运行时模块中的方法
  2. Jazor.Compiler.Generator 扫描这些 Attribute,生成 WhiteList.cs.Generate.cs
  3. WhiteList.cs.Generate.cs 被编译进 Jazor.Compiler,在运行时提供查找服务

生成器有一条关键的合约规则:白名单的 key 必须稳定。 如果从 Roslyn Symbol 推导 key,使用 symbol.OriginalDefinition.ToDisplayString(Format.NameFormat) 作为规范形式。不搞额外的规范化(不剥离 extern、不改写修饰符)——key 是什么就是什么。

这个"key 稳定"规则的工程意义是:白名单是自动生成的产出物。如果每次重新生成后 key 都变了,所有的查找都会断裂。所以生成器必须保证同一个符号在每次扫描中产出的 key 完全一致。

四、查找链:编译器怎么找到对应的映射

SemanticWalker 遇到一个外部方法调用时,它走一条多级查找链:

  1. 检查是否是当前模块内部声明的方法 → 直接 lowering,不走 WhiteList
  2. Compile 表里查
  3. Alias 表里查
  4. Inline 表里查
  5. Import 表里查
  6. 如果都没找到 → 进入普通 lowering 或报错:不支持的外部 API

关键的是,第 5 步(Compile)失败时不能回退——一旦注册了 Compile 但执行失败,说明是语义层面的问题,必须报错。编译器不允许"Compile 失败了但我用 Inline 兜底"。从设计原则上说,Compile 的失败是 claimed-and-failed,而不是 silent fallback。

查找支持多种 fallback 策略:

  • 静态扩展方法的查找:用 StaticExtensionNameFormat 做 key
  • overridevirtual 的兼容查找:找到基类声明
  • 泛型参数的结构匹配:List<T> 的 key 匹配 T 的引用

但这些都是 consumer-side 的查找补丁——不影响白名单 key 的生成。key 始终是规范形式。这条"key 不漂移"的原则是 Jazor WhiteList 设计里最重要的合约。

五、一条红线:不支持的类型必须报错

WhiteList 系统的一个重要职责是"拒绝"——编译器要知道什么不能做。

  • 不在白名单里的外部类型 → JAZOR001 诊断错误
  • 白名单里有映射但类型不兼容 → JAZOR002 模糊类型过滤
  • 泛型类型在运行时没有对应映射 → 编译报错

这些都是"明确的拒绝",不是"静默 fallback"。Jazor 的设计原则是:宁可少支持,不可瞎映射。

判断"是否支持一个外部类型"的标准是使用点裁决,而不是类型出现裁决。也就是说,List<Unsupported> 作为泛型参数出现时不报错——只有当你真的在这个 List<Unsupported> 上调用了某个需要运行时语义的方法时,编译器才会在那一刻拒绝。这个"延迟到使用点"的策略让 WhiteList 的检查粒度更精确,减少误报。

六、WhiteList 和 Analyzer 的分工

WhiteList 不是唯一的守卫。Jazor 还有一个 Jazor.Analyzer(Roslyn 诊断分析器),它在编译更早的阶段做部分检查。

两者的分工是:

  • Analyzer 做"前置收口"——在 IDE 里实时检查用户代码,诊断 JAZOR001JAZOR004。Analyzer 可以比编译器更严格,因为它的目标是在编译之前尽早发现问题。
  • SemanticWalker + WhiteList 做"最终裁决"——在 lowering 阶段,针对每一个具体的使用点判断支持情况。WhiteList 的 lookup 是在 lowering 时发生的,不是在分析阶段。

这个分层的好处是:用户写代码时 IDE 就能看到红色波浪线(Analyzer),不需要等到编译完才发现问题。而编译器层面的 WhiteList 保持精确——只在实际需要 lowering 时才做最终判断。

七、小结

Jazor 的 WhiteList 系统本质上是一个API 边界管理框架。它的四层结构(Alias/Inline/Import/Compile)覆盖了从简单名字替换到复杂 AST 构造的所有映射场景,同时用明确的"拒绝"策略防止不可靠的隐式映射。

这个系统还有几个重要的工程属性:

  • 自动生成:由 Jazor.Compiler.Generator 从 Attribute 标注自动产生,没有手写映射散落在各处
  • key 稳定:生成器保证同一符号的 key 在每次生成时完全一致
  • 使用点裁决:不在类型出现时报错,在实际使用时才判断
  • 分层守卫:Analyzer 做前置收口,WhiteList + SemanticWalker 做最终裁决

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

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