This post is not available in English yet. Showing the Chinese version.

← Writing
tutorial·2026-07-04·4 min read

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 语法:

```mermaid
graph LR
A --> B --> C
```

当 Expressive Code 跑到这个节点时,它不管 lang 是什么——统统语法高亮。mermaid 的 DSL 被当成代码展示出来,图表根本没机会渲染。

为什么选 remark 而不是 rehype?

Next.js 的 @next/mdx 管线分两个阶段:

  1. remark(操作 mdast,Markdown AST)——先跑
  2. 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() 拿到 parentindex 后,直接 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 blockremark 阶段提前替换为 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 可能吞掉内联 styleinnerHTML 直接写入,绕过 React

整个方案的核心思路就一句话:谁先跑谁赢。remark 比 rehype 先执行,所以在 remark 层把 mermaid 节点改写成 JSX 组件调用,后续管线根本不知道这里曾经有过一个代码块。