从 C# 到 mjs:Jazor 编译管线的端到端旅程

从 C# 到 .mjs:Jazor 编译管线的端到端旅程

一条 C# 类怎么变成浏览器能跑的 JavaScript 模块?四层管线,每层只做一件事。

一、全景:四层管线

一个标了 [ECMAScriptModule] 的 C# 类,从源码到 .mjs 文件,经过四层:

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

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

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

Layer 4: ESGenerator + Jazor.Emit
    ESTree → JavaScript 文本 + SourceMap → 物化 .mjs / .mjs.map

旁边还有两条辅助线:

  • Jazor.Analyzer:在第一层之前运行,前置拦截非法输入
  • Jazor.Compiler.Generator:扫描 CLR 模块声明,生成 WhiteList.cs.Generate.cs;该文件是生成输出,不应手工维护

二、第一层:Roslyn —— 拿到 IOperation

这是 Jazor 的"输入层"。用户写的 C# 代码首先被 Roslyn 正常编译。

[ECMAScriptModule("app.mjs")]
public static class App
{
    public static string Greet(string name)
        => $"Hello, {name}!";
}

Roslyn 做了三件事:

  1. 语法分析:产生 SyntaxTree
  2. 语义分析:绑定符号、解析类型、重载决议 → 产生 Compilation + SemanticModel
  3. IOperation 生成:对每个方法体、表达式、语句生成操作树

AstConverter 在这一层拿到 INamedTypeSymbol(类的符号)和 SemanticModel。它先处理"模块结构"——有哪些静态方法、哪些字段、哪些成员类——这些通过符号 API(GetMembers())遍历即可,不需要进入 IOperation。

真正的 IOperation 消费在第二层开始。

三、第二层:SemanticWalker —— 从 IOperation 到 ESTree

这是 Jazor 最核心的一层。SemanticWalker 继承自 OperationVisitor<SenseArgument, Node?>——Roslyn 提供的访问者基类。

工作方式:对每个方法体的 IOperation 节点,调用 Visit → 找出对应的 Visit* 方法 → 生成一个或者多个 ESTree 节点。

// AstConverter 中触发 SemanticWalker 的入口:
var operation = GetSemanticModel(blockSyntax).GetOperation(blockSyntax);
var walker = new SemanticWalker(classSymbol, ModuleDeclaredNames, cancellationToken);
walker.Visit(operation, argument);

SemanticWalker 按语法特性拆成了多个 partial 文件:

文件 职责
SemanticWalker.cs.Pattern.cs 模式匹配
SemanticWalker.cs.Ordinary.cs 通用语句和表达式
SemanticWalker.cs.Tuple.cs 元组 lowering
SemanticWalker.cs.Reference.cs 方法/属性/字段引用
SemanticWalker.cs.Creation.cs 对象创建、数组创建
SemanticWalker.cs.Switch.cs switch 语句和表达式
SemanticWalker.cs.Loop.cs 循环(for/foreach/while)
SemanticWalker.cs.TryCatch.cs 异常处理
SemanticWalker.cs.String.cs 字符串插值和操作
SemanticWalker.cs.WhiteList.cs 白名单映射(Alias/Inline/Import/Compile)
SemanticWalker.cs.Declaration.cs 声明(变量、lambda、局部函数)

在 lowering 过程中,如果遇到外部 API 调用(比如 List<T>.Add),SemanticWalker 会按 Compile → Alias → Inline → Import → 常规 lowering 分派。Compile 已声明但执行失败时属于 claimed-and-failed,必须报错,不能静默回退到普通 lowering。

Lowering 过程中还有一个重要的副产品:import 收集。每当 SemanticWalker 发现一个跨模块调用或者白名单 Import 引用,它就把目标模块路径和方法名记录到 SenseArgument 的 import 列表里。这个列表在第三层被消费。

四、第三层:AstConverter —— 模块组装

AstConverter 有两个角色:

角色 1:模块结构分发

在方法体 lowering 之前,AstConverter 先处理模块级结构——遍历 classSymbol.GetMembers(),对每种成员类型做分发:

foreach (var member in classSymbol.GetMembers())
{
    switch (member)
    {
        case IFieldSymbol field:      → ConvertModuleField()
        case IMethodSymbol func:      → ConvertModuleMethod()
        case INamedTypeSymbol @class: → AppendModuleClass()
    }
}
  • 静态字段 → let 变量声明
  • 静态方法 → export function
  • 成员类(非静态)→ class 声明,带 $ctor_<hash> 分派器
  • 接口 → 不发射(只做契约)
  • 枚举 → 不发射(只做编译期值域)

