博客渲染踩坑全记录:Mermaid、Markmap、双主题与布局

这篇文章记录在 搭建这个博客 之后,持续完善渲染功能时遇到的真实问题。每个坑都附原因分析和最终代码,可以直接复用。


问题全景

# 博客渲染踩坑 ## Mermaid 流程图 - Shiki 吞掉 language-* 类名 - 节点边框颜色/圆角 - Cylinder 形状 path 不一致 - 暗色主题渲染失效 - 切换主题不重渲染 - 工具栏(缩放/全屏) - 工具栏默认隐藏,hover 显示 - 高图自动等比缩放至 600px ## Markmap 思维导图 - autoloader CDN 竞态 - UMD 加载顺序覆盖 - foreignObject 文字选择器 - 连线颜色对比度 - 默认折叠层级 - 渐进折叠算法 ## 双主题代码高亮 - inline style vs CSS 变量 - Terminal 主题颜色失效 - 代码块语言标签 ## 布局与交互 - 目录悬浮定位 - 平滑跳转动画 - 固定顶栏 - 日期格式统一

一、Mermaid:从能跑到好看

1.1 Shiki 吞掉 language-mermaid 类名

最初的思路是让 Shiki 跳过 mermaid 代码块,留给客户端渲染。结果发现:Shiki 的 rehype 插件会把所有带 language-* 类名的 <code> 块都处理掉,处理完之后原来的类名就消失了,客户端 JS 再也找不到 mermaid 代码块。

flowchart LR MD["Markdown 源码\n→ unified 管道"] -->|"Shiki 处理后\nlanguage-mermaid 丢失 ❌"| HTML["HTML 输出\n类名已消失"] HTML -->|"找不到目标"| JS["客户端 JS ❌\n无法渲染"]

解法:在 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>

最初只写了 rectpolygon,结果"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-viewmarkmap-lib 都还没加载。

sequenceDiagram participant JS as 客户端 JS participant AL as markmap-autoloader participant CDN as CDN(jsdelivr/unpkg) JS->>AL: <script src="autoloader"> onload AL->>CDN: findFastestProvider()(异步测速) JS->>AL: renderAll()(此时依赖未加载!) Note over JS,AL: ❌ Markmap 渲染失败 CDN-->>AL: 测速完成,开始加载依赖 AL->>JS: 依赖加载完毕(已经太晚)

解法:绕过 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-viewmarkmap-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--;
  }
};

两层逻辑的执行效果示意:

flowchart TD A["transform(markdown)\n得到完整树"] --> B["foldDeep(root)\n折叠 depth≥2 有子节点的项"] B --> C{"可见 3 级\n节点总数 > 12?"} C -->|"是"| D["折叠最后一个 2 级节点\ntail--"] D --> C C -->|"否"| E["Markmap.create()\n渲染"]

三、双主题代码高亮

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 覆盖

调试清单

遇到客户端渲染不生效时,按以下顺序排查:

调试流程/
├── 1-检查 DOM/  ← 占位符 .diagram-raw 是否存在?
├── 2-检查 Console/  ← 有无 JS 错误?try/catch 是否打印了?
├── 3-检查网络/  ← CDN 脚本是否加载成功?
├── 4-检查 CSS/  ← 选择器是否命中正确元素?
│ ├── svg text  ← SVG 原生文字
│ └── foreignObject div  ← Markmap HTML 注入
└── 5-检查执行顺序/  ← async/await 是否遗漏?

关键结论

  1. 服务端渲染图表(rehype-mermaid)在无头浏览器限制下不可行,客户端渲染是在小服务器上最实用的方案。

  2. Markmap autoloader 不能用于生产,必须手动按序加载 UMD 脚本,且要在 markmap-lib 加载前提前捕获 Markmap 类引用。

  3. CSS 中 !important 是覆盖 SVG 内联样式的唯一手段,但要注意选择器精度——foreignObject 内的 HTML 和 SVG 原生元素是两个完全不同的渲染上下文。

  4. MutationObserver 是主题联动的标准方案,但要确保被观察的对象(如 Mermaid 渲染结果)能够被完全重置,否则重渲染会叠加样式。

  5. 渲染管道的顺序即合同,任何顺序调整都可能静默破坏功能。始终先用 curl 或 DevTools 验证 HTML 输出,再排查 JS 行为。