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 最好的状态,就是让你忘了"这段代码其实已经被编译过"这件事。

浙公网安备 33010602011771号