创见博客
React 中非法 HTML 嵌套:为什么客户端正常,SSR 却水合失败?
七崽爱吃小饼干2026/08/05阅读 0

下面两段代码看起来生成了相同的结构,浏览器的处理结果却可能不同。

第一段是浏览器直接解析的 HTML:

html
<a href="/outer">
  外层链接
  <a href="/inner">内层链接</a>
</a>

第二段通过 JavaScript 创建 DOM:

js
const outer = document.createElement('a');
outer.href = '/outer';

const inner = document.createElement('a');
inner.href = '/inner';

outer.appendChild(inner);
document.body.appendChild(outer);

第一段经过 HTML Parser 时,浏览器通常会结束外层 <a>,避免形成链接嵌套。第二段使用 DOM API 创建,嵌套结构却可以保留下来。

这正是 React 项目里一个容易被忽略的问题:客户端渲染时看似正常的非法 HTML,在 SSR 场景下可能被浏览器重构,随后导致 hydration mismatch、首屏样式闪动和点击区域异常。

一、<a> 里为什么不能再放 <a>

HTML 的内容模型不允许 <a> 元素包含另一个 <a> 元素。外层链接存在 href 时,也不应该包含按钮、输入框等交互内容。

html
<!-- 非法 -->
<a href="/article/1">
  查看文章
  <a href="/tag/react">React</a>
</a>

这不仅是语法洁癖。两个导航目标重叠后,浏览器和用户都很难确定一次操作到底代表什么:

  1. 鼠标点击应该进入文章,还是进入标签页?
  2. 键盘聚焦时,两个链接应该按照什么顺序出现?
  3. 屏幕阅读器应该如何描述这个交互区域?
  4. 内层点击冒泡后,是否还会触发外层导航?

即使内层 <a> 没有 href,它仍然是外层 <a> 的 <a> 后代,因此不能用这种方式规避嵌套限制。

二、浏览器修正的是 HTML 解析结果,不是所有 DOM 操作

需要区分两个概念:HTML 语法规则和 DOM API。

浏览器读取服务端返回的 HTML 字符串时,会运行 HTML Parser。解析器不仅负责把字符串变成节点,还带有一套错误恢复规则。遇到非法结构时,它可能自动结束标签、补充标签或者移动节点。

例如:

html
<p>
  一段文本
  <div>块级内容</div>
</p>

<p> 不能包含 <div>。浏览器解析时通常会在 <div> 前结束 <p>,最终 DOM 接近:

html
<p>一段文本</p>
<div>块级内容</div>
<p></p>

嵌套链接也会触发 HTML Parser 的错误恢复。下面的源码:

html
<a href="/outer">
  outer
  <a href="/inner">inner</a>
</a>

解析后的 DOM 通常更接近:

html
<a href="/outer">outer</a>
<a href="/inner">inner</a>

但 document.createElement()、appendChild() 这类 DOM API 不会替开发者执行完整的 HTML 内容模型校验。只要操作本身满足 DOM 树的基础约束,浏览器就允许把一个 <a> 放进另一个 <a>。

所以,DevTools 的 Elements 面板中能看到某种结构,不代表它符合 HTML 规范。浏览器可以保存一棵由 JavaScript 构造出的非法 HTML DOM。

三、为什么客户端 React 可能保留这种结构

React 客户端渲染最终也要调用浏览器 DOM API。下面的 JSX 虽然非法:

tsx
export function Card() {
  return (
    <a href="/article/1">
      <h2>文章标题</h2>
      <a href="/tag/react">React</a>
    </a>
  );
}

但在纯客户端渲染中,React 可以通过创建节点、设置属性和插入子节点,构造出嵌套的 <a>。浏览器不会因为 appendChild() 被调用就重新运行整页 HTML Parser。

开发环境中的 React 可能输出 DOM 嵌套警告,但警告并不等于浏览器会删除节点。生产构建也可能不展示相同的开发期提示。

下面两种情况则不同,因为它们仍然涉及 HTML 字符串解析:

  1. 使用 SSR 或 SSG,把 React 结果作为 HTML 返回给浏览器。
  2. 使用 innerHTML 或 dangerouslySetInnerHTML 插入 HTML 字符串。

只要内容进入 HTML Parser,浏览器的错误恢复规则就可能介入。

四、SSR 水合问题是怎样产生的

SSR 页面大致经历四步:

  1. 服务端根据 React 组件生成 HTML 字符串。
  2. 浏览器解析字符串并构造真实 DOM。
  3. 浏览器使用这棵 DOM 完成首屏展示。
  4. 客户端 React 下载完成后,在已有 DOM 上执行水合。

问题发生在第二步和第四步之间。

React 服务端认为自己输出了这棵树:

text
a.article-link
├── h2
└── a.tag-link

浏览器解析非法嵌套后,真实 DOM 可能变成:

text
a.article-link
└── h2

a.tag-link

客户端 React 水合时仍然按照组件代码期待第一棵树,却只能找到第二棵树。节点层级、父子关系或节点数量对不上,就会出现 hydration mismatch。

