创见博客
一个 <p> 包住 <div> 引发的 Hydration Error
七崽爱吃小饼干2026/07/07阅读 8专栏 Next.js/React

一个 <p> 包住 <div> 引发的 Hydration Error

最近排查了一个 Next.js 页面里的 hydration 报错,表面看起来像是 Markdown 里的图片渲染出了问题,实际根因是更基础的 HTML 语义错误:<div> 不能作为 <p> 的后代。

报错信息大概是这样:

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

这类问题很容易误判,因为你写的代码里可能并没有直接出现 <p><div /></p>,而是经过组件替换后才生成了非法结构。

先说结论

原生 <img> 放在 <p> 里通常不是问题:

html
<p>
  <img src="/demo.png" alt="demo" />
</p>

真正会出问题的是:你以为自己渲染的是 <img>,但实际组件输出的是一个带 <div> 外壳的复杂结构。

比如 Ant Design 的 Image 组件,最终 DOM 类似这样:

html
<div class="ant-image">
  <img src="/demo.png" alt="demo" />
  <div class="ant-image-mask">
    <div class="ant-image-mask-info">Preview</div>
  </div>
</div>

如果这个组件被 Markdown 默认段落包起来,最终就会变成:

html
<p>
  <div class="ant-image">
    ...
  </div>
</p>

这就是非法 HTML。

为什么非法 HTML 会变成 Hydration Error

React 服务端渲染时会输出一段 HTML 字符串。浏览器拿到 HTML 后,会先按照 HTML 解析规则构建真实 DOM。接着 React 在客户端执行 hydrate,把客户端虚拟 DOM 和浏览器已经构建好的真实 DOM 对齐。

问题在于,浏览器不会原样保留非法结构。比如:

html
<p>
  <div>content</div>
</p>

浏览器解析时会自动修正,大致变成:

html
<p></p>
<div>content</div>
<p></p>

也就是说:

服务端 React 以为结构是:

text
p
└─ div

浏览器实际解析出来的是:

text
p
div
p

客户端 hydrate 时一对比,发现节点层级不一致,就会报 hydration error。

所以这不是单纯的“样式问题”,也不是 React 小题大做,而是浏览器已经把 DOM 树改写了。

为什么 Markdown 场景特别容易遇到

Markdown 里的图片语法:

markdown
![demo](/demo.png)

通常会被解析成一个段落,段落里包含图片:

html
<p>
  <img src="/demo.png" alt="demo" />
</p>

这本来是合法的。

但很多项目会自定义 Markdown renderer,把 img 替换成业务组件:

tsx
<ReactMarkdown
  components={{
    img: ImageComponents,
  }}
/>

如果 ImageComponents 内部使用 AntD Image:

tsx
const ImageComponents = ({ node }) => {
  return <Image src={node.properties.src} />;
};

那 Markdown 的 <p><img /></p> 就会在 React 层变成 <p><AntdImage /></p>,而 AntD Image 实际输出 <div>,于是非法嵌套出现。

修复思路

最稳的修复方式不是强行改 AntD 组件,也不是把预览功能去掉,而是在 Markdown 的段落渲染阶段处理:如果一个段落里包含图片,就不要渲染成 <p>,改成 <div>。

示例:

tsx
const hasImageNode = (node?: HastNode): boolean => {
  if (!node) return false;
  if (node.type === 'element' && node.tagName === 'img') return true;
  return node.children?.some(hasImageNode) ?? false;
};

const ParagraphComponents = ({ node, children, ...props }) => {
  if (hasImageNode(node)) {
    return <div {...props}>{children}</div>;
  }

  return <p {...props}>{children}</p>;
};

然后注册到 react-markdown:

tsx
<ReactMarkdown
  components={{
    p: ParagraphComponents,
    img: ImageComponents,
  }}
/>

这样普通文字段落仍然是 <p>,图片段落则变成 <div>,AntD Image 的外层 <div> 也就有了合法容器。

排查这类问题的几个信号

看到下面这些现象时,可以优先怀疑 HTML 结构不合法:

  1. 报错里出现 cannot be a descendant of。
  2. 报错只在 SSR 或 Next.js 页面 hydration 时出现。
  3. 组件本身在纯客户端渲染时看起来正常。
  4. 页面里使用了 Markdown、富文本、CMS 内容或 dangerouslySetInnerHTML。
  5. 你替换了 Markdown 的默认标签,比如把 img、a、code 替换成 UI 组件。

一个实用原则

在 React 里写组件时,不只要看 JSX 表面写了什么,还要看组件最终输出的真实 DOM 是什么。

尤其是 UI 组件库组件,经常不是简单输出一个标签。一个看起来像图片的组件,可能外面包了预览层、遮罩层、占位层和交互层。只要它最终输出了 <div>,就不能放进 <p> 这种只允许 phrasing content 的容器里。

Hydration error 往往不是 hydrate 本身的问题,而是 SSR 输出、浏览器 HTML 解析、客户端 React 预期三者之间出现了结构差异。非法 HTML 正是最常见、也最隐蔽的一类原因。

评论
0/100