黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

Mermaid 渲染方案设计与实现

发布于 2026-08-17约 14 分钟

分享博客在 Next.js App Router 架构下的 Markdown Mermaid 图表渲染方案,采用服务端骨架占位、客户端按需动态加载、LRU 内存缓存、并发竞态控制与双主题适配机制。

方案概览

Mermaid 渲染流程分为服务端解析与客户端执行两个阶段:

  • 服务端阶段 (SSR/SSG):Markdown 解析器识别 mermaid 代码块,提取源码存入容器属性,并输出轻量骨架屏及 noscript 降级代码块,防止渲染前的布局偏移(CLS)。
  • 客户端阶段 (CSR):组件挂载或主题切换时,检测视图中是否存在目标容器;按需动态加载 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;
  }
}

并发竞态与 LRU 缓存

在组件快速重渲染或主题频繁切换时,异步渲染任务可能存在完成顺序与触发顺序不一致的问题。

1. 竞态控制机制

使用 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;
}

2. LRU 内存缓存

为避免相同主题与代码反复调用 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 双主题,并统一图表字体与容器样式。

1. 主题配置构建

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',
        },
  };
}

2. 容器与 SVG 样式约束

在全局样式 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;
}

异常隔离与降级处理

1. 语法错误捕获与降级

配置 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');
}

2. 临时 DOM 清理

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();
    }
  }
}

总结

本方案通过关注点分离原则构建:

  1. 服务端:负责生成语义化 HTML、基础骨架屏结构与源码暂存,保障首屏性能与可访问性。
  2. 客户端:采用按需加载、版本 Token 竞态校验与 LRU 缓存,在多场景切换下维持稳定的渲染行为。
  3. 视觉与交互:通过基础主题参数定制与全局样式覆盖,实现暗黑模式自适应与移动端横向滚动。
目录
方案概览服务端解析与骨架占位客户端按需加载与生命周期按需动态导入并发竞态与 LRU 缓存1. 竞态控制机制2. LRU 内存缓存主题适配与样式定制1. 主题配置构建2. 容器与 SVG 样式约束异常隔离与降级处理1. 语法错误捕获与降级2. 临时 DOM 清理总结

本文收录于专栏

一些好用的 npm 前端开源库

收集一些好用的前端开源库,主要是 npm 包

0 篇文章更新于 2026-08-17
上一篇Claude Code:AI 编程 CLI 速查手册

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com