常见提示包括:

text
Hydration failed because the server rendered HTML didn't match the client.

或者:

text
In HTML, <a> cannot be a descendant of <a>.
This will cause a hydration error.

具体提示文字会随 React 版本和框架而变化,但根因相同:组件预期的树与浏览器解析后的树不同。

五、为什么它会表现成首屏样式问题

非法嵌套不只会产生一条控制台警告。浏览器在 React 水合之前已经使用修正后的 DOM 绘制了页面,因此第一帧可能就是错误的。

假设样式依赖父子关系:

css
.article-link .tag-link {
  position: absolute;
  right: 16px;
  bottom: 16px;
}

浏览器把 .tag-link 移到 .article-link 外面后,这条选择器不再匹配。标签可能突然出现在普通文档流中,卡片高度和间距也会随之变化。

React 开始水合后可能有几种表现:

  1. React 放弃复用这一部分 DOM,改为客户端重新渲染。
  2. 服务端首屏与客户端结果短暂切换,出现闪烁或布局跳动。
  3. 原本依赖父子选择器的 CSS 在首屏失效。
  4. 点击区域与视觉区域不一致。
  5. 事件绑定到与预期不同的节点。

如果页面只在 CSR 模式下运行,非法结构可能一直“看起来没问题”;一旦切到 Next.js SSR、SSG 或 React Server Components,这个问题才集中暴露。这也是它容易被误判为框架水合不稳定的原因。

一个真实案例:SSG 改造后首屏 CSS 丢失

我是在一次 SSG 改造中遇到这个问题的。页面原本由客户端渲染,改成 SSG 后,首屏出现了明显的样式丢失;等客户端 JavaScript 执行完毕,页面样式又恢复了。

一开始看起来像是 CSS 没有被正确打包,或者首屏没有加载到对应的样式文件。但检查 Network 后没有发现样式资源缺失,真正的异常出现在 DOM 结构上。

客户端完成渲染后,data-elem-id="card-body" 对应的 <a> 包含了卡片封面、标签、标题和作者等内容,其中又嵌套了多个 <a>:

客户端完成渲染后的 DOM,card-body 链接内部包含其他链接

在纯客户端渲染阶段,这棵树由 React 通过 DOM API 创建,因此嵌套结构被保留了下来。原有 CSS 选择器也是按照这层父子关系编写的,所以页面最终看起来正常。

但 SSG 生成的是 HTML 字符串。浏览器首次打开页面时,会先解析这段 HTML。遇到 <a> 嵌套 <a> 后,HTML Parser 提前结束了 card-body 链接,把原本位于它内部的节点移到了外面。最终它们不再是父子节点,而变成了并列节点:

SSG 首屏解析后的 DOM,card-body 内的元素被移出并变成并列节点

DOM 层级改变后,依赖 .card-body .xxx 这类父子关系的选择器无法命中,于是表现为“首屏 CSS 丢失”。这并不是 CSS 文件真的丢了,而是浏览器修正 HTML 后,当前 DOM 已经不再满足选择器条件。

整个问题可以还原成下面这条链路:

text
CSR 阶段通过 DOM API 创建节点
-> 非法的 a 标签嵌套被保留
-> 原有父子选择器可以命中

改成 SSG 后输出 HTML 字符串
-> 浏览器解析时修正嵌套结构
-> 子节点被移到外层并成为并列节点
-> 父子选择器失效
-> 首屏样式异常,并可能继续触发水合不匹配

这个案例也说明,排查 SSR 或 SSG 首屏样式问题时,不能只检查 CSS 资源是否加载。还要对比首屏解析后的 DOM 与客户端完全渲染后的 DOM,确认两者的节点层级是否一致。

六、卡片整体可点击时应该怎么写

最常见的业务场景是:整张卡片点击后进入文章,但卡片内部还有作者、分类或标签链接。

错误做法是用一个外层 <a> 包住所有内容:

tsx
<a href="/article/1" className="card">
  <img src="/cover.png" alt="" />
  <h2>文章标题</h2>
  <a href="/tag/react">React</a>
</a>

方案一:只有一个导航目标

如果卡片内部没有第二个链接,直接让整张卡片成为 <a> 最简单:

tsx
<a href="/article/1" className="card">
  <img src="/cover.png" alt="文章封面" />
  <h2>文章标题</h2>
  <span className="tag">React</span>
</a>

这里标签只是展示信息,所以使用 <span>,不再承担跳转功能。

方案二:存在多个导航目标

如果标签必须单独跳转,应把链接改成兄弟节点:

tsx
<article className="card">
  <a href="/article/1" className="cardMainLink">
    <img src="/cover.png" alt="文章封面" />
    <h2>文章标题</h2>
    <p>文章简介</p>
  </a>

  <a href="/tag/react" className="tagLink">
    React
  </a>
</article>
css
.card {
  position: relative;
}

.cardMainLink {
  display: block;
}

.tagLink {
  position: relative;
  z-index: 1;
}

