一个 <p> 包住 <div> 引发的 Hydration Error
最近排查了一个 Next.js 页面里的 hydration 报错,表面看起来像是 Markdown 里的图片渲染出了问题,实际根因是更基础的 HTML 语义错误:<div> 不能作为 <p> 的后代。
报错信息大概是这样:
In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.
这类问题很容易误判,因为你写的代码里可能并没有直接出现 <p><div /></p>,而是经过组件替换后才生成了非法结构。
先说结论
原生 <img> 放在 <p> 里通常不是问题:
<p>
<img src="/demo.png" alt="demo" />
</p>
真正会出问题的是:你以为自己渲染的是 <img>,但实际组件输出的是一个带 <div> 外壳的复杂结构。
比如 Ant Design 的 Image 组件,最终 DOM 类似这样:
<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 默认段落包起来,最终就会变成:
<p>
<div class="ant-image">
...
</div>
</p>
这就是非法 HTML。
为什么非法 HTML 会变成 Hydration Error
React 服务端渲染时会输出一段 HTML 字符串。浏览器拿到 HTML 后,会先按照 HTML 解析规则构建真实 DOM。接着 React 在客户端执行 hydrate,把客户端虚拟 DOM 和浏览器已经构建好的真实 DOM 对齐。
问题在于,浏览器不会原样保留非法结构。比如:
<p>
<div>content</div>
</p>
浏览器解析时会自动修正,大致变成:
<p></p>
<div>content</div>
<p></p>
也就是说:
服务端 React 以为结构是:
p
└─ div
浏览器实际解析出来的是:
p
div
p
客户端 hydrate 时一对比,发现节点层级不一致,就会报 hydration error。
所以这不是单纯的“样式问题”,也不是 React 小题大做,而是浏览器已经把 DOM 树改写了。
为什么 Markdown 场景特别容易遇到
Markdown 里的图片语法:

通常会被解析成一个段落,段落里包含图片:
<p>
<img src="/demo.png" alt="demo" />
</p>
这本来是合法的。
但很多项目会自定义 Markdown renderer,把 img 替换成业务组件:
<ReactMarkdown
components={{
img: ImageComponents,
}}
/>
如果 ImageComponents 内部使用 AntD Image:
const ImageComponents = ({ node }) => {
return <Image src={node.properties.src} />;
};
那 Markdown 的 <p><img /></p> 就会在 React 层变成 <p><AntdImage /></p>,而 AntD Image 实际输出 <div>,于是非法嵌套出现。
修复思路
最稳的修复方式不是强行改 AntD 组件,也不是把预览功能去掉,而是在 Markdown 的段落渲染阶段处理:如果一个段落里包含图片,就不要渲染成 <p>,改成 <div>。
示例:
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:
<ReactMarkdown
components={{
p: ParagraphComponents,
img: ImageComponents,
}}
/>
这样普通文字段落仍然是 <p>,图片段落则变成 <div>,AntD Image 的外层 <div> 也就有了合法容器。
排查这类问题的几个信号
看到下面这些现象时,可以优先怀疑 HTML 结构不合法:
- 报错里出现
cannot be a descendant of。 - 报错只在 SSR 或 Next.js 页面 hydration 时出现。
- 组件本身在纯客户端渲染时看起来正常。
- 页面里使用了 Markdown、富文本、CMS 内容或
dangerouslySetInnerHTML。 - 你替换了 Markdown 的默认标签,比如把
img、a、code替换成 UI 组件。
一个实用原则
在 React 里写组件时,不只要看 JSX 表面写了什么,还要看组件最终输出的真实 DOM 是什么。
尤其是 UI 组件库组件,经常不是简单输出一个标签。一个看起来像图片的组件,可能外面包了预览层、遮罩层、占位层和交互层。只要它最终输出了 <div>,就不能放进 <p> 这种只允许 phrasing content 的容器里。
Hydration error 往往不是 hydrate 本身的问题,而是 SSR 输出、浏览器 HTML 解析、客户端 React 预期三者之间出现了结构差异。非法 HTML 正是最常见、也最隐蔽的一类原因。