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 集成的帖子。