这两个链接没有父子关系,HTML 语义明确,也不会因为 SSR 解析而改变 DOM 层级。

如果产品要求卡片空白区域也能进入文章,可以使用“拉伸链接”方案:在主链接上增加一个覆盖卡片的伪元素,再让独立链接位于更高层级。

css
.cardMainLink::after {
  position: absolute;
  inset: 0;
  content: '';
}

.tagLink {
  position: relative;
  z-index: 1;
}

这种方案仍需检查文本选择、右键菜单和其他按钮的点击体验,不能只看视觉效果。

方案三:容器处理跳转

也可以在普通容器上处理点击,但需要补齐键盘和无障碍行为:

tsx
<article
  className="card"
  role="link"
  tabIndex={0}
  onClick={() => router.push('/article/1')}
  onKeyDown={(event) => {
    if (event.key === 'Enter') {
      router.push('/article/1');
    }
  }}
>
  <h2>文章标题</h2>
</article>

不过这仍然不如原生 <a> 完整。原生链接天然支持在新标签页打开、复制链接地址、浏览器状态栏预览和辅助技术识别。因此能够使用原生链接时,应优先使用原生链接。

七、其他容易导致 SSR 水合异常的非法结构

<a> 嵌套只是其中一种。下面这些结构也值得检查。

<p> 包含不能放入段落的元素

html
<p>
  文本
  <div>内容</div>
</p>

类似问题还可能出现在 <p> 包含标题、列表、表格或 <section> 时。浏览器会提前结束 <p>。

<button> 包含交互元素

html
<button>
  提交
  <a href="/help">帮助</a>
</button>

按钮中再放链接、按钮、输入框或选择框,会造成嵌套交互目标。

<form> 嵌套 <form>

html
<form>
  <form>...</form>
</form>

浏览器不会按源码建立两层表单,提交行为也可能与预期不同。

表格层级错误

html
<table>
  <div>内容</div>
  <tr>普通文本</tr>
</table>

表格有严格的结构要求。浏览器解析时可能自动插入 <tbody>,也可能把不合适的内容移动到表格外部。

列表直接包含非列表项

html
<ul>
  <div>内容</div>
</ul>

<ul> 和 <ol> 的直接子元素应当是 <li>。这类结构不一定都以相同方式触发水合失败,但语义、无障碍和样式都可能受到影响。

八、如何快速定位这类问题

如果页面出现水合警告或首屏样式闪动,可以按下面的顺序排查。

第一,先看控制台。React 通常会指出不合法的标签关系,或者给出发生不匹配的组件栈。

第二,对比“查看网页源代码”和 Elements 面板。前者接近服务端返回的 HTML 字符串,后者是浏览器解析和 JavaScript 修改后的当前 DOM。如果二者层级不同,就要考虑 HTML Parser 的错误恢复。

第三,暂时禁用 JavaScript 后刷新页面。此时看到的是浏览器解析 SSR HTML 后的结果,可以排除客户端 React 后续修改的干扰。

第四,在可疑组件中搜索这些组合:

text
<a> 内有 <a>
<button> 内有交互元素
<p> 内有 <div>、列表、标题或表格
<form> 内有 <form>
table、tbody、tr、td 层级不正确

第五,使用 HTML Validator、eslint 插件或框架开发期警告尽早发现问题。不要等到生产环境启用 SSR 后,再依赖肉眼检查完整 DOM。

九、不要用 suppressHydrationWarning 掩盖结构错误

React 提供了 suppressHydrationWarning,但它主要用于明确知道服务端和客户端文本或属性会不同的场景,例如时间戳。

tsx
<time suppressHydrationWarning>{currentTime}</time>

它不是修复非法嵌套的工具。标签层级已经被浏览器修改时,真正的问题是 DOM 结构不同,而不是一条警告太吵。即使隐藏警告,首屏样式、事件和无障碍问题仍然存在。

正确做法始终是调整组件结构,让服务端输出的 HTML 在经过浏览器解析后仍然与 React 预期一致。

总结

同一段非法嵌套,在客户端 React 和 SSR 中可能表现不同,原因不在于浏览器随机处理,而在于它们经过了不同的建树路径:

text
HTML 字符串 -> HTML Parser -> 可能执行错误恢复并重构 DOM
React 客户端渲染 -> DOM API -> 通常保留 JavaScript 创建的嵌套
SSR React -> HTML 字符串 -> HTML Parser -> 真实 DOM 可能偏离 React 预期

因此,客户端页面中“能够渲染出来”不能证明结构合法。对于 SSR 项目,非法 HTML 还会进一步放大为水合失败和首屏样式问题。

排查这类问题时,先比较服务端源码与 Elements 中的真实 DOM,再检查 <a>、<button>、<p>、<form> 和表格等有明确内容模型限制的元素。修复时优先保留原生语义,把多个链接改为兄弟节点,而不是依靠事件阻止、CSS 层级或 suppressHydrationWarning 掩盖问题。

评论
0/100