角色 2:Import 收集与模块头生成

AstConverterSemanticWalkerSenseArgument 里收集中间产生的 import 需求,做三件事:

  1. 去重:同一个外部符号被多个方法引用时,只生成一条 import
  2. 排序:按稳定的排序键排列(保证两次编译产出完全一致)
  3. 生成模块头:在所有 export 前面插入 import { ... } from "..." 声明
// 组装后的模块输出:
import { ref, reactive } from "./vue3.mjs";
import { square } from "./math.mjs";

export function greet(name) {
    return "Hello, " + name + "!";
}

构造函数分派的处理

AstConverter 还负责构造函数分派器的生成(如果成员类有多个构造函数)。这部分在之前的构造函数文章里详细写了——简言之:为每个重载生成 $ctor_<hash> helper,在主 constructor 里用签名 key 做分派。

五、第四层:ESGenerator + Jazor.Emit —— 物化输出

第三层产出的是一棵完整的 ESTree Module 节点。第四层把它变成文件。

ESGenerator:AST → JavaScript 文本

ESGenerator(Acornima 提供)遍历 ESTree,把每个节点序列化成 JavaScript 字符串。在这个阶段,SourceMap 也在同步生成——节点的 SourceOrigin(附着在 Node.UserData 上)被映射到 JS 输出的行列号。

// AST → JS 文本 + SourceMap
var artifact = module.ToECMAScriptWithSourceMap("app.mjs");
// artifact.Code       = "import { ... };\nexport function ..."
// artifact.SourceMap  = SourceMap JSON

Jazor.Emit:文件物化 + 清单 + Bundle

Jazor.Emit 是一个独立的命令行工具(在 NuGet 包里通过 tools/net11.0/ 分发),它在 MSBuild target 的 JazorEmit 中被调用:

dotnet Jazor.Emit.dll
    --root MyApp.dll          ← 用户编译产出的程序集
    --out ./jazor/            ← .mjs 输出目录
    --write-manifest ./jazor/jazor-manifest.json
    --clean true

Jazor.Emit 的工作:

  1. 扫描程序集中的所有 [ECMAScriptModule]
  2. 对每个模块调用编译管线(Layer 1-3)
  3. 把产出的 .mjs.mjs.map 写到输出目录
  4. 生成 jazor-manifest.json(列出所有产出的模块)

Jazor.Emit 的模式由 JazorMode=none|debug|release 控制。debug 物化 .mjs、source map 和 manifest;release 先物化中间产物,再由显式指定的 JazorTool=DenoJazorTool=Netpack 生成 bundle。Emit 本身负责 catalog、物化和 bundle,不拥有 compiler lowering。

六、MSBuild 触发时机

用户只需要 dotnet build,不需要手动触发任何 Jazor 命令。整条管线的触发链是:

dotnet build
  ├─ Roslyn 编译 C# → DLL
  │   ├─ Jazor.Analyzer 提供前置诊断
  │   └─ CLR 模块声明生成 WhiteList.cs.Generate.cs
  ├─ Jazor.Emit/MSBuild 集成按 JazorMode 物化 artifact
  └─ 输出:.mjs、可选 .mjs.map、manifest,以及 release lane 的 bundle

管道在编译期间运行,产物在 dotnet build 结束时就已经就绪。

七、确定性原则

整条管线有一个贯穿始终的原则:两次相同输入的编译产出应该完全相同(byte-for-byte identical)。

这体现在几个细节上:

  • import 排序键是确定性生成的(不依赖遍历顺序)
  • lowering 临时名由语义位点决定(不是递增计数器)
  • $ctor_<hash> 的哈希值基于签名内容(不依赖编译时间戳)
  • SourceMap 记录 source origin 的文件、行和列位置;合成节点不冒充用户源码位置

确定性不是"测试方便"问题——它是编译器作为构建工具的契约。如果两次编译产出不同,增量构建和缓存就会失效。

八、小结

Jazor 的编译管线可以压缩成四句话:

  1. Roslyn 负责拿到 IOperation——C# 源码的语义表示
  2. SemanticWalkerIOperation 变成 ESTree 节点——每一点语义翻译到 JS
  3. AstConverter 负责模块组装——import 收集、去重、排序、模块结构分发
  4. ESGenerator + Jazor.Emit 负责物化——AST 到文本、SourceMap、写入文件

管线的每一步都有明确的输入和输出,不越界。这是 Jazor 能保持编译输出可控和可预测的基础。

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

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