Jazor 的 SourceMap:让浏览器 DevTools 认得 C# 源码

SourceMap:让浏览器认得你的 C# 源码

你盯着 Chrome DevTools 里那行 __temp3 === null 的报错,死活想不起来它对应你写的哪行 C#。SourceMap 就是来解决这件事的。

一、调试的痛点:生成的代码不是人看的

前端开发有一个绕不开的环节:你写的代码,和浏览器里跑的代码,不是同一个文件。

写 TypeScript 的人,浏览器里跑的是编译后的 JavaScript。用 Webpack 的人,浏览器里跑的是打包压缩后的 bundle。写 C# 然后编译成 JS 的人——浏览器里跑的东西,跟你写的源码更是天差地别。

一个简单的 list?.Where(x => x > 0).Select(x => x * 2),编译后被展开成七八行:null 检查、临时变量、箭头函数包装、条件表达式。当浏览器报了一个 TypeError,行号指向生成代码的某一行,你看着那行 __temp3 === null,别说修 Bug 了——你连这是你写的哪行都不知道。

调用栈更绝望。你写的 CalculateTax 方法,在浏览器里叫 $CalculateTax_3a2f。你写的 ApplyDiscount,叫 $ApplyDiscount_7b1e。名字被编码了,参数被展开了,调用关系被重组了。

这个痛点不是 C# 转 JS 独有的。早在 2011 年,Google 的工程师就遇到了同样的问题——只不过他们的"源码"是未压缩的 JavaScript,"生成代码"是 Closure Compiler 压缩混淆后的产物。

二、SourceMap 是怎么来的

2011 年,Google 用 Closure Compiler 压缩 JavaScript。压缩效果好得惊人——变量名变成单字母,代码挤成一行,函数调用被内联。体积小了一半,但完全不可读。

出了 Bug,工程师只能对着压缩后的代码干瞪眼。于是 Closure Compiler 团队发明了 SourceMap——一张"翻译对照表",记录生成代码的每一个位置,对应原始源码的哪一个位置。

SourceMap 经历了三个版本。v1 和 v2 是 Closure Compiler 内部格式,2013 年发布的 SourceMap v3 成为通用标准。它的核心是一个叫 mappings 的字段,用 VLQ(Variable Length Quantity)编码把"生成位置 → 源位置"的映射关系压缩成一段字符串。浏览器加载这张表之后,DevTools 里直接显示原始源码——打断点、看变量、走调用栈,就像在调试未压缩的代码一样。

如今你用的 Webpack、Vite、esbuild、TypeScript——它们的 SourceMap 能力都源于这个标准。SourceMap 不是某个框架的特性,它是 Web 调试基础设施的一部分。

三、SourceMap 能干什么

简单说:让 Chrome DevTools 里出现你的原始源码,而不是编译后的产物。

具体到日常调试:

  • 断点打在源码上。你在 Sources 面板里打开 MyComponent.cs(或 .ts.jsx),在想要的行号上点一下——断点设好了。浏览器执行到对应的生成代码位置时,停下来,高亮的是你的源码行。
  • 调用栈显示原始方法名CalculateTax 而不是 $CalculateTax_3a2f
  • 鼠标悬停看变量值。和调试原生 JS 一样,悬停在变量名上就能看到当前值。
  • 单步执行。逐行源码往下走,不需要理解编译器中间做了什么。

本质上是把"生成的代码"和"源码"之间建立了一张位置映射表。调试器通过在映射表里查"当前停在生成代码的第几行第几列",找到对应的源码位置,然后高亮源码、显示变量——后面的所有体验都由 DevTools 原生支持。

四、怎么用

不需要额外配置。任何一个支持 SourceMap 的构建工具,都会在做完编译之后额外产出一个 .map 文件,并在 JS 文件末尾加一行注释指向它。

Jazor 编译后的输出就是这样的:

MyModule.mjs              ← JavaScript 代码
MyModule.mjs.map          ← SourceMap 映射表

MyModule.mjs 的最后一行:

//# sourceMappingURL=MyModule.mjs.map

浏览器加载 MyModule.mjs 时,看到这行注释,自动去拉 MyModule.mjs.map。然后在 DevTools 的 Sources 面板里,你会看到一个 ../src/MyComponent.cs——点进去,里面是你写的 C#。

Emit 当前会把 sourcesContent 写入外部 .map,release bundle 也会生成并携带 bundle source map;仓库没有文章所说的“生产环境关闭 SourceMap”开关。若发布策略需要隐藏源码,应在下游发布流程中处理 map 文件,而不是把它描述成 Jazor 的现有配置项。

