分享博客在 Next.js App Router 架构下的 Markdown Mermaid 图表渲染方案,采用服务端骨架占位、客户端按需动态加载、LRU 内存缓存、并发竞态控制与双主题适配机制。
Mermaid 渲染流程分为服务端解析与客户端执行两个阶段:
mermaid 代码块,提取源码存入容器属性,并输出轻量骨架屏及 noscript 降级代码块,防止渲染前的布局偏移(CLS)。mermaid 运行时,读取 LRU 缓存或执行 SVG 渲染,并处理异常回退。在 Markdown 渲染层(如 marked 扩展)中拦截 mermaid 语言块,不直接在服务端调用无浏览器环境的渲染引擎,而是生成带有元数据的占位容器:
// src/utils/render-helper.ts
export function renderCodeBlock(code: string, infostring: string | undefined): string {
const lang = (infostring || '').match(/\S*/)?.[0];
const normalizedLang = lang?.toLowerCase();
if (normalizedLang === 'mermaid') {
const encodedCode = escapeHtml(code);
return (
`<div class="mermaid-container" data-mermaid-code="${encodedCode}">` +
`<div class="mermaid-skeleton" aria-hidden="true">` +
`<div class="mermaid-skeleton-content">` +
`<div class="mermaid-skeleton-node"></div>` +
`<div class="mermaid-skeleton-line"></div>` +
`<div class="mermaid-skeleton-node"></div>` +
`<div class="mermaid-skeleton-line"></div>` +
`<div class="mermaid-skeleton-node"></div>` +
`</div>` +
`</div>` +
`<noscript><pre><code class="hljs language-mermaid">${encodedCode}</code></pre></noscript>` +
`</div>\n`
);
}
const highlighted = hljs.highlightAuto(code).value;
return `<pre><code${lang ? ` class="hljs language-${lang}"` : ' class="hljs"'}>${highlighted}</code></pre>\n`;
}
data-mermaid-code 属性保存已转义的 Mermaid 原始代码,避免后续主题切换时重新解析 Markdown。<noscript> 标签保留原始代码块,在禁用 JavaScript 的环境下可正常阅读代码。通过 useMermaid 自定义 Hook 监听容器引用和主题变化。使用 requestAnimationFrame 延迟执行,等待 React 完成 DOM reconciliation 之后再操作真实 DOM:
// src/utils/mermaid.ts
export function useMermaid(
containerRef: RefObject<HTMLElement | null>,
resolvedTheme?: string,
triggerDeps: unknown[] = [],
) {
useEffect(() => {
let cancelled = false;
const isCancelled = () => cancelled;
const rafId = requestAnimationFrame(() => {
if (cancelled) return;
renderMermaidInContainer(containerRef.current, resolvedTheme, isCancelled);
});
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [resolvedTheme, containerRef, ...triggerDeps]);
}
仅当页面容器中包含未渲染的 .mermaid-container 元素且未命中缓存时,才执行异步导入,避免全站初始打包体积增加:
async function loadMermaidModule() {
try {
const mermaidModule = await import('mermaid');
const mermaid = (mermaidModule as any).default?.default?.render
? (mermaidModule as any).default.default
: (mermaidModule as any).default?.render
? (mermaidModule as any).default
: mermaidModule;
return mermaid;
} catch (error) {
console.error('Failed to load mermaid runtime:', error);
return null;
}
}
在组件快速重渲染或主题频繁切换时,异步渲染任务可能存在完成顺序与触发顺序不一致的问题。
使用 WeakMap<HTMLElement, number> 维护每个 DOM 节点的版本号(Token)。在 mermaid.render 异步返回后校验版本一致性,丢弃过期结果:
const nodeRenderTokens = new WeakMap<HTMLElement, number>();
// 渲染流程中:
const currentToken = (nodeRenderTokens.get(node) ?? 0) + 1;
nodeRenderTokens.set(node, currentToken);
const result = await mermaid.render(renderId, rawCode);
// 校验当前任务是否已被新任务覆盖或已取消
if (nodeRenderTokens.get(node) !== currentToken || (isCancelled && isCancelled())) {
return;
}
为避免相同主题与代码反复调用 mermaid.render 造成计算浪费,设计基于 ${themeMode}:${rawCode.trim()} 的 LRU 缓存结构:
export const MAX_MERMAID_CACHE_SIZE = 200;
const mermaidSvgCache = new Map<string, string>();
export function getMermaidSvgCache(cacheKey: string): string | undefined {
const cached = mermaidSvgCache.get(cacheKey);
if (cached !== undefined) {
mermaidSvgCache.delete(cacheKey);
mermaidSvgCache.set(cacheKey, cached);
}
return cached;
}
export function setMermaidSvgCache(cacheKey: string, svg: string): void {
if (mermaidSvgCache.has(cacheKey)) {
mermaidSvgCache.delete(cacheKey);
} else if (mermaidSvgCache.size >= MAX_MERMAID_CACHE_SIZE) {
const firstKey = mermaidSvgCache.keys().next().value;
if (firstKey !== undefined) {
mermaidSvgCache.delete(firstKey);
}
}
mermaidSvgCache.set(cacheKey, svg);
}
基于 Mermaid base 主题定制配色变量,适配 Light 与 Dark 双主题,并统一图表字体与容器样式。
export function getMermaidThemeConfig(resolvedTheme?: string) {
const isDark = resolvedTheme === 'dark';
return {
startOnLoad: false,
theme: 'base' as const,
fontFamily: MERMAID_FONT_FAMILY,
securityLevel: 'loose' as const,
suppressErrorRendering: true,
themeVariables: isDark
? {
darkMode: true,
fontFamily: MERMAID_FONT_FAMILY,
background: 'transparent',
canvasBackground: 'transparent',
primaryColor: '#27272a',
primaryBorderColor: '#3f3f46',
primaryTextColor: '#f4f4f5',
lineColor: '#a1a1aa',
arrowheadColor: '#ffc725',
}
: {
darkMode: false,
fontFamily: MERMAID_FONT_FAMILY,
background: 'transparent',
canvasBackground: 'transparent',
primaryColor: '#ffffff',
primaryBorderColor: '#e4e4e7',
primaryTextColor: '#18181b',
lineColor: '#71717a',
arrowheadColor: '#ffc725',
},
};
}
在全局样式 src/styles/style.css 中配置横向滚动、节点圆角及字体继承:
/* 容器样式与横向滚动条 */
.mermaid-container {
display: block;
width: 100%;
max-width: 100%;
min-height: 140px;
margin-top: 1.5em;
margin-bottom: 1.5em;
padding: 24px 16px;
border: 1px solid #e4e4e7;
border-radius: 12px;
background-color: #fafafa;
overflow-x: auto;
text-align: center;
scrollbar-width: thin;
scrollbar-color: #e5e5e5 transparent;
-webkit-overflow-scrolling: touch;
}
.dark .mermaid-container {
border-color: #27272a;
background-color: rgba(24, 24, 27, 0.5);
scrollbar-color: #404040 transparent;
}
/* 强制 SVG 内部文本继承站点字体栈 */
.mermaid-container svg text,
.mermaid-container svg .label,
.mermaid-container svg .nodeLabel,
.mermaid-container svg foreignObject {
font-family: inherit !important;
}
/* SVG 节点圆角规范 */
.mermaid-container svg .node rect {
rx: 6px;
ry: 6px;
}
.mermaid-container svg .cluster rect {
rx: 8px;
ry: 8px;
}
配置 suppressErrorRendering: true,防止 Mermaid 将默认的错误节点直接插入页面破坏布局。当解析发生错误时,捕获异常并恢复显示语法高亮代码块与提示信息:
function applyFallbackContent(node: HTMLElement, rawCode: string, errorMessage: string): void {
const noscriptEl = node.querySelector('noscript');
const fallbackHtml =
noscriptEl?.innerHTML ||
`<pre><code class="hljs language-mermaid">${escapeHtml(rawCode)}</code></pre>`;
const errorNotice = `<div class="mermaid-error-notice mb-2 inline-flex items-center gap-1.5 rounded-md border border-red-200 bg-red-50 px-2.5 py-1 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300"><span>${escapeHtml(errorMessage)}</span></div>`;
node.innerHTML = `${errorNotice}${fallbackHtml}`;
node.classList.add('mermaid-error');
node.removeAttribute('data-mermaid-rendered-theme');
}
Mermaid 在执行 mermaid.render 期间会在 document.body 生成临时节点(如 d${id}),在 finally 阶段统一清理,避免 DOM 节点残留:
function cleanupMermaidArtifacts(id: string) {
if (typeof document === 'undefined') return;
const artifactIds = [id, `d${id}`];
for (const artifactId of artifactIds) {
const el = document.getElementById(artifactId);
if (el && el.parentElement === document.body) {
el.remove();
}
}
}
本方案通过关注点分离原则构建: