← 返回博客
架构2026-08-23 02:09:14 1

给博客加阅读体验三件套:shiki 高亮、TOC、RSS 的踩坑实录

给博客加 shiki 语法高亮、TOC 目录、RSS 订阅三件套时踩到的坑:async 插件炸 runSync、CI 构建 OOM、自定义 code 组件导致 mermaid 失效、CSS 覆盖 token 颜色、fallback 样式特异性压制。

给博客加阅读体验三件套:shiki 高亮、TOC、RSS 的踩坑实录

作者:陈宁(万象部落) 关键词:Next.js、Shiki、代码高亮、TOC、RSS、react-markdown 分类:架构

背景

后端换成 Gin 之后,我把精力转回前端阅读体验。技术博客最核心的三件事:代码要好看、长文要好读、更新要可订阅。于是做了三件套:

  1. shiki 语法高亮(构建期静态高亮,双主题跟随明暗切换)
  2. 文章目录 TOC(侧边目录 + 滚动高亮当前章节)
  3. RSS feed/feed.xml + 全站订阅入口)

外加一个阅读进度条。功能都不复杂,但每个都踩了坑,这篇完整记录。

架构回顾

渲染管线:react-markdown + remark-gfm + rehype-slug,自定义 pre/code 组件分流 mermaid 与普通代码块。

坑 1:@shikijs/rehype 让 next build 直接炸了

现象

最初用官方 rehype 插件接入:

rehypePlugins={[
  [rehypeShiki, { themes: { light: "github-light", dark: "github-dark" } }],
]}

构建报错:

Error: `runSync` finished async. Use `run` instead
Export encountered an error on /article/101, exiting the build.

根因

@shikijs/rehype 的插件本体是 async 函数(内部先异步创建 highlighter),而 react-markdown 处理 rehype 管道用的是 runSync——同步执行。异步插件塞进同步管道必然爆炸。

修复

放弃插件路线,改为「预初始化 highlighter 单例 + 同步高亮」:

let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter() {
  if (!highlighterPromise) {
    highlighterPromise = createHighlighter({
      themes: ["github-light", "github-dark"],
      langs: ["typescript", "go", "php", "bash"],
    });
  }
  return highlighterPromise;
}

// MarkdownRenderer 改为 async Server Component
export async function MarkdownRenderer({ content }) {
  const highlighter = await getHighlighter(); // await 初始化
  function CodeBlock({ className, children }) {
    const lang = className?.match(/language-([\w-]+)/)?.[1] ?? "";
    // highlighter 实例上的 codeToHtml 是同步的!
    const html = highlighter.codeToHtml(raw, { lang, themes });
    return <div dangerouslySetInnerHTML={{ __html: html }} />;
  }
}

关键洞察:创建 highlighter 是异步的,但拿到实例后 codeToHtml() 是同步的。把异步收敛到组件顶层 await,管道内保持全同步。

Checklist

  • react-markdown 只支持同步 remark/rehype 插件
  • 异步能力(如 shiki 初始化)在组件层 await 收敛,实例方法同步调用

坑 2:CI 构建 OOM(exit code 137)

现象

shiki 上线后 CI 连续失败:

deploy  Build (standalone)  ##[error]Process completed with exit code 137.

137 = 128 + 9(SIGKILL),典型 OOM。

根因

Next 默认多 worker 并行预渲染。当时 generateStaticParams 返回全部 70 篇文章 id——每个 worker 各自加载一份 shiki 引擎 + 语言 grammar(wasm),内存叠加超限被杀。

修复:B 方案——构建期不预渲染文章

冷静评估后发现:70 篇存量文章没必要在构建期全部渲染。改为:

export const revalidate = 3600; // ISR 兜底周期

export function generateStaticParams() {
  return []; // 构建期不预渲染任何一篇
}

效果:

  • 构建从 192 页 / 7 workers / OOM 变成 15 页 / 1 worker / 8.3s
  • 文章页首次访问按需生成 + 缓存,配合 on-demand revalidation 更新即时生效

配套调整:

// package.json — 限制 Node 堆
"build": "NODE_OPTIONS=--max-old-space-size=1536 next build"

// next.config.ts — 单 worker 双保险
experimental: { cpus: 1, workerThreads: false }

Checklist

  • 预渲染列表越长,构建期内存越高(每 worker 一份 shiki)
  • 存量内容不必全量预渲染:首访生成 + ISR 缓存 + on-demand 刷新是更优组合
  • exit 137 = SIGKILL,先查 OOM

坑 3:自定义 code 组件后,mermaid 不渲染了

现象

加 shiki 时自定义了 code 组件做高亮。结果文章里所有 mermaid 流程图退化成了原始文本。

根因

