给博客加阅读体验三件套:shiki 高亮、TOC、RSS 的踩坑实录
作者:陈宁(万象部落) 关键词:Next.js、Shiki、代码高亮、TOC、RSS、react-markdown 分类:架构
背景
后端换成 Gin 之后,我把精力转回前端阅读体验。技术博客最核心的三件事:代码要好看、长文要好读、更新要可订阅。于是做了三件套:
- shiki 语法高亮(构建期静态高亮,双主题跟随明暗切换)
- 文章目录 TOC(侧边目录 + 滚动高亮当前章节)
- 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、纯文本)显示成浅灰字配纸色背景,几乎不可读。
根因
两个样式叠加打架:
.prose-wanxiang pre { background:#16150f; color:#dcd6c6 }(老样式:深底浅字).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 插件炸 runSync | highlighter 单例 await 后同步调用 |
| 构建内存 | 多 worker × shiki OOM | 存量内容别全量预渲染 |
| mermaid | pre.type 判断失效 | 拦截逻辑跟着组件自定义走 |
| 高亮颜色 | CSS 强制覆盖 token | 别用 !important 碰 shiki 内联色 |
| fallback 样式 | 特异性压制 | 新旧样式共存放要查特异性 |
三件套最终效果:构建期静态高亮(零运行时成本)、双主题跟随明暗切换、长文侧边目录滚动高亮、阅读进度条、RSS 订阅入口齐全。
希望这篇记录能帮你绕开这些坑。