React + Mermaid 图表渲染消失问题剖析及 4 种代码级修复方案
Mermaid 是一个流行的库,它可以将文本图表(例如 graph LR; A-->B;)转换为 SVG 图表。
在静态 HTML 页面中,Mermaid 会查找 <pre class="mermaid"> 代码块,并在页面加载时将它们替换为渲染后的图表。
它甚至会添加一个特殊的 data-processed 属性来标记已转换的块。
然而,在 React 应用中,这可能会导致一个意外的 bug:
你的 Mermaid 图表最初显示正常,但一旦 React 重新渲染(例如状态变化后),图表就会消失,取而代之的是原始的 Mermaid 代码。
“Mermaid 图表在项目首次加载时渲染得非常完美,但如果我修改图表标记,它就会以纯文本形式渲染,而不是图表。”
为什么会这样?我们又该如何解决呢?
Mermaid 渲染的工作原理
在底层,Mermaid 通过扫描 DOM 来查找带有 class="mermaid" 的元素,并解析它们的文本。
例如,<pre class="mermaid">graph LR A-->B</pre> 将被替换为一个内联 SVG,显示该流程图。
在页面加载时(或手动触发时),Mermaid 的运行函数会遍历文档,将每个代码块转换为 SVG,并为这些元素添加 data-processed 标签,以避免再次渲染。
这就是 Mermaid 的正常生命周期:
- 初始加载: Mermaid(通常通过 mermaid.init()、mermaid.contentLoaded() 或 mermaid.run())会找到所有 <pre class="mermaid">…</pre> 块,并将它们替换为 <svg> 图表。
- 标记: 转换图表后,Mermaid 会添加 data-processed="true" 属性,这样它就知道不用再处理它了。
- 重新渲染: 如果你后来添加更多 .mermaid 块并再次调用 mermaid.run(),它会跳过任何已标记的块。
data-processed 机制是性能的关键:它防止 Mermaid 在每次调用时重新解析每个图表。
但在 React 中,这个机制适得其反。
React 的虚拟 DOM 与 Mermaid 的冲突
React 使用自己的 虚拟 DOM 来高效更新 UI。
在 React 组件中,你返回的 JSX 描述了 UI 应该 是什么样子。
React 然后将这个虚拟树与实际 DOM 进行比较,并进行最小化更新。
关键在于,React 会覆盖它“拥有”的任何 DOM 部分。
正如 React 文档所解释的,“React 会自动更新 DOM 以匹配你的渲染输出”。
换句话说,如果 Mermaid 直接修改了真实 DOM(通过插入 <svg>),React 并不知道;
下次 React 渲染该组件时,它会用渲染函数指定的内容替换那个 <svg>——在我们的案例中,很可能是原始的 <pre class="mermaid">…</pre> 文本。
换一种说法,Mermaid 操作的是 真实 DOM,而 React 维护的是 虚拟 DOM 并将其与真实 DOM 协调。
当两者冲突时,React 会获胜。
可以这么说:“Mermaid 直接与浏览器的真实 DOM 交互,这与 React 的虚拟 DOM 方法形成对比。”
具体来说,在 React 应用中的典型序列是:
- 初始挂载: React 渲染组件,包含 <div class="mermaid">…图表代码…</div>。然后我们触发 mermaid.contentLoaded() 或类似函数,Mermaid 将其转换为 DOM 中的 <svg>。
- 状态更新: 某些东西变化了(props 或状态),React 重新运行组件的渲染函数。如果该函数仍然返回原始的 <div class="mermaid">…图表代码…</div>,React 会用原始文本元素覆盖 SVG,因为那是虚拟 DOM 指定的内容。
- 图表消失,原始文本重新出现。
这种交互就是 Mermaid 图表在更新时“消失”的原因:React 本质上抹除了 Mermaid 的工作。
要修复这个问题,我们需要将 Mermaid 的真实 DOM 渲染与 React 的渲染周期桥接起来。
策略 #1:移除 data-processed并重新运行 Mermaid
一个常见且直接的修复方法是 清除 data-processed 标志,并在图表数据变化时再次调用 Mermaid 的渲染。
由于 Mermaid 不会重新渲染已标记的块,我们首先移除该属性,让它视之为新块。
例如:
import React, { useEffect } from 'react';
import mermaid from 'mermaid';/** 方法 1:移除 `data-processed` 并调用 contentLoaded。* 这确保 Mermaid 会重新扫描元素。*/
function MermaidChart({ chartDefinition }) {useEffect(() => {// 找到容器并移除 Mermaid 的标记属性const element = document.getElementById('mermaid-container');element?.removeAttribute('data-processed');// 重新运行 Mermaid 以重新渲染图表mermaid.contentLoaded();}, [chartDefinition]); // 每当 chartDefinition 变化时运行 effect// 在容器中渲染图表代码return (<div id="mermaid-container" className="mermaid">{chartDefinition}</div>);
}
在这个代码片段中,每次 chartDefinition prop 变化时,我们获取图表的 DOM 元素(#mermaid-container),移除其 data-processed 属性,并调用 mermaid.contentLoaded()。
这会“欺骗” Mermaid,让它再次查看该元素并重绘图表。
一篇 StackOverflow 回答总结了这种方法:“你需要在组件状态更新后移除该属性并重新调用 mermaid.contentLoaded()。”
这个 hack 在 React 更改底层文本时让 Mermaid 更新图表。
注意事项: 确保代码中的 ID 或类与你的目标匹配。还要注意 mermaid.contentLoaded() 会尝试重新渲染页面上的 所有 Mermaid 块,而不仅仅是一个,因此如果你有许多图表,这个方法可能会比较耗资源。对于少量图表来说,它很合适。
策略 #2:使用 mermaid.render()手动生成 SVG
另一种方法是完全绕过 Mermaid 的自动扫描,并 使用 mermaid.render() 手动生成 SVG 代码。
而不是让 Mermaid 自己修改 DOM,你用图表文本调用 API,并获取 SVG 字符串作为返回。
然后,你可以将该字符串注入组件中,例如使用 dangerouslySetInnerHTML。
这样,React 保持对 DOM 的控制(它看到的是你设置在状态中的 <svg>),从而完全避免 data-processed 问题。
以下是一个使用现代 Mermaid API(返回 Promise)的 React 示例:
import React, { useState, useEffect } from 'react';
import mermaid from 'mermaid';/** 方法 2:使用 mermaid.render() 获取 SVG 并注入它。* 这显式生成图表代码。*/
function MermaidChart({ chartDefinition }) {const [svgCode, setSvgCode] = useState('');useEffect(() => {let isMounted = true; // 避免在卸载组件时更新状态async function renderChart() {try {await mermaid.parse(chartDefinition); // 可选:验证图表const { svg } = await mermaid.render('uniqueChartId', chartDefinition);if (isMounted) {setSvgCode(svg);}} catch (error) {console.error('渲染 Mermaid 图表出错:', error);}}renderChart();return () => {isMounted = false;};}, [chartDefinition]);// 直接将 SVG 字符串渲染到 DOM 中return <div dangerouslySetInnerHTML={{ __html: svgCode }} />;
}
工作原理: 每次 chartDefinition 变化时,我们解析并渲染它。mermaid.render('uniqueChartId', chartDefinition) 返回一个包含 svg 字段的对象(SVG 标记)。然后我们将该 SVG 存入 React 状态。组件输出一个 <div>,其 HTML 设置为 SVG。因为 React 直接渲染 SVG 标记,所以没有被擦除的风险——React 拥有该 SVG 节点。
这种模式在实践中被多位作者展示。
例如,一篇教程在 React 应用中使用 mermaid.render("theGraph", definition, (svgCode) => { output.innerHTML = svgCode; })。
Tuanhuy 博客也在 useLayoutEffect 中使用 const { svg } = await mermaid.render("id", graphText); 来设置状态变量。
关键点是:自己使用 Mermaid API,而不是依赖自动扫描。
注意: 如果使用此方法,确保 mermaid.render 的第一个参数(这里是 'uniqueChartId')对每个图表都是唯一的,因为 Mermaid 用它来标识 SVG 元素。在 React 中,如果你渲染多个图表,可以使用 ref 或 UUID。
策略 #3:使用 useLayoutEffect进行同步渲染
React 的 useEffect 钩子在组件更新浏览器后运行(绘制后)。
相反,useLayoutEffect 在 React 应用 DOM 更新后但浏览器重绘前运行。
这种时机在库(如 Mermaid)需要立即作用于 DOM 时更安全。
因为 Mermaid 期望真实 DOM 在绘制前就位,使用 useLayoutEffect 可以避免闪烁。
在实践中,你可以在 useLayoutEffect 中调用 mermaid.contentLoaded()。
例如:
import React, { useLayoutEffect } from 'react';
import mermaid from 'mermaid';/** 方法 3:使用 useLayoutEffect 初始化和渲染。* 这确保 Mermaid 在 React 更新 DOM 后运行。*/
function MermaidChart({ chartDefinition }) {// 一次性初始化 MermaiduseLayoutEffect(() => {mermaid.initialize({ startOnLoad: false });}, []);// 每当图表变化时重新运行 MermaiduseLayoutEffect(() => {// 当 React 用新 chartDefinition 更新 DOM 时,重新运行 Mermaidmermaid.contentLoaded();}, [chartDefinition]);return <div className="mermaid">{chartDefinition}</div>;
}
在这个示例中,第二个 useLayoutEffect 的依赖数组包含 chartDefinition,因此它会在 React 将新图表文本放入 DOM 后立即运行。
使用 useLayoutEffect(而非 useEffect)确保我们甚至不会短暂看到原始文本。
可以这么说:“Mermaid 基于真实 DOM 渲染,因此必须在页面渲染后发生,所以我们使用 useLayoutEffect 钩子来渲染。”
通过在 useLayoutEffect 中运行 mermaid.contentLoaded(),Mermaid 会看到更新的 <div> 并绘制图表。
在后续 React 更新中,第一个 effect(空依赖)不会重新运行初始化,而第二个 effect 会根据需要重新运行渲染。
另一个变体是将 useLayoutEffect 与其中的 mermaid.render() 结合(如 Tuanhuy 示例)。
本质是 useLayoutEffect 让 Mermaid 有机会在浏览器绘制前绘制,从而实现更平滑的更新。
策略 #4:使用 MutationObserver 观察 DOM 变化(高级)
作为可选或高级方法,你可以使用 MutationObserver API 来监视 DOM 变化,并在新图表代码出现时触发 Mermaid。
这更复杂,但适用于 Mermaid 块由某些渲染器深层插入的系统。
思路是观察容器元素,当添加新子节点时,在它们上调用 Mermaid。
例如:
import React, { useEffect } from 'react';
import mermaid from 'mermaid';/** 方法 4:使用 MutationObserver 检测新 Mermaid 块。*/
function MermaidWrapper() {useEffect(() => {const observer = new MutationObserver((mutationsList) => {for (const mutation of mutationsList) {if (mutation.addedNodes.length > 0) {// 添加了新内容;重新扫描 Mermaid 图表document.querySelectorAll('div.mermaid').forEach(el => {el.removeAttribute('data-processed');});mermaid.contentLoaded();break;}}});// 观察整个文档或特定容器observer.observe(document.body, { childList: true, subtree: true });return () => observer.disconnect();}, []);// ... 你的应用动态插入 <div class="mermaid"> 块 ...return <ContentWithMermaid />;
}
这里我们监视 document.body(或某个包装元素)的任何新子节点。
当出现 addedNodes 时,我们假设可能有新 Mermaid 图表。
我们然后清除所有 .mermaid div 的 data-processed,并调用 mermaid.contentLoaded()。
这确保即使动态插入的图表也能被渲染。
谨慎使用: MutationObserver 对于大多数应用来说可能是多余的,如果误用可能会影响性能。但如果你的应用渲染流程难以仅用钩子拦截,这是一个选项。
结论
总之,React 中的 Mermaid 图表在重新渲染时消失是因为 React 的虚拟 DOM 用原始文本替换了 Mermaid 注入的 SVG。
要修复这个问题,我们需要在 React 更新后显式重新触发 Mermaid。
常见解决方案包括:
- 清除 data-processed 并重新运行: 移除标记并在 React effect 中调用 mermaid.contentLoaded()(或 mermaid.run())。
- 使用 mermaid.render(): 用 API 生成 SVG 并让 React 渲染它(如上所示)。
- 使用 useLayoutEffect: 在布局 effect 中调用 Mermaid,让它在重绘前看到更新的 DOM。
- (高级)MutationObserver: 监视 DOM 变化并根据需要触发 Mermaid。
每种方法都有权衡。
直接清除 data-processed 对于简单案例来说快速且容易,而 mermaid.render() 提供更多控制,但需要手动注入 HTML。
使用 React effect(useLayoutEffect)可以优雅地将 Mermaid 集成到 React 生命周期中。
借助这些策略,你可以让 Mermaid 图表在 React UI 更新时保持活跃。
参考资料:
更多细节请参阅 Mermaid 文档和社区关于将 Mermaid 与 React 集成的帖子。