之前 mermaid 的拦截逻辑在自定义的 pre 组件里,靠判断 children 的 type 找 code 子元素。但一旦 components.code 也被自定义,pre 收到的 children.type 就变成了 CodeBlock 函数——拦截逻辑死路。而我又在 CodeBlock 的 mermaid 分支里只返回了个 <code> 原文。

修复

把 mermaid 拦截移进 code 组件,直接返回 MermaidSlot:

function CodeBlock({ className, children }) {
  const raw = String(children ?? "").replace(/\n$/, "");
  const lang = className?.match(/language-([\w-]+)/)?.[1] ?? "";
  if (lang === "mermaid") {
    return <MermaidSlot code={raw} />; // 直接在这里拦
  }
  if (lang && SHIKI_LANGS.has(lang)) {
    return <div dangerouslySetInnerHTML={{ __html: highlighter.codeToHtml(raw) }} />;
  }
  return <code>{children}</code>;
}
function PreBlock(props) {
  return <pre className="shiki-pre">{children}</pre>;
}

坑 4:CSS 把 shiki 的多彩 token 染成了统一色

现象

高亮上线后用户反馈「看不清楚」——代码块所有文字变成同一个浅灰色。

根因

我在 CSS 里写了这样的「双主题适配」:

.prose-wanxiang .shiki span {
  color: var(--shiki-light) !important; /* 错误示范 */
}

--shiki-light整块代码的默认前景色(一个灰黑色)。强制应用到每个 span,等于把 shiki 精心分配的每个 token 颜色全部覆盖成同一个颜色——语法高亮直接失效。

shiki 双主题的正确输出结构是:亮色 token 颜色写在内联 style 的 color 里,暗色放在 --shiki-dark 变量里:

<span style="color:#D73A49;--shiki-dark:#F97583">keyword</span>

所以亮色模式根本不需要 CSS 干预 color;只有暗色需要切换到 --shiki-dark

修复

/* 亮色:只调背景融入纸面,不动 token 颜色 */
.prose-wanxiang .shiki {
  background-color: #faf7f0 !important;
}
/* 暗色:整体切到 dark 变量 */
.dark .prose-wanxiang .shiki,
.dark .prose-wanxiang .shiki span {
  color: var(--shiki-dark) !important;
}
.dark .prose-wanxiang .shiki {
  background-color: var(--shiki-dark-bg) !important;
}

Checklist

  • shiki 双主题:亮色 token 自带内联 color,CSS 不要碰
  • 只有暗色切换才需要 color: var(--shiki-dark) 覆盖
  • !important + 全 span 选择器是高亮杀手

坑 5:fallback 代码块「纸底浅字」看不清

现象

无语言标注的代码块(如 jsonc、纯文本)显示成浅灰字配纸色背景,几乎不可读。

根因

两个样式叠加打架:

  1. .prose-wanxiang pre { background:#16150f; color:#dcd6c6 }(老样式:深底浅字)
  2. .shiki-pre { background: transparent }(新容器:透明)

叠加结果 = 透明底(露出纸色)+ 继承浅色字。而 .shiki-pre 里写的 color: var(--foreground) 因为特异性低于 .prose-wanxiang pre(0,1,2 vs 0,1,0)被压制。

修复

提升优先级并给 fallback 一个明确外观:

.shiki-pre {
  padding: 1em 1.2em !important;
  background: #faf7f0 !important;
  color: var(--foreground) !important;
}
.dark .shiki-pre {
  background: #1a1915 !important;
}

Checklist

  • 新旧样式共存时先查特异性(0,1,2 > 0,1,0)
  • fallback 分支也要有明确的底色与文字色,不能靠继承

RSS feed 的实现要点

RSS 本身没踩坑,记录几个设计决策:

// app/feed.xml/route.ts
export const revalidate = 3600; // 1 小时刷新

export async function GET() {
  const { list } = await getArticleList(1, 20); // 最新 20 篇真实数据
  // 拼 RSS 2.0 XML,escapeXml 处理转义
  return new Response(xml, {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}
  • 用的是真实文章数据(构建期走公网拉取,同 ISR 方案)
  • layout.tsx 的 metadata 加了 RSS 自动发现声明,阅读器可自动探测
  • Footer 和博客页头部都放了订阅入口,方便读者发现

总结

功能一句话教训
shiki 高亮async 插件炸 runSynchighlighter 单例 await 后同步调用
构建内存多 worker × shiki OOM存量内容别全量预渲染
mermaidpre.type 判断失效拦截逻辑跟着组件自定义走
高亮颜色CSS 强制覆盖 token别用 !important 碰 shiki 内联色
fallback 样式特异性压制新旧样式共存放要查特异性

三件套最终效果:构建期静态高亮(零运行时成本)、双主题跟随明暗切换、长文侧边目录滚动高亮、阅读进度条、RSS 订阅入口齐全。

希望这篇记录能帮你绕开这些坑。

相关推荐