根本原因是浏览器自动补全 DOM 结构,和服务端输出不一致:服务端省略 <tbody> 时,浏览器强制插入导致客户端真实 DOM 与 React 虚拟 DOM 结构不匹配,触发 hydration mismatch。

为什么 SSR 渲染的 <table> 会触发 hydration mismatch
根本原因是浏览器自动补全 DOM 结构,和服务端输出不一致。比如服务端返回 <table><tr><td>A</td></tr></table>,客户端 React 水合时发现真实 DOM 多了一个自动生成的 <tbody>,而虚拟 DOM 没有——结构对不上,React 就丢弃服务端 HTML,重新渲染整张表。
这不是 React 的 bug,是 HTML 规范强制要求:<tr> 必须嵌套在 <thead>、<tbody> 或 <tfoot> 内。漏写 <tbody> 是非法 HTML,浏览器解析时会“修复”,但修复方式不可控(有的补在开头,有的补在结尾)。
- Next.js、Remix、Nuxt 等 SSR 框架都受此影响,尤其在
use client组件中直接写 table 时高频出现 - 错误提示通常是
Warning: Expected server HTML to contain a matching <tbody> in <table> - 实际后果不只是警告:首次交互延迟、滚动位置丢失、焦点错乱、CSS 选择器失效(如
table tbody tr:first-child)
怎么写 table 才能绕过 hydration mismatch
唯一可靠做法:显式写出所有合法父容器。哪怕内容空,也要写 <tbody>,且不能靠 JS 动态插入或 CSS 生成。
Next.js 示例(必须加 "use client"):
立即学习“前端免费学习笔记(深入)”;
"use client";
export default function TableExample() {
return (
<table className="min-w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border px-4 py-2 text-left">Company</th>
<th className="border px-4 py-2 text-left">Contact</th>
<th className="border px-4 py-2 text-left">Country</th>
</tr>
</thead>
<tbody> {/* ← 这行不能省 */}
<tr>
<td className="border px-4 py-2">Alfreds Futterkiste</td>
<td className="border px-4 py-2">Maria Anders</td>
<td className="border px-4 py-2">Germany</td>
</tr>
</tbody>
</table>
);
}-
<thead>和<tbody>都要手动写,哪怕只有一组数据 - 不要用
dangerouslySetInnerHTML或[innerHTML]注入 table 片段——Angular/Next.js 中这会导致双重渲染 - 如果数据动态生成,确保每次 render 都保持相同结构层级,避免条件渲染导致
<tbody>消失
客户端 fallback 渲染:当 hydration 真失败了怎么办
即使结构合法,网络抖动、资源加载延迟、组件状态不一致仍可能导致 hydration 失败。这时不能让页面卡死或白屏,得降级为纯客户端渲染。
关键不是“重试 hydration”,而是提前埋点、检测失败、切换渲染路径:
- 用
useEffect+useState标记 hydration 是否完成,初始设为false,成功后设为true - 在 hydration 失败回调里(如 Next.js 的
useEffect中捕获 error),设置isHydrated = false,触发重新 render - fallback 渲染逻辑应完全独立:不依赖服务端数据,用
useState+useSWR或fetch拉取数据,再画 table - 避免视觉跳变:给 table 容器加
transition: opacity 0.2s,hydration 成功时淡入,失败时淡出再淡入新内容
容易被忽略的细节:CSS 和 JS 选择器也依赖结构合法性
hydration mismatch 往往只是表象,背后常连带样式和脚本失效。比如你写了 table > tbody > tr:hover,但服务端没写 <tbody>,浏览器补完后,这个选择器在客户端才生效——服务端渲染的样式就“不生效”;JS 里用 document.querySelector('table tbody') 在 hydration 前可能返回 null。
所以容灾不止是“让页面能显示”,更要保证行为一致:
- 所有 CSS 选择器必须基于显式写出的标签,别依赖浏览器自动补全
- DOM 查询逻辑(如
getElementsByClassName)要加存在性判断,别假设<tbody>一定在 - 服务端和客户端使用同一份 CSS,禁用仅客户端生效的伪类(如
:has()在 SSR 中不解析)



















