在 /dom-source-lab/line-marker 中选择页面标题后,右侧源码面板会立即定位到:
src/app/dom-source-lab/line-marker/Fixture.tsx:24:9
这条映射链路不需要分析 AST,也不依赖 Source Map。它采用的是最直接的办法:提前把源码文件和行列位置写入 DOM,用户选中元素后再读取这些信息。
本文以当前 Demo 的真实实现为例,拆解行号标记如何连接 JSX、DOM 和源码面板,以及这种方案的优势与限制。
1. 映射结果需要包含什么
源码映射不能只返回一个行号。当前 Demo 使用统一的数据结构描述位置:
type SourceLocation = {
file: string;
start: {
line: number;
column: number;
};
end?: {
line: number;
column: number;
};
nodeId?: string;
confidence?: number;
};
其中:
file表示源码文件;line和column用于定位源码;nodeId表示源码节点身份;confidence表示映射结果的可信度。
行号标记属于显式映射,不需要根据文本或 DOM 结构推测位置,因此当前 Demo 将置信度设置为 1。
2. 标记是在什么阶段添加的
当前实现没有使用 Babel、SWC 或其他编译插件。标记是在编码阶段手工添加到 JSX 中的。
首先定义一个标记生成函数:
const SOURCE_FILE =
'src/app/dom-source-lab/line-marker/Fixture.tsx';
function sourceMarker(
line: number,
id: string,
column = 7,
) {
return {
'data-source-file': SOURCE_FILE,
'data-source-line': line,
'data-source-column': column,
'data-source-id': id,
};
}
然后将标记展开到 JSX 元素上:
<h2 {...sourceMarker(24, 'fixture-title', 9)}>
每一个界面节点,都留下源码坐标。
</h2>
React 渲染完成后,浏览器中的 DOM 将包含对应属性:
<h2
data-source-file="src/app/dom-source-lab/line-marker/Fixture.tsx"
data-source-line="24"
data-source-column="9"
data-source-id="fixture-title"
>
每一个界面节点,都留下源码坐标。
</h2>

