技术

XML Diff 的底层原理:从标记文本到数据树

2026年10月3日 · 约 6 分钟

如果问一个 XML diff 工具是怎么实现的,你可能会期待某种 XML 专属算法——SAX 事件流、DOM 树编辑距离之类的奇技淫巧。我们的真实答案平淡得近乎令人失望:我们根本不 diff XML。我们把两份文档解析成普通的数据树,交给一个 JSON 对比引擎,然后报告它返回的结果。真正有讲究的工程全在这个映射里——以及为什么这是正确的选择。

文本 diff 对 XML 失效得比 JSON 更彻底

JSON 有格式问题:同一份数据有多种序列化方式。XML 的问题是它的平方,因为规范本身就声明了好几种表面差异无关紧要:

属性顺序无关。 这两个元素完全相同:

<user id="42" name="Ann"/>
<user name="Ann" id="42"/>

标签间的空白无关。 缩进、元素间的换行——在大多数场景下都可以忽略:

<user><id>42</id><name>Ann</name></user>
<user>
  <id>42</id>
  <name>Ann</name>
</user>

空元素有两种写法。 <flag/> 和 <flag></flag> 是同一个节点。

逐行 diff 会在这三件事上全军覆没。而且和 JSON 不同,XML 没有 XML.stringify 可以先把文本规范化——美化打印会改变混合内容里的空白,那本身可能是有意义的。把 XML 当文本比较,是条死路。

映射:XML 变成数据树

我们的引擎(@compare-xml/core)构建在 fast-xml-parser 之上,有几个深思熟虑的开关:

const xmlParser = new XMLParser({
  ignoreAttributes: false,      // 属性是数据,不是噪声
  attributeNamePrefix: '@',     // 加前缀,避免和子元素撞名
  parseAttributeValue: true,    // id="42" 变成数字 42
});

映射规则很简单,但每条都编码了一个决策:

  • 有子节点的元素变成对象:<user><id>42</id></user> → { user: { id: 42 } }。
  • 重复的兄弟元素变成数组:同一个父节点下的三个 <item> → item: [ ... ]。这是 XML 无 schema 本性咬人的地方——一个 <item> 是对象,两个是数组,diff 必须能处理「中途才出现第二个元素」的文档。
  • 属性变成带 @ 前缀的 key:id="42" → '@id': 42。命名空间和属性从此和元素住在同一棵树里,用同一套路径语法寻址。
  • 文本内容变成值——parseAttributeValue 会把像数字的字符串变成真正的数字,所以 id="42" 按数字比较,而不是字符串。

属性顺序?没了——对象 key 的顺序从不参与比较。标签间空白?解析器丢弃了。<flag/> 对 <flag></flag>?解析结果相同。文本 diff 的三个陷阱一步之内全部消失。

免费得到的 diff

两份文档一旦变成数据树,比较就是一个已解决的问题——字面意义上的:compareXML 解析两边之后直接委托给 JSON 工具背后的引擎 @compare-json/core 的 compareJSON。这一个决定让 XML diff 免费继承了 JSON 引擎的一切:

  • 按路径寻址的结果——差异以 users.user[2].@id 的形式返回,而不是「第 847 行」,类型是 added、deleted 或 valueChanged。
  • 三种数组对比策略——重复元素就是数组,所以位置配对、LCS 最小编辑、无序成员匹配全部适用。<item> 列表只是换了顺序?Unordered 模式报告零差异。在头部插入一个元素?LCS 模式报告一次插入,而不是所有索引全部偏移。
  • 大小写不敏感选项——key 和值的大小写折叠直接沿用。

翻译过程中有一处刻意的损失:JSON 的 typeChanged 活不下来,因为 XML 没有类型可变——源文档里的一切都是文本。映射层会把 diff 类型过滤到 XML 真正会产出的三种。

先校验,再比较

XML 有一种 JSON 没有的失败模式:文档可以花样百出地无法解析——未闭合的标签、不匹配的命名空间、非法实体。所以引擎先用 XMLValidator 校验,报告错误所在的精确行列,然后才解析。从一份坏文档开始的比较只能是垃圾的 diff;不如直接说「第 12 行,第 34 列」然后停下。

同一输出,三种视图

因为 diff 结果和 JSON 工具产出的是同一张扁平的、按路径寻址的列表,整个 UI 层的思路是共享的:按路径分组差异的侧边栏、把高亮映射回源码范围的 Monaco 双栏视图,以及给习惯看 +/- 的人准备的 git-diff 视图。一次计算,三种呈现——而且没有一种知道输入曾经是 XML。

总结

语义化 XML diff 的本质是拒绝写一个 XML diff:

  1. 把两份文档解析成数据树:属性带 @ 前缀,重复元素变数组。
  2. 把树交给带 LCS 和无序数组策略的 JSON 树比较器。
  3. 把返回的按路径寻址的差异映射回 UI。

XML 专属的工作收缩到它该在的地方——解析、映射、校验——下游的一切都是共享的、测过的、免费的。

拿你自己的数据试试:立即对比两个 XML 文档。