Mermaid 图表:Remark 层 AST 改写与客户端渲染
在 Expressive Code 吃掉所有代码块之前,用 remark 插件把 mermaid 节点偷走
On this page
在 MDX 里写一个 ```mermaid 代码块,期望看到流程图——结果渲染出来的是一段语法高亮的纯文本。Expressive Code 把所有 fenced code block 都接管了,mermaid 也不例外。
这篇文章记录我怎么在 remark 层截胡,把 mermaid 代码块改写成自定义组件节点,以及客户端渲染 Mermaid SVG 时踩的几个坑。
冲突:Expressive Code 和 Mermaid 都想要代码块
本站的 MDX 编译链用 Expressive Code 做代码高亮(配置在 mdx-rehype-expressive-code.mjs)。它会在 rehype 阶段遍历 AST,把所有 <code> 节点转换成漂亮的高亮代码块。
问题在于,Mermaid 图表的源码也用 fenced code block 语法:
```mermaidgraph LR A --> B --> C```当 Expressive Code 跑到这个节点时,它不管 lang 是什么——统统语法高亮。mermaid 的 DSL 被当成代码展示出来,图表根本没机会渲染。
为什么选 remark 而不是 rehype?
Next.js 的 @next/mdx 管线分两个阶段:
- remark(操作 mdast,Markdown AST)——先跑
- rehype(操作 hast,HTML AST)——后跑
Expressive Code 注册在 rehype 阶段。如果我也在 rehype 里处理 mermaid,就得和 Expressive Code 抢执行顺序,而且它内部的匹配逻辑不一定给我留口子。
最稳的方案是在 remark 阶段就把 mermaid 代码块从 AST 中移除——等 rehype 跑到的时候,已经没有 mermaid 的 code 节点了,Expressive Code 自然不会碰它。
在 next.config.ts 里可以看到插件顺序:
remarkPlugins: [ 'remark-gfm', 'remark-frontmatter', path.join(projectDir, 'mdx-remark-github-alerts.mjs'), path.join(projectDir, 'mdx-remark-math.mjs'), path.join(projectDir, 'mdx-remark-mermaid.mjs'),],rehypePlugins: [ 'rehype-slug', path.join(projectDir, 'mdx-rehype-katex.mjs'), '@stefanprobst/rehype-extract-toc', '@stefanprobst/rehype-extract-toc/mdx', path.join(projectDir, 'mdx-rehype-expressive-code.mjs'), path.join(projectDir, 'mdx-rehype-strip-ec-scripts.mjs'),],mdx-remark-mermaid.mjs 在 remark 阶段的最后执行,而 Expressive Code 在 rehype 阶段才开始。时间差足够大,不存在冲突。
Remark 插件实现:手写 walk 替换 AST 节点
完整代码在 mdx-remark-mermaid.mjs,只有 36 行:
/** @typedef {import('mdast').Root} Root *//** @typedef {import('mdast').Code} Code */
/** * Convert ```mermaid fenced blocks to <Mermaid chart="..." /> before Expressive Code runs. */export default function remarkMermaid() { /** @param {Root} tree */ return (tree) => { walk(tree, null, null) }}
function walk(node, parent, index) { if (node.type === 'code' && node.lang === 'mermaid' && parent != null && index != null) { parent.children[index] = { type: 'mdxJsxFlowElement', name: 'Mermaid', attributes: [ { type: 'mdxJsxAttribute', name: 'chart', value: /** @type {Code} */ (node).value, }, ], children: [], } return }
if ('children' in node && Array.isArray(node.children)) { node.children.forEach((child, i) => walk(child, node, i)) }}几个值得说的设计选择:
没用 unist-util-visit
社区写 remark 插件通常用 unist-util-visit。我直接手写了递归 walk(),原因很简单——这个插件只做一件事(找 code + lang === 'mermaid'),手写反而更直观,而且省一个依赖。
AST 节点结构
替换后的节点类型是 mdxJsxFlowElement——这是 MDX AST 里表示 JSX 组件调用的节点类型。它告诉编译器:"这里有一个 <Mermaid chart="..." /> 组件调用"。
关键字段:
type: 'mdxJsxFlowElement'——flow 表示块级元素(对应 inline 的是mdxJsxTextElement)name: 'Mermaid'——组件名,必须和mdx-components.tsx里注册的名字一致attributes——props,这里只有一个chart,值是 mermaid 源码字符串children: []——自闭合,没有子节点
直接替换 parent.children[index]
walk() 拿到 parent 和 index 后,直接 parent.children[index] = newNode 原地替换。不需要 splice,不需要返回新树——mdast 是可变的,直接改就行。替换后 return 跳过子节点遍历(新节点已经没有 children 需要遍历了)。
组件注册:mdx-components.tsx
remark 插件生成的 <Mermaid> 节点需要在运行时找到对应的 React 组件。在 src/mdx-components.tsx 里:
import Mermaid from '@/components/mdx/Mermaid'
export function useMDXComponents(): MDXComponents { return { Mermaid, // ...其他组件 }}名字必须严格匹配——remark 插件里写的是 name: 'Mermaid',这里导出的 key 也必须是 Mermaid。
客户端渲染:比想象中多几个坑
Mermaid 库只能在浏览器端运行(依赖 DOM),所以组件必须是 'use client'。完整代码在 src/components/mdx/Mermaid.tsx:
'use client'
import mermaid from 'mermaid'import { useTheme } from 'next-themes'import { useEffect, useId, useRef } from 'react'
export default function Mermaid({ chart }: { chart: string }) { const containerRef = useRef<HTMLDivElement>(null) const renderId = useId().replace(/:/g, '') const { resolvedTheme } = useTheme()
useEffect(() => { if (!containerRef.current || !resolvedTheme) return
let cancelled = false
mermaid.initialize({ startOnLoad: false, theme: resolvedTheme === 'light' ? 'neutral' : 'dark', securityLevel: 'loose', })
mermaid .render(`mermaid-${renderId}`, chart.trim()) .then(({ svg }) => { if (!cancelled && containerRef.current) { containerRef.current.innerHTML = svg } }) .catch(() => { if (!cancelled && containerRef.current) { containerRef.current.textContent = 'Failed to render diagram' } })
return () => { cancelled = true } }, [chart, renderId, resolvedTheme])
return ( <div ref={containerRef} className="mermaid-diagram my-6 overflow-x-auto rounded-[4px] border border-bd-base bg-card p-4" /> )}逐个说说踩过的坑。
useId() 的冒号问题
React 的 useId() 生成的 ID 带冒号,类似 :r1:。但 Mermaid 内部用这个 ID 创建 SVG 元素和 CSS 选择器——冒号在 CSS 选择器里是特殊字符,会导致渲染失败。
修复很简单:.replace(/:/g, ''),把冒号全部去掉。
主题响应
用户切换暗色/亮色模式时,Mermaid 图表需要跟着变。useTheme() 的 resolvedTheme 放进 useEffect 依赖数组,主题变化时重新 initialize + render:
theme: resolvedTheme === 'light' ? 'neutral' : 'dark',Mermaid 的 'neutral' 主题在亮色背景下观感比默认的 'default' 更干净,暗色则直接用 'dark'。
竞态:cancelled 标志位
mermaid.render() 是异步的。如果用户快速切换主题,上一次 render 还没完成,新的 useEffect 就触发了。没有防护的话,旧的 Promise resolve 后会覆盖新的 SVG。
经典的 cleanup 模式:
let cancelled = false// ...异步操作...return () => { cancelled = true }旧的 effect 被清理时 cancelled 设为 true,Promise 回调里检查后跳过写入。
innerHTML 而非 React state
注意我用的是 containerRef.current.innerHTML = svg,而不是 setSvg(svg) + dangerouslySetInnerHTML。
原因是 Mermaid 生成的 SVG 可能包含 <style> 标签和事件绑定(securityLevel: 'loose' 时),直接设 innerHTML 让浏览器原生解析,绕过 React 的 DOM diffing。这比走 React state 更可靠——React 对 raw HTML 字符串的处理有时会丢掉内联样式或脚本。
securityLevel: 'loose'
默认 securityLevel 是 'strict',会禁用点击事件和 <style> 标签。设为 'loose' 是因为图表内容完全由我自己的 MDX 文件控制,不存在 XSS 风险,而某些高级 Mermaid 功能(比如可交互节点)需要 loose 模式。
管线全景
从 Markdown 源码到最终渲染的完整流程:
Markdown 源码 │ ` ```mermaid ... ``` ` ▼remark 阶段(mdast) │ mdx-remark-mermaid.mjs │ code[lang=mermaid] → mdxJsxFlowElement[name=Mermaid] ▼rehype 阶段(hast) │ Expressive Code 只看到普通代码块,Mermaid 节点已经不在了 ▼React 运行时 │ mdx-components.tsx 把 <Mermaid> 映射到 Mermaid.tsx ▼浏览器 │ useEffect → mermaid.render() → innerHTML = svg ▼用户看到 SVG 图表总结
| 问题 | 根因 | 修复 |
|---|---|---|
| Mermaid 被高亮成代码 | Expressive Code 接管所有 fenced code block | remark 阶段提前替换为 JSX 节点 |
| 为什么不在 rehype 处理 | rehype 和 Expressive Code 同阶段,顺序难控制 | remark 在 rehype 之前执行,天然避开冲突 |
| 没用 unist-util-visit | 逻辑简单,一个递归搞定 | 手写 walk() 省依赖 |
| useId 冒号导致渲染失败 | Mermaid 用 ID 做 CSS 选择器 | .replace(/:/g, '') |
| 主题切换图表不更新 | Mermaid 初始化时主题已固定 | resolvedTheme 变化时重新 render |
| 快速切主题 SVG 闪烁 | 旧 Promise 覆盖新结果 | cancelled 标志位 + cleanup |
| SVG 内样式丢失 | React DOM diffing 可能吞掉内联 style | innerHTML 直接写入,绕过 React |
整个方案的核心思路就一句话:谁先跑谁赢。remark 比 rehype 先执行,所以在 remark 层把 mermaid 节点改写成 JSX 组件调用,后续管线根本不知道这里曾经有过一个代码块。