完整过程可以概括为:
手工标记 JSX
→ React 渲染
→ DOM 携带 data-source-* 属性
→ 用户选择 DOM
→ 映射器读取属性
→ 源码面板高亮对应行
3. DOM 选择与源码映射保持独立
元素选择仍然由通用的 DomPicker 完成:
const picker = new DomPicker({
root: canvas,
onSelect: async (element) => {
const location = await mapper.locate(element);
setSelection({ element, location });
},
});
DomPicker 只负责返回原始 Element,它不知道行号标记、AST 或 Source Map 的存在。
源码映射由独立的 DomSourceMapper 处理:
type DomSourceMapper = {
name: string;
locate: (
element: Element,
) => SourceLocation | null | Promise<SourceLocation | null>;
};
这种边界使不同方案可以复用同一个选择器和源码面板:
DomPicker
├── LineMarkerMapper
├── AstMapper
├── SourceMapMapper
└── RuntimeMapper
新增映射方案时,不需要修改元素选择器。
4. 如何从 DOM 读取源码位置
行号映射器从选中元素开始,向上寻找最近的完整标记:
const SOURCE_SELECTOR =
'[data-source-file][data-source-line]';
const sourceElement =
element.closest<HTMLElement>(SOURCE_SELECTOR);
找到元素后,将 dataset 转换成 SourceLocation:
const location: SourceLocation = {
file: sourceElement.dataset.sourceFile,
start: {
line: Number(sourceElement.dataset.sourceLine),
column: Number(
sourceElement.dataset.sourceColumn ?? 1,
),
},
nodeId: sourceElement.dataset.sourceId,
confidence: 1,
};
这里使用 closest,而不是只检查选中元素本身,是为了支持祖先回退。
例如,外层 article 带有标记,内部某个 span 没有标记。用户选择 span 时,映射器仍然可以沿祖先链定位到 article 的源码位置。
如果整个祖先链都没有标记,映射器返回 null。Demo 会明确显示“该元素及其祖先没有源码标记”,而不是根据文本或结构猜测结果。
5. 如何读取并高亮真实源码
获得文件路径和行号后,源码面板还需要读取对应文件。
Demo 提供了一个源码读取接口:
GET /api/dom-source-lab/source?file=...
服务端读取文件内容并返回给浏览器。源码面板按换行符拆分内容,渲染行号,并将目标行设置为高亮状态:
source.split('\n').map((line, index) => {
const lineNumber = index + 1;
const active = lineNumber === location.start.line;
return (
<div className={active ? styles.activeLine : styles.codeLine}>
<span>{lineNumber}</span>
<code>{line}</code>
</div>
);
});
目标行渲染完成后,再通过 scrollIntoView 将它移动到源码面板中央。
6. 为什么源码接口需要白名单
文件路径来自 DOM 属性,而 DOM 属性可以被浏览器开发者工具修改。如果服务端直接读取用户提交的路径,攻击者可能尝试访问项目中的其他文件。
因此,当前接口只允许读取明确登记的源码文件:
const ALLOWED_SOURCE_FILES = new Set([
'src/app/dom-source-lab/line-marker/Fixture.tsx',
]);
读取前必须先检查白名单:
if (!file || !ALLOWED_SOURCE_FILES.has(file)) {
return NextResponse.json(
{ error: '不允许读取该源码文件' },
{ status: 403 },
);
}
即使只是 Demo,也不应该提供一个能够读取任意磁盘路径的接口。
7. 动态列表如何映射
动态列表展示了行号标记方案中的一个重要特征:
{items.map((item, index) => (
<div
key={`${item}-${index}`}
{...sourceMarker(59, 'dynamic-instance', 13)}
>
<span>{index + 1}</span>
<strong>{item}</strong>
</div>
))}
无论循环创建多少个 DOM 元素,它们都来自同一个 JSX 模板节点,因此拥有相同的源码位置:
Fixture.tsx:59:13
这对于“跳转到源码”已经足够,但不能区分不同运行时实例。
如果后续需要支持画布选中状态、列表数据检查或协同编辑,还需要把映射模型拆成两层:
sourceNodeId:表示源码中的模板节点
instanceId:表示本次渲染产生的 DOM 实例
源码节点 ID 不应该因为运行时实例增加而改变。
8. 行号标记方案的优势
8.1 实现简单
行号标记不需要解析 JSX,也不需要处理 Source Map。只要 DOM 中存在位置属性,映射器就能直接读取。
核心逻辑只有两步:
closest 查找标记
→ dataset 转换为 SourceLocation
它适合快速验证“选择元素并打开源码”这条产品链路。
8.2 查询速度快
映射过程不需要扫描源码、比较文本或者遍历整棵 DOM 树。选中元素后,只需沿祖先链查找属性。
8.3 结果确定
运行时特征匹配可能遇到多个相同按钮、重复文本或结构相似的列表项。显式行号标记不存在这类歧义。只要属性正确,映射结果就是确定的。
8.4 容易排查问题
所有映射信息都能直接在浏览器开发者工具中查看:
data-source-file="..."
data-source-line="..."
data-source-column="..."
data-source-id="..."
当结果错误时,可以快速判断问题发生在标记生成、React 渲染、DOM 选择还是源码展示阶段。
8.5 与选择器解耦
DomPicker 只返回元素,行号映射器只解析位置。后续切换到 AST 映射时,选择器和源码面板都可以继续复用。
9. 行号标记方案的局限
9.1 手工行号容易失效
当前 Demo 最大的问题是行号由开发者手工填写。
假设在文件前面增加一行代码,后续所有标记都可能发生偏移:
<h2 {...sourceMarker(24, 'fixture-title', 9)}>
元素实际移动到第 25 行后,DOM 中仍然记录第 24 行,源码面板便会高亮错误位置。
因此,手工行号只适合 Demo,不适合持续维护的正式项目。
9.2 增加 DOM 体积
每个元素都可能携带文件、行、列和节点 ID。大量注入会增加 HTML 大小,也会增加服务端渲染和水合时需要传输的数据。
正式方案更适合只在 DOM 中保留短 ID:
<h2 data-source-id="n_7fa2">
完整位置则存放在独立映射表中。
9.3 可能泄露源码结构
data-source-file 会把项目目录暴露给浏览器:
src/app/dom-source-lab/line-marker/Fixture.tsx
开发环境中问题不大,但生产环境不应该默认公开完整源码路径。
9.4 无法描述复杂编译过程
如果 JSX 经过宏转换、模板预处理、代码生成或多阶段编译,简单行号未必能表示最终节点真正对应的原始源码。这类场景通常需要 AST 节点 ID 和 Source Map 共同参与。
9.5 源码节点与 DOM 不是一一对应
循环会把一个源码节点渲染为多个实例,Fragment 不会产生真实 DOM,组件也可能跨越多个文件。
行号标记只能说明“这个 DOM 来自哪个模板位置”,不能天然表达完整的组件和渲染关系。
9.6 依赖构建或编码过程可控
如果目标页面来自第三方站点,或者无法修改其源码与构建流程,就不能给元素添加标记。
这种情况下只能考虑 XPath、DOM 特征匹配或运行时增量分析,但映射精度也会随之下降。
10. 从手工标记走向自动注入
当前 Demo 验证的是运行时链路:
DOM Element
→ data-source-* 属性
→ SourceLocation
→ 读取源码
→ 高亮目标行
下一步可以通过 Babel 或 SWC 在编译阶段自动注入属性。
转换前:
<button type="button">提交</button>
转换后:
<button
type="button"
data-source-file="src/components/Form.tsx"
data-source-line="18"
data-source-column="3"
data-source-id="n_7fa2"
>
提交
</button>
解析器能够直接获得 AST 节点的真实位置,因此不再需要开发者维护行号。
更完整的实现还可以让 DOM 只保留节点 ID:
DOM data-source-id
→ 独立映射表
→ 文件与源码区间
这样既减少 DOM 冗余,也为后续 AST 映射、Source Map 回溯和节点稳定性研究留下空间。
11. 适用场景
当前行号标记方案适合:
- 验证 DOM 到源码跳转的产品交互;
- 开发环境中的内部调试工具;
- 技术教学和方案演示;
- 构建自动注入能力之前的 MVP。
它不适合直接作为大型生产系统的最终实现。
行号标记真正有价值的地方,不是手工填写了多少个 data-source-line,而是验证了一条清晰的映射链路:在源码信息仍然可控时,把身份传递到 DOM,再由统一映射接口还原成源码位置。
