下面两段代码看起来生成了相同的结构,浏览器的处理结果却可能不同。
第一段是浏览器直接解析的 HTML:
<a href="/outer">
外层链接
<a href="/inner">内层链接</a>
</a>
第二段通过 JavaScript 创建 DOM:
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 时,也不应该包含按钮、输入框等交互内容。
<!-- 非法 -->
<a href="/article/1">
查看文章
<a href="/tag/react">React</a>
</a>
这不仅是语法洁癖。两个导航目标重叠后,浏览器和用户都很难确定一次操作到底代表什么:
- 鼠标点击应该进入文章,还是进入标签页?
- 键盘聚焦时,两个链接应该按照什么顺序出现?
- 屏幕阅读器应该如何描述这个交互区域?
- 内层点击冒泡后,是否还会触发外层导航?
即使内层 <a> 没有 href,它仍然是外层 <a> 的 <a> 后代,因此不能用这种方式规避嵌套限制。
二、浏览器修正的是 HTML 解析结果,不是所有 DOM 操作
需要区分两个概念:HTML 语法规则和 DOM API。
浏览器读取服务端返回的 HTML 字符串时,会运行 HTML Parser。解析器不仅负责把字符串变成节点,还带有一套错误恢复规则。遇到非法结构时,它可能自动结束标签、补充标签或者移动节点。
例如:
<p>
一段文本
<div>块级内容</div>
</p>
<p> 不能包含 <div>。浏览器解析时通常会在 <div> 前结束 <p>,最终 DOM 接近:
<p>一段文本</p>
<div>块级内容</div>
<p></p>
嵌套链接也会触发 HTML Parser 的错误恢复。下面的源码:
<a href="/outer">
outer
<a href="/inner">inner</a>
</a>
解析后的 DOM 通常更接近:
<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 虽然非法:
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 字符串解析:
- 使用 SSR 或 SSG,把 React 结果作为 HTML 返回给浏览器。
- 使用
innerHTML或dangerouslySetInnerHTML插入 HTML 字符串。
只要内容进入 HTML Parser,浏览器的错误恢复规则就可能介入。
四、SSR 水合问题是怎样产生的
SSR 页面大致经历四步:
- 服务端根据 React 组件生成 HTML 字符串。
- 浏览器解析字符串并构造真实 DOM。
- 浏览器使用这棵 DOM 完成首屏展示。
- 客户端 React 下载完成后,在已有 DOM 上执行水合。
问题发生在第二步和第四步之间。
React 服务端认为自己输出了这棵树:
a.article-link
├── h2
└── a.tag-link
浏览器解析非法嵌套后,真实 DOM 可能变成:
a.article-link
└── h2
a.tag-link
客户端 React 水合时仍然按照组件代码期待第一棵树,却只能找到第二棵树。节点层级、父子关系或节点数量对不上,就会出现 hydration mismatch。
常见提示包括:
Hydration failed because the server rendered HTML didn't match the client.
或者:
In HTML, <a> cannot be a descendant of <a>.
This will cause a hydration error.
具体提示文字会随 React 版本和框架而变化,但根因相同:组件预期的树与浏览器解析后的树不同。
五、为什么它会表现成首屏样式问题
非法嵌套不只会产生一条控制台警告。浏览器在 React 水合之前已经使用修正后的 DOM 绘制了页面,因此第一帧可能就是错误的。
假设样式依赖父子关系:
.article-link .tag-link {
position: absolute;
right: 16px;
bottom: 16px;
}
浏览器把 .tag-link 移到 .article-link 外面后,这条选择器不再匹配。标签可能突然出现在普通文档流中,卡片高度和间距也会随之变化。
React 开始水合后可能有几种表现:
- React 放弃复用这一部分 DOM,改为客户端重新渲染。
- 服务端首屏与客户端结果短暂切换,出现闪烁或布局跳动。
- 原本依赖父子选择器的 CSS 在首屏失效。
- 点击区域与视觉区域不一致。
- 事件绑定到与预期不同的节点。
如果页面只在 CSR 模式下运行,非法结构可能一直“看起来没问题”;一旦切到 Next.js SSR、SSG 或 React Server Components,这个问题才集中暴露。这也是它容易被误判为框架水合不稳定的原因。
一个真实案例:SSG 改造后首屏 CSS 丢失
我是在一次 SSG 改造中遇到这个问题的。页面原本由客户端渲染,改成 SSG 后,首屏出现了明显的样式丢失;等客户端 JavaScript 执行完毕,页面样式又恢复了。
一开始看起来像是 CSS 没有被正确打包,或者首屏没有加载到对应的样式文件。但检查 Network 后没有发现样式资源缺失,真正的异常出现在 DOM 结构上。
客户端完成渲染后,data-elem-id="card-body" 对应的 <a> 包含了卡片封面、标签、标题和作者等内容,其中又嵌套了多个 <a>:

在纯客户端渲染阶段,这棵树由 React 通过 DOM API 创建,因此嵌套结构被保留了下来。原有 CSS 选择器也是按照这层父子关系编写的,所以页面最终看起来正常。
但 SSG 生成的是 HTML 字符串。浏览器首次打开页面时,会先解析这段 HTML。遇到 <a> 嵌套 <a> 后,HTML Parser 提前结束了 card-body 链接,把原本位于它内部的节点移到了外面。最终它们不再是父子节点,而变成了并列节点:

