博客渲染踩坑全记录:Mermaid、Markmap、双主题与布局
这篇文章记录在 搭建这个博客 之后,持续完善渲染功能时遇到的真实问题。每个坑都附原因分析和最终代码,可以直接复用。
问题全景
一、Mermaid:从能跑到好看
1.1 Shiki 吞掉 language-mermaid 类名
最初的思路是让 Shiki 跳过 mermaid 代码块,留给客户端渲染。结果发现:Shiki 的 rehype 插件会把所有带 language-* 类名的 <code> 块都处理掉,处理完之后原来的类名就消失了,客户端 JS 再也找不到 mermaid 代码块。
解法:在 Shiki 之前拦截。写一个 rehypeClientDiagramExtract 插件,把 mermaid/markmap 代码块转成 <div class="diagram-raw" data-lang="mermaid"> 占位符,Shiki 看不到 language-mermaid 就不会处理它。
// src/lib/content.ts
function rehypeClientDiagramExtract() {
const CLIENT_LANGS = new Set(['mermaid', 'markmap']);
return (tree: any) => {
function walk(node: any) {
if (!Array.isArray(node.children)) return;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (child.tagName === 'pre') {
const code = child.children?.[0];
const cls: string[] = code?.properties?.className ?? [];
const lang = cls.find(c => c.startsWith('language-'))?.slice(9);
if (lang && CLIENT_LANGS.has(lang)) {
const source = code.children.map(c => c.value ?? '').join('');
node.children[i] = {
type: 'element', tagName: 'div',
properties: { className: ['diagram-raw'], 'data-lang': lang },
children: [{ type: 'text', value: source }],
};
continue;
}
}
walk(child);
}
}
walk(tree);
};
}
渲染管道的最终顺序很关键:
| 顺序 | 插件 | 作用 |
|---|---|---|
| 1 | remarkParse |
Markdown → MDAST |
| 2 | remarkGfm |
GFM 表格/删除线/任务列表 |
| 3 | remarkRehype |
MDAST → HAST |
| 4 | rehypeFileTree |
```filetree → 样式化文件树 |
| 5 | rehypeMarkHighlight |
文字 → <mark> |
| 6 | rehypeClientDiagramExtract |
mermaid/markmap → 占位符(必须在 Shiki 前) |
| 7 | @shikijs/rehype |
代码高亮 |
| 8 | rehypeSlug |
给标题加 id |
1.2 节点边框:所有形状都要覆盖
Mermaid 把不同节点形状渲染成不同的 SVG 元素:
| 形状 | SVG 元素 | 说明 |
|---|---|---|
矩形 [] |
<rect> |
最常见 |
圆角矩形 () |
<rect> with rx/ry |
同元素,属性不同 |
菱形 {} |
<polygon> |
判断节点 |
圆柱/数据库 [("")] |
<path> |
⚠️ 这个最容易漏掉 |
椭圆 ([]) |
<ellipse> |
|
圆形 (()) |
<circle> |
最初只写了 rect 和 polygon,结果"Markdown 文件"圆柱节点的边框颜色始终和其他节点不一致——因为它用 path 渲染。
/* 覆盖所有形状,一个都不能少 */
.mermaid svg .node rect,
.mermaid svg .node polygon,
.mermaid svg .node path, /* ← 圆柱/数据库形状 */
.mermaid svg .node ellipse,
.mermaid svg .node circle {
stroke: var(--mermaid-node-border) !important;
stroke-width: 2px !important;
}
1.3 主题切换时自动重渲染
Mermaid 在 initialize() 时读取主题配置,之后渲染出的 SVG 是静态的,切换页面主题不会触发重渲染。
解法:用 MutationObserver 监听 data-theme 属性变化,每次切换主题都重新 initialize() + run()。
重渲染时需要把 .mermaid div 重置到源码状态:
const renderMermaid = async () => {
// 1. 重置所有 div 到源码
mermaidDivs.forEach((div, i) => {
div.innerHTML = '';
div.textContent = mermaidSrcs[i];
div.removeAttribute('data-processed'); // ← 必须移除,否则 Mermaid 跳过
});
// 2. 用当前主题重新初始化
const dark = document.documentElement.dataset.theme === 'terminal';
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'base',
themeVariables: { /* 读 CSS 变量 */ },
});
// 3. 重新渲染
await mermaid.run();
};
// 监听主题变化
new MutationObserver(renderMermaid).observe(
document.documentElement,
{ attributes: true, attributeFilter: ['data-theme'] }
);
1.4 Mermaid 工具栏(缩放/全屏)
Mermaid 输出的是静态 SVG,没有内置的缩放 API。缩放用 CSS transform: scale() 实现:
// 读取当前缩放比(从 transform 字符串解析)
const getScale = () => {
const m = getSvg()?.style.transform.match(/scale\(([^)]+)\)/);
return m ? +m[1] : 1;
};
// 放大
toolbar.appendChild(mkBtn('放大', IC.zoomIn, () => {
const s = getSvg(); if (!s) return;
const next = Math.min(getScale() * 1.33, 6);
s.style.transition = 'transform 0.2s';
s.style.transform = `scale(${next.toFixed(3)})`;
}));
全屏用浏览器原生 Fullscreen API,切换时重置 scale:
const mkFsBtn = (wrapper, onToggle) => {
const btn = mkBtn('全屏', IC.fs, () => {
if (!document.fullscreenElement) wrapper.requestFullscreen?.();
else document.exitFullscreen?.();
});
document.addEventListener('fullscreenchange', () => {
const isFull = document.fullscreenElement === wrapper;
btn.innerHTML = isFull ? IC.exitFs : IC.fs;
onToggle?.(isFull);
});
return btn;
};
1.5 工具栏默认隐藏,hover 显示
工具栏常驻显示时会叠压在图表内容上方,轻微模糊焦点。改为默认透明,鼠标移入 wrapper 时淡入:
.markmap-toolbar {
opacity: 0;
transition: opacity 0.2s;
}
.markmap-wrapper:hover .markmap-toolbar,
.mermaid-wrapper:hover .markmap-toolbar { opacity: 1; }
Mermaid 和 Markmap 共用同一个 .markmap-toolbar 类,一条规则覆盖两种图表。
1.6 高图自动等比缩放
TD 方向流程图、深层序列图等纵向较长,若不加限制可能撑出数百像素高。直接在 SVG 上加 max-height 即可——浏览器对替换元素(replaced element)的处理是:高度达到上限时,width: auto 自动等比缩小,完整呈现内容而不产生滚动条。
.mermaid svg {
max-width: none !important; /* 宽图横向滚动 */
max-height: 600px; /* 高图等比缩小,不裁切 */
height: auto !important;
width: auto;
}
对比两种方案的取舍:
| 方案 | 效果 | 缺点 |
|---|---|---|
overflow-y: auto + max-height 加在 wrapper |
超出部分可纵向滚动 | 在图表内部滚动体验差 |
max-height 加在 SVG 本身 |
等比缩小,全图可见 | 超高图文字变小,可用工具栏放大 |
二、Markmap:坑最多的组件
2.1 Autoloader 的 CDN 竞态条件
官方推荐用 markmap-autoloader,但它内部调用 findFastestProvider() 测速各个 CDN,这是异步操作——<script> 的 onload 在测速完成前就触发了,此时 markmap-view 和 markmap-lib 都还没加载。
解法:绕过 autoloader,按序手动加载 UMD 脚本:
const loadScript = src => new Promise((res, rej) => {
const s = document.createElement('script');
s.src = src; s.onload = res;
s.onerror = () => rej(new Error(src));
document.head.appendChild(s);
});
await loadScript('https://cdn.jsdelivr.net/npm/d3@7');
await loadScript('https://cdn.jsdelivr.net/npm/markmap-view@0.18');
await loadScript('https://cdn.jsdelivr.net/npm/markmap-lib@0.18');
2.2 window.markmap 被后加载的包覆盖
markmap-view 和 markmap-lib 都把自己的导出写到 window.markmap。问题在于:后加载的包可能整体替换 window.markmap,而不是追加到它上面,导致先加载的 Markmap 类丢失。
// ❌ 错误:两个包都加载完再解构,Markmap 可能已丢失
await loadScript('markmap-view');
await loadScript('markmap-lib');
const { Transformer, Markmap } = window.markmap; // Markmap 可能是 undefined
// ✅ 正确:在 markmap-lib 加载前先捕获 Markmap
await loadScript('markmap-view');
const Markmap = window.markmap?.Markmap; // 提前保存引用
await loadScript('markmap-lib');
const Transformer = window.markmap?.Transformer;
if (!Markmap || !Transformer)
throw new Error('markmap load failed');
2.3 svg text 选择器选不到节点文字
花了很长时间才弄清楚:Markmap 节点文字不用 SVG <text> 渲染,而是用 <foreignObject><div> 注入 HTML。
.markmap-wrapper
└── <svg>
└── <g class="markmap-node">
├── <circle>(彩色圆点)
├── <line>(连线)
└── <foreignObject>
└── <div class="markmap-foreign">
└── 节点文字 ← 这里是 HTML,不是 SVG text!
所以 svg text { fill: var(--fg) } 完全无效,必须改成:
/* ❌ 无效 */
.markmap-wrapper svg text { fill: var(--fg) !important; }
/* ✅ 正确 */
.markmap-wrapper foreignObject div { color: var(--fg) !important; }
2.4 连线颜色对比度问题
Markmap 连线的 stroke 颜色来自 D3 的 Tableau10 色板,其中部分颜色(如 #f28e2b 橙黄、#59a14f 浅绿)在白色/浅色背景下对比度不足。
| 方案 | 优点 | 缺点 |
|---|---|---|
覆盖为 var(--fg-muted) |
任何主题都可读 | 失去彩色层次感 |
| 覆盖为固定深色 | 简单 | 深色主题下看不清 |
| 不覆盖(使用默认) | 保留 D3 配色美感 | 部分主题下对比度不足 |
最终选择不覆盖,保留 D3 默认配色。对比度问题通过保证 foreignObject 文字颜色正确来补偿视觉层次。
2.5 默认折叠策略
思维导图全部展开时节点密集,阅读体验差。设计了两层折叠逻辑:
第一层:深度折叠(所有 depth ≥ 2 且有子节点的节点收起)
const foldDeep = (node, depth) => {
if (depth >= 2 && node.children?.length)
node.payload = { ...(node.payload ?? {}), fold: 1 };
node.children?.forEach(c => foldDeep(c, depth + 1));
};
第二层:渐进折叠(若 3 级可见节点总数仍 > 12,从末尾依次折叠 2 级节点)
const applyProgressiveFold = (root) => {
const d1 = root.children ?? [];
const count = () => d1.reduce(
(n, c) => n + (c.payload?.fold ? 0 : (c.children?.length ?? 0)), 0
);
let tail = d1.length - 1;
while (count() > 12 && tail >= 0) {
d1[tail].payload = { ...(d1[tail].payload ?? {}), fold: 1 };
tail--;
}
};
两层逻辑的执行效果示意:
三、双主题代码高亮
3.1 @shikijs/rehype 双主题的实现原理
Shiki 双主题(light: github-light / dark: github-dark)不是在运行时切换,而是在构建时把两套颜色都写进 HTML:
- 亮色 → 直接写进
<span>的style="color: #d73a49"(inline style) - 暗色 → 写进 CSS 自定义属性
--shiki-dark: #f97583
<span style="color:#d73a49;--shiki-dark:#f97583">import</span>
当主题切换到 terminal 暗色时,只需:
[data-theme="terminal"] .shiki span {
color: var(--shiki-dark) !important;
}
[data-theme="terminal"] pre.shiki {
background-color: var(--shiki-dark-bg) !important;
}
!important 是必须的,否则无法覆盖 inline style。
3.2 <script type="module"> 顶层不能 return
ES module 规范禁止顶层 return,所有异步图表渲染逻辑必须包在 IIFE 里:
// ❌ 报错:Illegal return statement
<script type="module">
const els = document.querySelectorAll('.diagram-raw');
if (!els.length) return; // SyntaxError
</script>
// ✅ 正确:包在 async IIFE 里
<script type="module">
(async () => {
const els = document.querySelectorAll('.diagram-raw');
if (!els.length) return; // OK
// ...
})();
</script>
另一个坑:Mermaid 段的错误如果没有 try/catch,会导致 Markmap 段也无法执行(整个 IIFE async 函数提前结束)。两段要各自独立包一层 try/catch:
// Mermaid
if (mermaidEls.length) { try {
// ...
} catch(e) { console.error('[mermaid]', e); } }
// Markmap(独立的 try/catch,互不影响)
if (markmapEls.length) { try {
// ...
} catch(e) { console.error('[markmap]', e); } }
3.3 代码块语言标签
Shiki 渲染后 <code> 上保留了 language-xxx 类名,但页面上看不到语言类型。用和复制按钮相同的 JS 循环注入标签:
document.querySelectorAll('#post-article pre').forEach(pre => {
const code = pre.querySelector('code');
// 语言标签(左上角)
const langCls = [...(code?.classList ?? [])].find(c => c.startsWith('language-'));
const lang = langCls?.slice(9);
const SKIP = new Set(['plaintext', 'text', 'ansi', 'filetree']);
if (lang && !SKIP.has(lang)) {
const label = document.createElement('span');
label.className = 'code-lang';
label.textContent = lang;
pre.appendChild(label);
}
// 复制按钮(右上角)
const btn = document.createElement('button');
btn.className = 'copy-btn';
// ...
pre.appendChild(btn);
});
.code-lang {
position: absolute;
top: 0.5rem;
left: 0.75rem;
font-size: 0.68rem;
font-family: var(--font-mono);
color: var(--fg-muted);
opacity: 0.6;
pointer-events: none;
user-select: none;
}
/* 给语言标签留出顶部空间 */
.prose pre { padding-top: 2rem; }
filetree 等自定义语言类型加入跳过列表,避免显示无意义的内部标识符。
四、布局与交互优化
4.1 悬浮目录定位
目录(TOC)要悬浮在文章右侧空白处,position: fixed 配合 JS 动态计算 left:
function updatePositions() {
const right = article.getBoundingClientRect().right;
const space = window.innerWidth - right;
if (space > 240 && window.innerWidth >= 1024) {
tocSidebar.style.left = (right + 20) + 'px';
tocSidebar.style.display = 'block';
} else {
tocSidebar.style.display = 'none'; // 空间不足时隐藏
}
}
window.addEventListener('resize', updatePositions, { passive: true });
4.2 目录点击平滑跳转
三行 CSS 实现完整的平滑跳转体验:
/* 平滑滚动 */
html { scroll-behavior: smooth; }
/* 避免固定顶栏遮住目标标题 */
.prose h1, .prose h2, .prose h3, .prose h4 {
scroll-margin-top: 4rem;
}
/* 跳转后目标标题短暂闪亮 */
.prose h2:target, .prose h3:target {
animation: heading-flash 1.2s ease-out;
}
@keyframes heading-flash {
0%, 60% { color: var(--link); }
100% { color: var(--heading-fg); }
}
4.3 日期格式统一
原先各个页面手写日期格式化逻辑,导致显示不一致(有的是 ISO 格式字符串,有的是 06-05T23:45+08:00)。抽取共享工具:
// src/lib/date.ts
const CST = 'Asia/Shanghai';
// 列表页:2026/6/5
export function fmtShort(dateStr: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: CST, year: 'numeric', month: 'numeric', day: 'numeric',
}).format(d);
}
// 文章页:2026年6月5日 23:45 CST
export function fmtFull(dateStr: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const hasTime = dateStr.includes('T') || /\d{2}:\d{2}/.test(dateStr);
if (hasTime) {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: CST, year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
}).format(d) + ' CST';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric',
}).format(d);
}
五、经验总结
踩坑类型分布
| 类型 | 数量 | 典型案例 |
|---|---|---|
| CSS 选择器不精确 | 4 | svg text 选不到 foreignObject div |
| 异步加载竞态 | 2 | autoloader 测速 / Mermaid 主题变量 |
| 库 API 行为假设错误 | 3 | window.markmap 覆盖 / data-processed |
| 渲染管道顺序 | 1 | Shiki 在图表提取前执行 |
| 规范限制 | 1 | ES module 不允许顶层 return |
| CSS 优先级 | 2 | inline style 需要 !important 覆盖 |
调试清单
遇到客户端渲染不生效时,按以下顺序排查:
关键结论
-
服务端渲染图表(rehype-mermaid)在无头浏览器限制下不可行,客户端渲染是在小服务器上最实用的方案。
-
Markmap autoloader 不能用于生产,必须手动按序加载 UMD 脚本,且要在
markmap-lib加载前提前捕获Markmap类引用。 -
CSS 中
!important是覆盖 SVG 内联样式的唯一手段,但要注意选择器精度——foreignObject内的 HTML 和 SVG 原生元素是两个完全不同的渲染上下文。 -
MutationObserver是主题联动的标准方案,但要确保被观察的对象(如 Mermaid 渲染结果)能够被完全重置,否则重渲染会叠加样式。 -
渲染管道的顺序即合同,任何顺序调整都可能静默破坏功能。始终先用
curl或 DevTools 验证 HTML 输出,再排查 JS 行为。