从 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 做了三件事:
- 语法分析:产生 SyntaxTree
- 语义分析:绑定符号、解析类型、重载决议 → 产生
Compilation+SemanticModel - 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 收集与模块头生成
AstConverter 从 SemanticWalker 的 SenseArgument 里收集中间产生的 import 需求,做三件事:
- 去重:同一个外部符号被多个方法引用时,只生成一条 import
- 排序:按稳定的排序键排列(保证两次编译产出完全一致)
- 生成模块头:在所有 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 的工作:
- 扫描程序集中的所有
[ECMAScriptModule]类 - 对每个模块调用编译管线(Layer 1-3)
- 把产出的
.mjs和.mjs.map写到输出目录 - 生成
jazor-manifest.json(列出所有产出的模块)
Jazor.Emit 的模式由 JazorMode=none|debug|release 控制。debug 物化 .mjs、source map 和 manifest;release 先物化中间产物,再由显式指定的 JazorTool=Deno 或 JazorTool=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 的编译管线可以压缩成四句话:
- Roslyn 负责拿到
IOperation——C# 源码的语义表示 - SemanticWalker 把
IOperation变成 ESTree 节点——每一点语义翻译到 JS - AstConverter 负责模块组装——import 收集、去重、排序、模块结构分发
- ESGenerator + Jazor.Emit 负责物化——AST 到文本、SourceMap、写入文件
管线的每一步都有明确的输入和输出,不越界。这是 Jazor 能保持编译输出可控和可预测的基础。

浙公网安备 33010602011771号