Elytra
Blog2026

从 VitePress 到 Fumadocs

记录用 Fumadocs、Next.js 和 Cloudflare Workers 搭建三个内容站的经验,以及我为什么从 Vue 转到 React。

站务
GPT 5.6+1
fumadocsnextjsmigrationvitepress

先说结论:这三个网站本质上都是在写文章。文章里需要代码、公式、图表和稳定的目录;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 中
  • 站点要容易部署,访问速度也不能绑在一台需要维护的服务器上

所以我更关心「从写作到发布」这条链路,而不是首页能不能做出很复杂的动画。对这类站点来说,好的框架应该尽量让下面这条路短一些:

想法 MDX 文件 Fumadocs + React Next.js 构建 Cloudflare Workers

先认识三个名词

Next.js 是 React 的应用框架。React 负责组件怎么写,Next.js 再提供路由、构建、静态生成和服务端渲染。我的文档站用它在构建时把文章生成成静态页面。

MDX 可以理解成 Markdown + JSX。普通段落、标题和列表仍然是 Markdown;遇到 Tabs、Steps 或自定义卡片时,可以直接在正文里嵌入 React 组件。它比纯 Markdown 更灵活,也多了一层编译。

Fumadocs 建在 Next.js 和 MDX 之上,主要提供内容加载、侧栏页面树、文档布局和常用组件。它不是只能套固定模板的 CMS,页面外观和特殊组件仍然可以自己改。

文章就在仓库里

这篇文章本身就是仓库里的一个文件:

fumadocs-experience.mdx
skill-cli-mcp.mdx
meta.json

我可以用编辑器打开它,用 Git 看修改记录,提交到 GitHub,也可以让脚本批量检查链接。普通段落就是 Markdown;需要交互时,再写 React 组件:

普通正文可以直接写。

<Tabs items={["直觉", "实现"]}>
  <Tab value="直觉">先用一句话解释它解决什么问题。</Tab>
  <Tab value="实现">再放代码、公式或更细的推导。</Tab>
</Tabs>

文章不会被某个编辑器的页面模型绑死。代码审查、分支、批量替换、脚本生成目录,都可以继续用 Git 和命令行。

数学公式和图表

数学公式通常只需要在 MDX 流水线里接入 remark-mathrehype-katex。接入之后,表达式可以直接写在文章里:

P(load)=min(1,vv+d)P(\text{load}) = \min\left(1,\frac{v}{v+d}\right)

三个站点都用了 KaTeX。Ray Tracing Wiki 里公式尤其多:相机、法线、折射和 BRDF 的推导如果只靠图片,既不好复制,也不好继续改。

Mermaid 不是安装 Fumadocs 后自动拥有的能力,需要接到 MDX 编译或渲染流程里。Thinking in Minecraft 和 Ray Tracing Wiki 都用了它,图表仍然和正文一起版本控制:

Markdown MDX React StaticPage

组件可以直接写进正文

Fumadocs 真正改变写作体验的地方,是一批为技术文档准备的组件。它们不是装饰,而是另一种排版:把并列解释、操作步骤、目录树和补充说明,从「一大段纯文字」里拆出来。

Tabs 适合并列不同语言,或同一件事的两条解释路径:

先告诉读者它解决什么问题。比如侧栏:读者打开一篇文章时,应该立刻知道自己在哪一章、上一篇和下一篇是什么。

Steps 适合写操作流程。从文件到发布,大致是这样:

content/ 里写 MDX。普通段落用 Markdown,需要交互时再嵌入组件。

Fumadocs 读取目录和 meta.json,生成侧栏、路由和页面树。

Next.js 在构建时把大部分文章生成静态页,OpenNext 再部署到 Cloudflare Workers。

Accordion 适合收起不影响主线的细节。主线读完之后,需要时再展开:

TypeTable 适合描述 API 或配置项。比手写 Markdown 表格更适合带类型、默认值和是否必填:

Prop

Type

这些组件覆盖了技术写作里最常见的几种结构:并列、步骤、目录、收纳、类型说明。需要更特殊的东西时,再自己写 React 组件,例如图片对比滑杆。

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 的结构也清楚;NolebaseCrashMCLucide 都证明了这一点。

后来转向 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 的单文件组件则把 templatescriptstyle 分成三个区块。这种结构很清晰,也适合长期维护;但改一个小组件时,常常要在三个区块之间跳。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。

On this page