五、Jazor 是怎么生成 SourceMap 的

很多转译器的做法是:先生成 JS,再回头对照源码位置拼映射表。Jazor 的做法不同——源位置信息在 lowering 阶段就附着到 AST 上了,emit 阶段只是把它序列化出来。

第一步:给每个 AST 节点背上"出生地"

Jazor 定义了一个叫 SourceOrigin 的 record:

internal sealed record SourceOrigin(
    string? SourcePath,       // C# 源文件路径
    int StartLine,            // 起始行
    int StartColumn,          // 起始列
    int EndLine,              // 结束行
    int EndColumn,            // 结束列
    string? Name = null,      // 可选的语义名称
    bool IsSynthetic = false  // 是不是编译器生成的合成节点?
);

在 lowering 过程中,每创建一个新的 ESTree 节点,就把当前 Roslyn IOperation 的源位置填进一个 SourceOrigin,挂到节点的 UserData 槽位上。UserData 是 Acornima AST 留给下游的一个 object? 自由槽位,Jazor 用它来背"这行代码原来在 C# 的哪个文件的哪一行"。

容易犯的错:覆盖了更精确的位置。 lowering 过程中会创建多层包装节点。外层包装(比如一个 SequenceExpression)如果覆盖了内层真正代表用户代码的节点(比如 CallExpression),SourceMap 就会丢失精度。Jazor 用 WithOriginIfMissing 来解决:只在节点还没有 origin 时才附着,保证最接近用户源码的 origin 胜出。

第二步:区分"用户写的"和"编译器插的"

lowering 过程中,Jazor 会插入大量"脚手架代码"——临时变量声明、IIFE 包装、逗号表达式序列。这些不是用户直接写的 C#,是编译器为了保证语义正确性塞进去的。

IsSynthetic = true 标记这些节点。在生成 SourceMap 时,合成节点被跳过——调试器不会在 var __temp3 = null 这种代码上停住,直接跳到下一个有真实源位置的生成代码行。

同样的逻辑用在 import 头部。Jazor 编译后的 .mjs 文件开头往往有一段自动生成的 import 声明——它们没有对应的 C# 源码行,标记为 IsSynthetic = true,不进入 SourceMap。

第三步:序列化

origin 都附着好了之后,emit 阶段遍历 AST,边写 JS 代码边记录每个生成位置对应的 SourceOrigin。不是一次性算整个 mappings 字符串,而是在 AST 遍历过程中增量构建。

最终产出标准的 SourceMap v3 JSON:

{
    "version": 3,
    "file": "MyModule.mjs",
    "sources": ["../src/MyComponent.cs"],
    "sourcesContent": ["using ECMAScript; ..."],
    "names": ["x", "result", "items"],
    "mappings": "AAAA;AACA;AACA,SAAS,GAAG;..."
}

容易犯的错:mappings 里行列号的基准搞混。 SourceMap v3 的规范里,行号和列号都是零基准的,而很多编辑器显示行号是从 1 开始。Roslyn 的 FileLinePositionSpan 也是零基准的——但如果中间经过了某个自己写的转换层,要注意不要把 0-based 和 1-based 搞混。

六、当前输出边界

已经落地的:

  • 每个 .mjs 模块自动产出 .mjs.map
  • sourcesContent 内嵌原始 C# 源码,浏览器不需要额外请求源文件
  • 合成节点过滤,编译器脚手架不污染调试体验
  • release bundle 会继续携带由 Emit 链路生成的 source map

当前 render-function .mjs 的生成节点可以保留对应的 Razor/C# source origin;SourceMap 已记录 source origin 的文件、行和列位置,合成节点不会覆盖更精确的用户节点。内联 data URL 不是当前 Emit 的默认输出契约,是否使用取决于下游发布工具。

七、收尾

SourceMap 解决的不是"能不能跑"的问题——代码没有 SourceMap 一样跑。它解决的是"出了 Bug 怎么修"的问题。

当你写完一段 C#,按下 F12,在 Chrome DevTools 里看到熟悉的文件名、熟悉的方法名、熟悉的行号——打断点、看变量、走调用栈,和调试自己写的 JS 没什么两样。SourceMap 最好的状态,就是让你忘了"这段代码其实已经被编译过"这件事。

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

posted @ 2026-05-12 17:12  形而下晓寒  阅读(19)  评论(0)    收藏  举报