如何找出两个 HTML 文件之间的差异

两个 HTML 文件在浏览器里可以渲染得一模一样,源码中却有几十处不同。 标签周围的空白字符、属性的顺序、HTML 实体,以及自闭合语法,都是常见的元凶。 纯文本 diff 会把它们统统标记为改动,从而把你真正关心的内容淹没掉。 本指南会告诉你如何得到干净的结果。

如果你想直接用工具,我们的 HTML 比较页面 让你粘贴两个文件,就能在浏览器里看到 diff,无需安装。 本文余下的部分讲解拿到结果之后该看些什么。

为什么 HTML diff 会产生这么多噪音

HTML 不是一种面向行的格式。一个段落可以写成一整行,也可以分散到十行, 两者都合法。大多数编辑器、代码格式化工具和 CMS 在保存时都会重新排版 HTML, 这意味着改动一个单词就可能连带出几十行重新排版的内容。 diff 会把这些看作改动的行,而不是"改了一个单词"。

WHATWG HTML Living Standard 规定了浏览器如何解析 HTML,但并未规定它应当如何被序列化回文本。 两个工具可以产出结构上完全相同的 HTML,作为原始文本看却毫不相像。

大部分噪音来自这四件事:

  • 标签之间和标签内部的空白字符
  • 属性顺序(浏览器并不要求特定顺序)
  • HTML 实体与字面字符之别
  • 空元素语法<br> vs <br />

空白字符:最大的误报来源

在大多数上下文中,HTML 里连续的空白字符在浏览器渲染时会折叠成一个空格。 这意味着下面这两段代码显示效果完全相同:

<!-- Version 1 -->
<p>Free text comparison tool.</p>

<!-- Version 2 -->
<p>
  Free text comparison tool.
</p>

纯文本 diff 会把两行都标记为已改动。但从任何有意义的角度看,它们都没有改动。 比较之前,先判断空白字符对你的场景是否重要。 对于邮件模板或 PDF 渲染器,它有时确实重要。对大多数网页来说,则不重要。

最好的解决办法,是在做 diff 之前用同一份配置、让两个文件都经过 Prettier 这样的格式化工具处理一遍。这一步就能归一化缩进、行长和间距。 一旦两个文件采用相同的排版风格,纯文本 diff 就只会标出真正的内容改动。

属性顺序

HTML 规范并不要求属性以任何特定顺序出现。 <div id="main" class="container"><div class="container" id="main"> 是同一个元素。 但按行 diff 会把它们当作不同的行。当模板由不同工具生成, 或者有人运行了一个按字母顺序排列属性的自动格式化工具时,这种情况尤其常见。

根据 WHATWG 解析规范, 同一元素上的属性按定义就是无序的。把属性重新排序报告为改动的 diff, 技术上没错,却很少有用。如果这造成了噪音,就在比较前把两个文件的属性顺序归一化。

HTML 实体

&amp;&lt;&gt;&nbsp;,以及像 &#8212; 这样的数字引用, 都是在 HTML 源码中编码字符的方式。两个文件可以用不同方式编码同一个字符, 却渲染出相同的页面。文本 diff 把 &amp;& 视为不同的字符串,尽管两者在浏览器里都会产生一个 & 符号。

如果实体差异让你的 diff 变得杂乱,就在比较前让两个文件都经过一个 会归一化实体的 HTML 解析器。浏览器自带的 DOMParser API 是在 JavaScript 里做这件事的可靠方式:解析两个字符串, 用 innerHTML 序列化回来,然后对结果做 diff。

空元素

在 HTML5 中,空元素(没有子节点的元素)不需要写闭合斜杠。 <br><br/><br /> 全都合法,解析结果也完全相同。 <img><input><meta> 以及其余的空元素也是如此。 如果一个文件用的是 XHTML 风格的自闭合斜杠,另一个用的是纯 HTML5 语法, 文本 diff 就会把每一个这样的元素都标出来。

MDN 上的空元素列表 涵盖了全部 14 个。在 diff 输出里快速搜一下 />, 就能知道其中有多少噪音来自自闭合语法。

一个完整实例

下面是一个站点头部的真实改动前后对比。有三处变化: 给 header 加了一个 CSS 类、修正了 Home 链接的目标,以及新增了一个 Contact 链接。

<!-- Version 1 -->
<header class="site-header">
  <nav>
    <a href="/home" class="nav-link active">Home</a>
    <a href="/about" class="nav-link">About</a>
  </nav>
</header>

<!-- Version 2 -->
<header class="site-header sticky-top">
  <nav>
    <a href="/" class="nav-link active">Home</a>
    <a href="/about" class="nav-link">About</a>
    <a href="/contact" class="nav-link">Contact</a>
  </nav>
