- Published on
Markdown/MDX 流水线性能 — satteri 与原生内核真正改变了什么
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 3000 页文档,2 分 22 秒的构建
- 时间去哪儿了 — 五个阶段
- 性能分析 — 用测量代替猜测
- 插件链为什么会成为成本
- satteri — 原生内核改变了什么,又放弃了什么
- 基准测试能信到什么程度
- 数千页规模的站点实际该做什么
- 结语 — 不运行的解析器比快的解析器更快
- 参考资料
引言 — 3000 页文档,2 分 22 秒的构建
文档站点一旦变大,从某个节点开始,构建就会成为 CI 流水线里耗时最长的一项。页面只有几百个的时候根本不会在意的事情,一旦到了几千个,就会主导部署的交付周期,本地改一个错别字想确认一下,都得先去泡杯咖啡等着。
针对这个问题,最近的一个答案是 satteri。GeekNews 把它介绍为"面向 JavaScript 生态的高性能 Markdown 与 MDX 处理器",仓库自己的概括也很准确 —— 在 Rust 里解析和编译,插件则跑在 JavaScript 里。项目指出的问题同样清晰:JavaScript 生态插件丰富但解析器慢,Rust 生态则恰恰相反。
不过,在换工具之前,有件事必须先做:测量时间实际花在了哪里。在大型 MDX 站点里,Markdown 解析成为瓶颈的情况,比想象中要少。本文先搭建分阶段的成本结构和性能分析流程,再在此基础上看 satteri 这类原生内核到底改变了什么、又放弃了什么。构建变慢的常见原因,我们在为什么构建这么慢一文中已经梳理过。
时间去哪儿了 — 五个阶段
一个 MDX 文件变成 HTML 要经过以下几个阶段。每个阶段的成本性质不同,混在一起谈就找不到对策。
| 阶段 | 做什么 | 相对页面数的成本 | 主要浪费 | 怎么测 |
|---|---|---|---|---|
| 解析 | micromark 把 Markdown 词法化并生成 mdast | 线性,与文档长度成正比 | 几乎没有 | CPU 性能分析里的 micromark 帧 |
| 转换 | remark、rehype 插件各自把树遍历一遍 | 线性 × 插件数量 | 用不上的插件、重复遍历 | 按插件分别计时 |
| 编译 | 把 mdast 转成 hast,再转成 JSX/JS。MDX 还要解析表达式 | 线性 | 没有 MDX 表达式的文档也走 MDX 处理 | CPU 性能分析里的 acorn、estree 帧 |
| 打包 | 打包器把生成的 JS 再解析、转换、做 tree-shaking 一遍 | 线性但系数很大 | 每个页面单独一个模块,公共 chunk 没拆出来 | 打包器自带的性能分析 |
| 预渲染 | 执行每个页面生成 HTML | 线性但系数最大 | 代码高亮器、图片处理的初始化 | 构建日志里各阶段的耗时 |
实践中反复验证的一个事实是:页面数一旦到了几千的量级,下面两行经常会压过上面三行。Markdown 解析每篇文档只要几毫秒,但预渲染每个页面要花几十毫秒,代码高亮和图片优化还会叠加在这上面。尤其是 Shiki 系的高亮器,加载语法和主题的初始成本很大,配置不当的话甚至会在每个页面上重新做一遍。
因此,更换 Markdown 处理器这个选择是否有意义,取决于解析和转换是否确实是成本大头。换成不是这种情况的站点,就算把解析器提速五倍,整体构建也只会缩短百分之几。
性能分析 — 用测量代替猜测
测量做到三个层次就足够了。
第一层:把整个构建跑几次,看方差。只测一次就下结论,通常是错的。
# 清缓存后重复三次,同时观察平均值和波动
hyperfine --warmup 1 --runs 3 --prepare 'rm -rf .next .contentlayer' 'pnpm build'
第二层:用 Node CPU 性能分析看时间花在了哪些代码上。
# 输出 V8 CPU 性能分析文件(会生成 .cpuprofile 文件)
node --cpu-prof --cpu-prof-dir=./prof ./node_modules/.bin/next build
# 在 Chrome DevTools 的 Performance 面板中打开 .cpuprofile,按 Self Time 排序
看性能分析时要关注的是排在前面的帧属于什么性质。micromark 系列排在前面就是解析,unist-util-visit 排在前面就是插件遍历,acorn 系列就是 MDX 表达式编译,看到高亮器的名字,那就是元凶。
第三层:按插件把耗时拆开看。这是三层里给出信息最可操作的一层。把 unified 的 attacher 包一层,就能测出每个插件各自的遍历耗时。
// time-plugins.mjs — 测量每个插件的树遍历耗时
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'
const totals = new Map()
// 包一层 attacher,只累加 transformer 的执行时间
function timed(plugin, name, options) {
return function (...args) {
const transformer = plugin.call(this, options)
if (typeof transformer !== 'function') return transformer
return async (tree, file) => {
const t0 = performance.now()
const result = await transformer(tree, file)
totals.set(name, (totals.get(name) ?? 0) + (performance.now() - t0))
return result
}
}
}
const processor = unified()
.use(remarkParse)
.use(timed(remarkGfm, 'remark-gfm'))
.use(remarkRehype)
.use(timed(rehypeSlug, 'rehype-slug'))
.use(timed(rehypeAutolinkHeadings, 'rehype-autolink-headings'))
.use(rehypeStringify)
const source = readFileSync(process.argv[2], 'utf8')
const t0 = performance.now()
for (let i = 0; i < 100; i++) await processor.process(source)
const total = performance.now() - t0
console.table(
[...totals].map(([name, ms]) => ({
plugin: name,
ms: +ms.toFixed(1),
share: `${((ms / total) * 100).toFixed(1)}%`,
}))
)
console.log(`total ${total.toFixed(1)}ms for 100 passes`)
把这个脚本拿到实际仓库里最大的几篇文档上跑一遍,通常会得到意外的结果。要么是生成目录、标题锚点这类看似不起眼的插件冲到了前面,要么正相反 —— 插件总耗时加起来微不足道,换流水线这件事本身就没有意义。不管是哪一种,这个信息都会决定接下来怎么做。
插件链为什么会成为成本
unified 的设计很优雅:把插件接到处理器上,每个插件拿到树、修改它,再传给下一个。问题出在这份优雅背后的成本结构上。
每个插件都会各自完整遍历一遍树。GFM 表格、任务列表、自动链接、删除线、智能引号、指令、标题 slug、锚点链接 —— 这些每加一个就多一次遍历。一篇文档如果挂了十二个插件,树就要被遍历十二遍。单次遍历要做的事不多,但那是一路追着节点对象、追着指针跑的活儿,缓存局部性很差。
再叠上 MDX,又多一层。MDX 文档里的 JSX 和花括号表达式必须重新交给 JavaScript 解析器解析成 estree,输出的也不是 HTML,而是可执行的 JS 模块。Markdown 编译结束之后,打包器还会把那份 JS 再解析一遍 —— 同样的内容实际上被解析了三遍。
所以有一些不用更换流水线、立刻见效的措施。
- 删掉用不上的插件。从模板继承来的插件原封不动留在那儿的情况真的很常见。逐个拿掉、对比结果 HTML 就能确认。
- 把多个插件合并成一次遍历。如果有三个插件都在处理标题,可以合并成自己写的一个。这是拿可维护性换性能的选择。
- 不需要 MDX 的文档就按纯 Markdown 处理。把不用组件的文档也编译成 MDX,等于白白付出 JS 解析的成本。
- 把代码高亮从构建里拿出来。与其在构建时每次都跑高亮器,不如缓存结果,或者把加载范围收窄到实际用到的语言和主题。仅这一项,就有不少站点能把构建时间砍掉一半。
satteri — 原生内核改变了什么,又放弃了什么
satteri 的结构在仓库里写得相对清楚。Rust crate 组成流水线,napi-rs 绑定把它暴露给 Node,TypeScript 层提供插件 API。
satteri-pulldown-cmark—— 加了 MDX 扩展的 pulldown-cmark 分支,负责 CommonMark 解析。satteri-mdxjs-rs—— fork 自 Titus Wormer 的 mdxjs-rs、改为使用 pulldown-cmark 和 OXC 的 MDX 编译器。satteri-arena—— arena 分配器与二进制缓冲区的原始类型。satteri-ast—— mdast/hast 节点类型、编解码器与树操作。satteri-plugin-api—— 插件 trait、带类型的 visitor 与执行器。
挑出三个核心设计决策来看:第一,解析器基于 pulldown-cmark,因此运行方式接近单遍事件流。第二,AST 以二进制形式排布在 arena 里,节点逐个分配对象、追踪指针的成本就此消失。第三,GFM 表格、任务列表、脚注、删除线、数学公式、标题属性、YAML frontmatter 这类扩展,不是作为插件、而是作为解析器自身的功能被内置进去的。也就是说,以前要靠插件遍历处理的东西,现在一次解析就顺带处理掉了。
安装和使用都很平常。因为通过 napi-rs 分发预编译好的原生二进制文件,所以不需要 Rust 工具链。macOS(Apple Silicon、Intel)、Linux x86_64 glibc、Windows x86_64 都提供了对应的二进制文件,其他环境则回退到 WASI 构建。
npm install satteri
# Vite 集成 — 直接 import .md/.mdx
npm install vite-plugin-satteri
import { markdownToHtml, mdxToJs, defineMdastPlugin } from 'satteri'
// mdast 层级的插件 — 用 define 辅助函数来获得类型推断
const stripDrafts = defineMdastPlugin({
name: 'strip-drafts',
visit: {
blockquote(node, ctx) {
if (ctx.text(node).startsWith('DRAFT:')) ctx.remove(node)
},
},
})
const html = await markdownToHtml(source, { mdastPlugins: [stripDrafts] })
const js = await mdxToJs(source, { mdastPlugins: [stripDrafts] })
这里有一点必须分清楚。satteri 在 mdast 和 hast 两个层级提供了自己的插件 API。但这不代表现成的 remark-* / rehype-* npm 包可以直接拿来用。那些插件是基于 unified 处理器和普通 JavaScript 对象树的假设写出来的,而 satteri 的树是以二进制形式存放在 arena 里、跨越 napi 边界传过来的。实际上,Astro 把 satteri 接入作为可选处理器时公布的限制,说的正是这一点 —— 把处理器换成 satteri,整条 unified 链都会被替换掉,remark-toc、rehype-slug、rehype-autolink-headings 这类生态插件都无法工作。
下面这份配置形式是从二手媒体报道里引用来的,实际的包名和选项请以 Astro 官方文档为准重新确认。
// astro.config.mjs — Astro 中的可选启用形式(依据报道整理)
import { satteri } from '@astrojs/markdown-satteri'
export default defineConfig({
markdown: {
processor: satteri({
features: { directive: true },
}),
},
})
总结一下,这笔交换是这样的。
- 得到的:解析和编译降到原生速度,扩展功能不再需要插件遍历。插件越少、文档越多的站点,收益越大。
- 失去的:整个 unified 生态的插件资产。需要自定义处理的话,得用 satteri 的插件 API 重写一遍。
- 需要确认的:只要接上一个 JS 插件,每篇文档就都要在 Rust 和 JS 之间往返一次。这个边界成本会随插件数量怎样增长,公开文档里没有给出具体数字。如果计划接入较多插件,最好用自己的文档集合直接测一遍,更保险。
基准测试能信到什么程度
这一部分我如实写。
有一组广为引用的数字:Astro 文档站点从 142 秒降到 63 秒(约 2.25 倍),Cloudflare 文档从 120 秒降到 55 秒(约 2.18 倍),中等规模的营销站点从 38 秒降到 22 秒(约 1.73 倍)。随之流传的还有一个换算 —— 每次构建省下 79 秒,按每天构建 50 次算,一年大约能省下 230 个 CI 小时。
据我确认到的范围,情况是这样的。
- 这些数字不是 satteri 官方网站或仓库给出的。截至确认的那个时间点,satteri 官网上只有浏览器 WASM 演示的每秒文档处理量展示,并没有公布与 unified、remark、mdx-js 的对比数字,也没有可复现的基准测试工具。
- 上面那张表,引用自围绕 Astro 集成的二手媒体报道。测量用的硬件、缓存状态、插件配置、重复次数这些可复现条件,均未给出。
- 所以,"快了大概两倍"这个方向本身在架构上是说得通的,但把上面这几个数字当成自己项目的预期值,依据并不充分。
读基准测试时该确认哪些项目,整理如下。
- 到底拿什么和什么比。如果拿挂了十个插件的 unified 链条去和没有插件的原生流水线比,测出来的是插件数量,不是解析器性能。
- 比的是整个构建,还是只有流水线这一段。就算流水线提速五倍,如果这个阶段只占整体的 15%,整体也只会缩短 12%。阿姆达尔定律在这里同样成立。
- 是冷启动还是热启动。缓存还在的情况下重新构建,测的是另一回事。
- 输出是否一致。如果最终的 HTML 不一样,速度比较本身就不成立。换解析器时,必须用 diff 把渲染结果一一对照。
第四点尤其重要。pulldown-cmark 系列和 micromark 系列即便 CommonMark 兼容度都很高,在边界情况下输出也可能有细微差异。切换之前,最好先建立一套流程,把全部文档用两边分别渲染出来做对比。
# 切换前后全量比对渲染结果的最小流程
node scripts/render-all.mjs --engine=unified --out /tmp/html-unified
node scripts/render-all.mjs --engine=satteri --out /tmp/html-satteri
diff -ru /tmp/html-unified /tmp/html-satteri | head -100
数千页规模的站点实际该做什么
按效果大小排优先级,更换工具排得比想象中靠后。
第一优先级,减少要处理的页面数。让流水线更快,永远比不上根本不跑它。以内容文件的哈希为 key 缓存编译结果,做一条只重新处理变更文件的增量路径。在 CI 里把这份缓存远程共享出去,连冷启动的 runner 也能受益。缓存 key 该怎么设计才准确,我们在单体仓库 CI 缓存策略一文中讲过。
第二优先级,收拾预渲染和高亮。前面说过,页面数越多,这一块就越占主导。把高亮器要加载的语法和主题收窄到实际用到的那些,高亮结果按内容哈希缓存起来。
第三优先级,清理插件链。用前面那个计测脚本找出排名前三的插件,删掉或者合并。这是不用换工具就能拿到的改进,没有风险。
第四优先级,到这一步才去考虑换流水线。按这个顺序走下来,如果解析和转换依然排在前面,satteri 这类原生内核也许真的是答案。评估方法只有一个 —— 拿自己的文档集合直接测一遍,并把输出结果做比对。
引入时机也需要判断。satteri 还是个年轻的项目,官方文档还有没填满的地方,截至本文写作时也还没到稳定的 1.0 版本。文档站点的渲染准确性属于那种一旦出错很难挽回的风险,所以如果是大型生产站点,比较稳妥的做法是先跑几周并行渲染对比,再切换。反过来,如果是刚起步、插件依赖又少的新文档站点,现在就采用,损失也不大。
结语 — 不运行的解析器比快的解析器更快
satteri 瞄准的是一个真实的问题。JavaScript 的 Markdown 流水线之所以慢,不是因为哪段代码写得差,而是因为"每个插件都要把树重新遍历一遍"这个结构本身就是成本。把解析器和扩展功能挪到原生的单遍处理上,是对这个结构的一次老实的回应。
- 先测量。有
--cpu-prof加上按插件分别计时,30 分钟之内就能找到瓶颈。 - 在数千页规模的站点上,预渲染和代码高亮往往比解析更耗时。不要把这个顺序颠倒过来。
- 增量构建和内容哈希缓存永远是第一优先级。没有什么比不运行更快。
- 迁移到 satteri,unified 生态的插件不会跟着一起过来。那份资产的大小,才是真正的决策依据。
- 流传的那个"两倍"数字来自二手媒体引用,复现条件并未公开。请在自己的仓库里直接重测,并用 diff 比对渲染结果。
构建时间问题,大多数时候靠更少的工作量解决,而不是更快的工具。更换工具是排在这之后的选项。