从 VitePress 到 Fumadocs
记录用 Fumadocs、Next.js 和 Cloudflare Workers 搭建三个内容站的经验,以及我为什么从 Vue 转到 React。
先说结论:这三个网站本质上都是在写文章。文章里需要代码、公式、图表和稳定的目录;Fumadocs 让我继续用文件写作,同时用 React 组件补上 Markdown 不够用的那一层。
最近我连续发布了三个内容站:
- Thinking in Minecraft:关于 Minecraft 历史、设计与技术实现的长篇写作。
- Ray Tracing Wiki:沿着 Rust、WebGPU 和光线追踪的实践过程写成的教程。
- Concurrency Wiki:围绕 Rust 并发,从原子操作、内存序一直写到自己实现锁和通道。
主题不同,底座几乎一样:Fumadocs + Next.js + MDX,部署到 Cloudflare Workers。
这不是一篇框架评测。VitePress 仍然很好用。我想记录的是:换成 Fumadocs 之后,哪些地方真的更顺手,哪些地方只是换了一套复杂度。
我为什么重新选择文档框架
这三个项目都不是典型的 Web 应用。最重要的能力不是登录或实时交互,而是让人能够长期阅读:
- 文章需要稳定的目录层级和侧栏
- 代码、图片、公式、流程图要能混在正文里
- 文章最好就是 Git 仓库里的 Markdown / MDX,而不是锁在 CMS 中
- 站点要容易部署,访问速度也不能绑在一台需要维护的服务器上
所以我更关心「从写作到发布」这条链路,而不是首页能不能做出很复杂的动画。对这类站点来说,好的框架应该尽量让下面这条路短一些:
先认识三个名词
Next.js 是 React 的应用框架。React 负责组件怎么写,Next.js 再提供路由、构建、静态生成和服务端渲染。我的文档站用它在构建时把文章生成成静态页面。
MDX 可以理解成 Markdown + JSX。普通段落、标题和列表仍然是 Markdown;遇到 Tabs、Steps 或自定义卡片时,可以直接在正文里嵌入 React 组件。它比纯 Markdown 更灵活,也多了一层编译。
Fumadocs 建在 Next.js 和 MDX 之上,主要提供内容加载、侧栏页面树、文档布局和常用组件。它不是只能套固定模板的 CMS,页面外观和特殊组件仍然可以自己改。
文章就在仓库里
这篇文章本身就是仓库里的一个文件:
我可以用编辑器打开它,用 Git 看修改记录,提交到 GitHub,也可以让脚本批量检查链接。普通段落就是 Markdown;需要交互时,再写 React 组件:
普通正文可以直接写。
<Tabs items={["直觉", "实现"]}>
<Tab value="直觉">先用一句话解释它解决什么问题。</Tab>
<Tab value="实现">再放代码、公式或更细的推导。</Tab>
</Tabs>文章不会被某个编辑器的页面模型绑死。代码审查、分支、批量替换、脚本生成目录,都可以继续用 Git 和命令行。
数学公式和图表
数学公式通常只需要在 MDX 流水线里接入 remark-math 和 rehype-katex。接入之后,表达式可以直接写在文章里:
三个站点都用了 KaTeX。Ray Tracing Wiki 里公式尤其多:相机、法线、折射和 BRDF 的推导如果只靠图片,既不好复制,也不好继续改。
Mermaid 不是安装 Fumadocs 后自动拥有的能力,需要接到 MDX 编译或渲染流程里。Thinking in Minecraft 和 Ray Tracing Wiki 都用了它,图表仍然和正文一起版本控制:
组件可以直接写进正文
Fumadocs 真正改变写作体验的地方,是一批为技术文档准备的组件。它们不是装饰,而是另一种排版:把并列解释、操作步骤、目录树和补充说明,从「一大段纯文字」里拆出来。
Tabs 适合并列不同语言,或同一件事的两条解释路径:
先告诉读者它解决什么问题。比如侧栏:读者打开一篇文章时,应该立刻知道自己在哪一章、上一篇和下一篇是什么。
Steps 适合写操作流程。从文件到发布,大致是这样:
在 content/ 里写 MDX。普通段落用 Markdown,需要交互时再嵌入组件。
Fumadocs 读取目录和 meta.json,生成侧栏、路由和页面树。
Next.js 在构建时把大部分文章生成静态页,OpenNext 再部署到 Cloudflare Workers。
Accordion 适合收起不影响主线的细节。主线读完之后,需要时再展开:
组件是为了改变信息的呈现方式,不应该替正文承担解释工作。如果一段话拆开之后,读者仍然要来回跳才能看懂,那就应该先改写作,而不是再加一层 Tabs。
我实际做法也不是把所有组件都注册一遍,而是只在确实需要时接入。
正文里的提示框默认就能用。info 补充背景,warn 标出坑,error 标出错误后果,success 收束结论,idea 放实践判断。
TypeTable 适合描述 API 或配置项。比手写 Markdown 表格更适合带类型、默认值和是否必填:
Prop
Type
这些组件覆盖了技术写作里最常见的几种结构:并列、步骤、目录、收纳、类型说明。需要更特殊的东西时,再自己写 React 组件,例如图片对比滑杆。
Sidebar 内容树
Fumadocs 的侧栏主要由每个目录下的 meta.json 描述。例如,一个站点可以把阅读顺序写成:
{
"title": "中文原版",
"root": true,
"pages": [
"index",
"---基础---",
"01-basic-of-rust-concurrency",
"02-atomics",
"---自己动手---",
"04-building-our-own-spin-lock"
]
}这里有一个容易踩的坑:如果目录是 root: true,它的 index 必须明确写进 pages,否则 /docs 或 /zh 可能无法正确激活这一棵树。页面 URL 也必须能在 page tree 里找到对应的 root;找不到时,侧栏可能把多棵树一起画出来。
我的经验是,先把 URL、目录和版本切换想清楚,再开始大量写文章。否则内容越多,后面修 sidebar 越像在给一棵已经长歪的树重新接枝。
部署到 Cloudflare Workers
三个站点都通过 OpenNext 部署到 Cloudflare Workers。这和「把一堆 HTML 上传到 Pages」有一点区别:Next.js 仍然负责构建和运行,Cloudflare 负责边缘分发。静态页面走 Static Assets,搜索等动态能力仍然可以由 Worker 处理。
我目前比较在意下面两件事。
SSG 页面要配合静态资源缓存
文档站绝大多数页面都是构建时生成的。OpenNext 配置里需要启用静态资源的增量缓存,否则每次刷新都可能重新唤起完整的 Next.js 运行时,在 Workers 上更容易遇到 1102 或 503。
核心配置大致是:
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import staticAssetsIncrementalCache from
'@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache';
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
enableCacheInterception: false,
});Cloudflare Workers 的踩坑
对于 Cloudflare Workers,enableCacheInterception 必须是 false。设为 true 后,线上会持续触发 _rsc 请求,频率可以达到每秒 20 多次,Worker 很快就会被打满。
部署命令不只是 wrangler deploy
我把日常部署收敛成一个命令:
npm run deploy它会先构建 OpenNext,再部署 Worker 和静态资源。只执行底层的 wrangler deploy,可能漏掉 OpenNext 的构建产物或缓存填充步骤,表现就会变成「构建成功,但线上刷新不稳定」。
部署后至少应该检查首页、几个深层文档页和搜索接口,并对同一页面连续刷新多次。文档站最容易被忽略的问题,往往不是首次打开,而是第二次、第三次请求的缓存路径。
为什么从 Vue 转到 React
2024 年用 VitePress 时,我仍然觉得它是非常好的文档框架。默认体验完整,Vue 的结构也清楚;Nolebase、CrashMC 和 Lucide 都证明了这一点。
后来转向 React,主要是两件事:能直接拿来改的组件更多,以及组件的编写方式更统一。
组件更多,也更好找
不是 Vue 做不出组件,而是我现在更常需要的那种「打开一个站点、复制一段代码、改成自己的视觉块」,在 React 这边更密、也更容易被搜到。
React 这边我会经常打开的:
- shadcn/ui:把源码复制进自己的仓库,再继续改
- React Bits:大量动画和视觉组件
- 21st.dev:聚合 React / Tailwind / shadcn 风格的组件和模板,也面向 AI 编程工具提供可复制提示词
- Aceternity UI:落地页和营销站点常用的动效区块
- Magic UI:偏展示向的动画组件
- Origin UI:另一套可复制的 shadcn 风格积木
Vue 同样有很好的库,只是重心不太一样,更接近完整的产品 UI,而不是「复制即用的视觉积木」:
- shadcn-vue:shadcn 在 Vue 上的对应实现
- Inspira UI:Vue 侧比较接近 Magic UI / Aceternity 的动画组件
- Nuxt UI:和 Nuxt 绑得很紧的设计系统
- Naive UI:完整、好用的 Vue 3 组件库
- Element Plus:后台和表单场景非常常见
- Vuetify:Material 风格的大型组件库
- Reka UI:无样式头组件,前身是 Radix Vue
就我目前关心的范围——视觉组件、落地页区块、可复制代码——React 的生态更大、更集中,也更容易被 AI 检索和组合。Vue 不是做不到,而是在这类组件的数量、分发方式和现成示例上,密度还不太一样。
一个函数写完,结构更紧凑
第二个原因是编写模型。
React 组件通常就是一个函数:接收 props,返回 UI。标签结构、条件判断、状态和数据处理可以放在同一个 TypeScript 文件里。对 MDX 来说,这意味着文章里嵌入的组件,和普通页面里的组件是同一种东西。
Vue 的单文件组件则把 template、script、style 分成三个区块。这种结构很清晰,也适合长期维护;但改一个小组件时,常常要在三个区块之间跳。React / JSX 通常把结构和逻辑放在一起,再用 Tailwind 或 CSS Modules 配样式,修改更集中。
用一个很小的「可折叠说明」就能看出差别:
import { useState } from 'react';
export function Note({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen((value) => !value)}>
{open ? '收起说明' : '展开说明'}
</button>
{open ? <p>{children}</p> : null}
</section>
);
}结构和逻辑在同一个函数里。要改文案、改条件,或者把它丢进 MDX,都还是这一处。
这不是说 Vue 更差。SFC 的分区对很多人来说更有秩序。只是我现在的工作方式更接近「在一个函数里同时改结构和逻辑」,再把它直接嵌进 MDX。这种写法加上 TypeScript 资料密度,和 AI 协作时也更顺:生成、修改、复用往往都在同一个上下文里。
当然,React 也可能变成很长的 className,并不是没有代价。
现在选择 Fumadocs,是因为项目同时需要更灵活的组件写法、更丰富的 React 组件生态,以及和 Next.js 共用一套 TypeScript 基础设施。
这也意味着 Fumadocs 不是 VitePress 的全面升级版。Next.js、MDX 编译链和 Cloudflare 部署都增加了复杂度。纯文档项目,我仍然会优先考虑 VitePress;需要大量自定义组件的内容项目,我会选择 Fumadocs。