</header>

把两者粘贴进 HTML 比较工具, diff 会精确高亮出这三行:header 的 class 属性、Home 的 href, 以及新增的那个 anchor 标签。没有噪音, 因为两个版本之间的缩进和排版是一致的。

上例中改动的内容
元素 Version 1 Version 2 改动类型
<header> class="site-header" class="site-header sticky-top" 新增了类
Home 链接 href="/home" href="/" 路径已修正
Contact 链接 不存在 <a href="/contact">Contact</a> 已新增

什么时候该在比较前做归一化

并非每一次 HTML 比较都需要归一化。如果你比较的是自己用相同编辑器设置写的两个文件, 纯文本 diff 通常就够了。归一化在下列情况下才划算:

  • 一个文件来自 CMS 导出,另一个来自你的编辑器
  • 构建工具重新排版了其中一个文件,另一个没有
  • 你在把压缩过的 HTML 与美化排版的 HTML 做比较
  • 你在审查一封来自外部发件人的 HTML 邮件模板

W3C 标记验证服务 可以用来在比较前检查两个文件是否都能正确解析。 标签结构有问题的文件会产出误导性的 diff, 因为解析器会以自己的方式从错误中恢复,而两个解析器的恢复方式可能不同。

比较生成的 HTML

服务端渲染框架(Angular、Next.js、Rails)常常会在 HTML 输出中嵌入时间戳、 nonce 或随机标识符。即便内容完全相同,同一个页面的两次渲染 在这些行上也会 diff 出差异。如果你要比较生成的 HTML, 请在做 diff 之前去掉或归一化这些字段。

本站底层的 diff 引擎是 Google 的 diff-match-patch (Apache 2.0),它处理的是纯文本。它不解析 HTML, 所以会把格式差异和内容差异一并标出。这正是先做归一化很重要的原因。 不过在多数场景下,直接粘贴两个文件,几秒钟内就能得到足够有用的结果。 如果你的 HTML 是格式良好的 XHTML,我们的 XML 比较工具也值得一试, 因为理解 XML 的 diff 能正确处理命名空间和属性顺序。

常见问题

我只改了一行,为什么 HTML diff 显示出几百处改动?
几乎总是格式变化造成的。如果你保存时编辑器或构建工具重新排版了文件(改了缩进、折行,或重排了属性),纯文本 diff 会把每一行重排后的内容都视为改动。先让两个文件经过同一个格式化工具处理,再做比较。这样剩下的唯一一行,就是真正的改动。
HTML 中属性的顺序重要吗?
对浏览器来说不重要。HTML Living Standard 并不要求属性以任何特定顺序出现,浏览器无论顺序如何都能正确解析。有些 linter 和格式化工具会按字母顺序排列属性作为风格规则,这会让两个语义完全相同的文件在文本 diff 里看起来不同。如果属性顺序造成了噪音,就在比较前用同一个格式化工具归一化两个文件。
HTML 源码中的 &amp;amp; 和 & 有什么区别?
浏览器渲染页面时,两者都会产生一个 & 字符。在 HTML 源码中,&amp; 是实体编码形式,而 & 是字面字符。按规范,属性值中的 & 技术上应当编码为 &amp;,但浏览器两种都接受。文本 diff 会把它们当作不同的字符串。如果实体编码造成了噪音,就用一个库或浏览器的 DOMParser 解析两个文件,序列化回来之后再比较。
我能把压缩过的 HTML 和美化排版的 HTML 做比较吗?
可以,但你应该先把压缩过的文件美化排版。直接拿压缩版和美化版做 diff,结果会是几乎每一行都显示为已改动,因为压缩移除了格式化工具添加的所有空白字符。先让压缩文件经过 Prettier 或同类格式化工具处理,再做比较。这样有意义的改动就能显现出来,而不被空白噪音淹没。
我只想比较 HTML 文件的一部分,比如某个特定组件,该怎么做?
从两个文件中把相关片段抽取出来,只把这部分粘贴进 diff 工具。举例来说,如果你在审查导航组件的改动,就只从每个文件里复制 <nav> 那一块。当你只关心某一个片段时却比较整份文档,会引入页面其他无关部分带来的噪音。
HTML 注释会出现在 diff 里吗?
会。基于文本的 diff 包含文件中的一切内容,注释也不例外。如果一个版本有一段注释而另一个版本删掉了,或者有开发者更新了某条注释,diff 都会显示出来。这通常是有用的:被删除的注释往往意味着一次有意的清理。如果你想忽略注释,就在比较前把它们从两个文件中去掉。

准备好比较你的 HTML 文件了吗?把两者都粘贴进免费的 HTML 比较工具,并排查看被高亮出的差异,无需注册账号。