DOM 层级改变后,依赖 .card-body .xxx 这类父子关系的选择器无法命中,于是表现为“首屏 CSS 丢失”。这并不是 CSS 文件真的丢了,而是浏览器修正 HTML 后,当前 DOM 已经不再满足选择器条件。
整个问题可以还原成下面这条链路:
CSR 阶段通过 DOM API 创建节点
-> 非法的 a 标签嵌套被保留
-> 原有父子选择器可以命中
改成 SSG 后输出 HTML 字符串
-> 浏览器解析时修正嵌套结构
-> 子节点被移到外层并成为并列节点
-> 父子选择器失效
-> 首屏样式异常,并可能继续触发水合不匹配
这个案例也说明,排查 SSR 或 SSG 首屏样式问题时,不能只检查 CSS 资源是否加载。还要对比首屏解析后的 DOM 与客户端完全渲染后的 DOM,确认两者的节点层级是否一致。
六、卡片整体可点击时应该怎么写
最常见的业务场景是:整张卡片点击后进入文章,但卡片内部还有作者、分类或标签链接。
错误做法是用一个外层 <a> 包住所有内容:
<a href="/article/1" className="card">
<img src="/cover.png" alt="" />
<h2>文章标题</h2>
<a href="/tag/react">React</a>
</a>
方案一:只有一个导航目标
如果卡片内部没有第二个链接,直接让整张卡片成为 <a> 最简单:
<a href="/article/1" className="card">
<img src="/cover.png" alt="文章封面" />
<h2>文章标题</h2>
<span className="tag">React</span>
</a>
这里标签只是展示信息,所以使用 <span>,不再承担跳转功能。
方案二:存在多个导航目标
如果标签必须单独跳转,应把链接改成兄弟节点:
<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>
.card {
position: relative;
}
.cardMainLink {
display: block;
}
.tagLink {
position: relative;
z-index: 1;
}
这两个链接没有父子关系,HTML 语义明确,也不会因为 SSR 解析而改变 DOM 层级。
如果产品要求卡片空白区域也能进入文章,可以使用“拉伸链接”方案:在主链接上增加一个覆盖卡片的伪元素,再让独立链接位于更高层级。
.cardMainLink::after {
position: absolute;
inset: 0;
content: '';
}
.tagLink {
position: relative;
z-index: 1;
}
这种方案仍需检查文本选择、右键菜单和其他按钮的点击体验,不能只看视觉效果。
方案三:容器处理跳转
也可以在普通容器上处理点击,但需要补齐键盘和无障碍行为:
<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> 包含不能放入段落的元素
<p>
文本
<div>内容</div>
</p>
类似问题还可能出现在 <p> 包含标题、列表、表格或 <section> 时。浏览器会提前结束 <p>。
<button> 包含交互元素
<button>
提交
<a href="/help">帮助</a>
</button>
按钮中再放链接、按钮、输入框或选择框,会造成嵌套交互目标。
<form> 嵌套 <form>
<form>
<form>...</form>
</form>
浏览器不会按源码建立两层表单,提交行为也可能与预期不同。
表格层级错误
<table>
<div>内容</div>
<tr>普通文本</tr>
</table>
表格有严格的结构要求。浏览器解析时可能自动插入 <tbody>,也可能把不合适的内容移动到表格外部。
列表直接包含非列表项
<ul>
<div>内容</div>
</ul>
<ul> 和 <ol> 的直接子元素应当是 <li>。这类结构不一定都以相同方式触发水合失败,但语义、无障碍和样式都可能受到影响。
八、如何快速定位这类问题
如果页面出现水合警告或首屏样式闪动,可以按下面的顺序排查。
第一,先看控制台。React 通常会指出不合法的标签关系,或者给出发生不匹配的组件栈。
第二,对比“查看网页源代码”和 Elements 面板。前者接近服务端返回的 HTML 字符串,后者是浏览器解析和 JavaScript 修改后的当前 DOM。如果二者层级不同,就要考虑 HTML Parser 的错误恢复。
第三,暂时禁用 JavaScript 后刷新页面。此时看到的是浏览器解析 SSR HTML 后的结果,可以排除客户端 React 后续修改的干扰。
第四,在可疑组件中搜索这些组合:
<a> 内有 <a>
<button> 内有交互元素
<p> 内有 <div>、列表、标题或表格
<form> 内有 <form>
table、tbody、tr、td 层级不正确
第五,使用 HTML Validator、eslint 插件或框架开发期警告尽早发现问题。不要等到生产环境启用 SSR 后,再依赖肉眼检查完整 DOM。
九、不要用 suppressHydrationWarning 掩盖结构错误
React 提供了 suppressHydrationWarning,但它主要用于明确知道服务端和客户端文本或属性会不同的场景,例如时间戳。
<time suppressHydrationWarning>{currentTime}</time>
它不是修复非法嵌套的工具。标签层级已经被浏览器修改时,真正的问题是 DOM 结构不同,而不是一条警告太吵。即使隐藏警告,首屏样式、事件和无障碍问题仍然存在。
正确做法始终是调整组件结构,让服务端输出的 HTML 在经过浏览器解析后仍然与 React 预期一致。
总结
同一段非法嵌套,在客户端 React 和 SSR 中可能表现不同,原因不在于浏览器随机处理,而在于它们经过了不同的建树路径:
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 掩盖问题。