我为什么用 React 重写了一个 VitePress
故事是这样开始的。
团队要给 React 组件库做文档站。技术选型会上没人纠结:项目是 React 的,文档站当然也用 React 生态的工具------MDX 系的方案(Next.js + MDX、Nextra 这类自建或半自建组合)。理由非常充分:
- 示例即代码:
.mdx里直接import我们的<DatePicker />,文档和 demo 不会各写一遍; - 类型能串起来:组件 props 改了,示例里立刻报错;
- 团队熟悉 React 与 Next 那一套,不用引入第二套心智。
于是我们搭了站点、写了三十页文档、示例组件活蹦乱跳,演示时大家很满意。这一版是真的做完了、也是真的好用。
三周后,PM 发来一条消息
文档站挺不错!再补几个东西吧:
- 那种提示框,
::: tip/::: warning,像 Vue 文档那样的;- 代码块做成多语言 tab 切换,
pnpm/npm/yarn一组;- 高亮要好看点,最好代码块能标标题、能高亮指定行;
- 加个本地搜索,离线也能用;
- 右边要能跟滚动高亮标题的大纲,底下要有上一页/下一页,每页要有"在 GitHub 编辑此页";
- 中英文都要。
你参考一下 VitePress 那个文档站就行。
最后一句是重点:"参考 VitePress"。
而 VitePress 是 Vue 生态的文档站标杆------那些 ::: 容器、代码组、{#自定义锚点}、{.class}、右侧大纲、本地搜索,是它开箱自带的一套文档语法和默认主题。
于是问题变成了:我在 MDX 里"补"这些东西
我一开始以为是一周的量。做了两天发现,这不是"加几个组件",而是把一整套文档语法逐项搬进 MDX:
mdx
<!-- 想要 :: tip 容器?MDX 里没有这种语法 -->
<Callout type="tip">需要 Node 18+</Callout>
<!-- 想要多语言代码 tab?得自己写组件把多个代码块聚合 -->
<CodeGroup>
<CodeBlock lang="pnpm">pnpm add -D @10coding/vitepress-react</CodeBlock>
<CodeBlock lang="npm">npm i -D @10coding/vitepress-react</CodeBlock>
</CodeGroup>
每一项都是独立的坑:
- 提示框:要么给 micromark/remark 写插件,要么改用组件写法------但这样一来,内容团队写的就不再是"标准 Markdown",而是"带组件的文档";
- 代码组:要自己写聚合组件,还要处理高亮、标题、激活态持久化;
- Shiki 高亮 + 行高亮 + 代码标题:要自己接(hast 那一层还得改);
- 本地搜索:要么申请 Algolia DocSearch(联网、要审核),要么自己写索引生成 + 搜索 UI;
- 右侧大纲 / 上一页下一页 / 编辑此页 / i18n 文案表:全都要自己写或找插件。
更要命的是容错 。写惯了 VitePress 的人会下意识敲 ::: tip,而在 MDX 里,不认识的语法不会退化成文本,而是直接让编译失败。于是一个"打字习惯"就能让整页构建挂掉------文档团队的同事不可能记住"哪些写法在这个站里是合法的"。
做到第四天,我盯着需求清单突然反应过来:
PM 要的每一项,VitePress 都已经有了;我要的东西,就是VitePress 的语法 + 它的默认主题。 唯一的障碍是------VitePress 是 Vue 写的。
所以有了 vitepress-react
我们面前其实有三条路:
- 继续在 MDX 上补齐:每一项都自己能做,但等于自己维护一套文档框架,而且内容格式会越来越"像代码";
- 换成 VitePress:语法、主题、搜索、i18n 全都有------但团队得开始写 Vue 组件,而我们的组件库是 React 的;换成别的 React 文档框架则意味着再赌一次"它的语法约定够不够用",而 PM 参考的是 VitePress 那一套;
- 把 VitePress 的渲染层换成 React:内容模型、配置语义、默认主题的"形状"照抄,渲染与主题用 React 重写。
我选了第三条,于是有了 vitepress-react (npm 包名 @10coding/vitepress-react):
- 你写的还是 VitePress 风格的
.md------:::容器、代码组、frontmatter、{#锚点}、{.class}照旧; - 配置还是 VitePress 语义,站点目录只是从
.vitepress/变成.vitepress-react/; - 但正文里的组件、页面作用域状态、主题,全都是 React。
一篇博文里最诚实的一句话在这里:它不是"更好的 VitePress",而是"VitePress 的 React 实现"。内容不用重写,要重写的只有"正文里嵌的组件"和"你自己写的主题"。
你现在读的这篇博文的"母体"就是它:文档站在 aliuyilin.github.io/vitepress-r... ,代码在 github.com/ALiuYiLin/v... 。
一、为什么不干脆自己攒一个?
评估过。结论是没必要------VitePress 已经把"技术文档站"这件事打磨到几乎没改进空间,而这些恰好就是 PM 要的那份清单:
-
内容优先:Markdown 是唯一必需的语言。导航、侧边栏、大纲、本地搜索、上下页、编辑链接、暗色模式、i18n,默认主题开箱就有。
-
语法舒服(下面是真实的 Markdown,不是伪代码):
md--- title: 快速开始 --- ::: tip 前置条件 需要 Node 18+。 ::: ::: code-group ```bash [pnpm] pnpm add -D @10coding/vitepress-react ``` ```bash [npm] npm i -D @10coding/vitepress-react ``` ::: -
开发体验:Vite 驱动,启动快、改动即时生效;写的是 Markdown,得到的是可部署的静态站点。
-
性能模型:首屏是预渲染好的静态 HTML(SEO / 弱网友好),加载完成后变成 SPA------站内跳转不再整页刷新,还会自动预取视口里的链接。
-
生态与影响力 :Vue 3、Vue Router、Pinia、VueUse、Vite、Vitest、Rollup、UnoCSS、Iconify、Element Plus、Slidev......这些站点的文档都基于它(或其派生主题)。这意味着一整套"文档站最佳实践 + 主题约定 + 社区经验"都是现成的------PM 嘴里的"参考 VitePress",其实就是在说"用别人验证过的约定"。
所以目标不是"造一个更好的 VitePress",而是:保留它的内容模型和外观,把渲染层换成 React。
二、为什么不用 MDX?
先把话说清楚:MDX 是个好东西,我很喜欢它。
它的核心卖点非常漂亮:Markdown 文档可以直接当 JSX 用 。.mdx 里能 import 组件、写表达式,交互式文档写起来特别顺:
mdx
import Chart from './Chart.tsx'
# 销售看板
本月数据:<Chart data={data} />
remark / rehype 生态成熟,Next、Vite、Astro 都有成熟集成。如果我的需求是"用 Markdown 写 React 应用页面",MDX 会是首选。
但我的需求是"内容优先的文档站,而且 Markdown 语法要能照 VitePress 那套随便用",这时 MDX 有三处很硬的摩擦:
1)自定义 Markdown 语法必须自带插件,否则直接报错
::: 容器、::: code-group、{#anchor}、{.class} 都是扩展语法,MDX 里没有------而且它要求内容是合法 JSX ,遇到不认识的写法不会"退化成普通文本",而是编译失败 。开头那个"敲错一个 ::: 就整页挂掉"的体验,根源就在这里。
2)编辑器 / 语言服务的开箱程度不一样
.mdx 想有补全和诊断,需要额外配置:编辑器装 MDX 扩展、项目里配 TS 插件、处理 mdx 类型声明。仓库里一旦 .md 和 .mdx 混着放,体验就割裂。而 .md 是"原地可用"的:装完直接写,编辑器怎么处理 Markdown,就还是怎么处理。
3)正文与组件的边界太模糊,代价是错误面变大
MDX 里整页都是 JSX。少一个 import、漏转义一个 <,整页就编译不过。对"文档为主、交互为辅"的站点,这种"文档长得像代码"的代价并不划算。
所以本项目选了反过来的契约:
Markdown 保持是文档;只有你显式划出来的区域,才交给 React。
正文里的裸 {}、{{ }}、CSS 片段 .a { color: red } 通通按字面输出(不会意外求值);要动态内容就写 <>{expr}</>,要组件就写组件标签。规则清晰到可以写进团队规范,也可以让不写代码的同事安全地改文案。
三、那它用起来是什么感觉?
1. 在 Markdown 里写 React 组件
md
<script>
import { useState } from 'react'
export function Counter() {
const [n, setN] = useState(0)
return <button onClick={() => setN(n + 1)}>点了 {n} 次</button>
}
</script>
## 计数器
<Counter />
当前值:<>{1 + 1}</>
<script> 里的 import 与具名导出会提升到模块顶层(<Counter /> 才能被正文当组件用),其余语句进入页面组件作用域------所以 useState 这类 hooks 在 Markdown 里是合法的,和正文共享同一份状态。
再多行、再复杂的 JSX,用 ::: react 容器;或者直接写块级 <>...</>(支持内部空行和跨行表达式):
md
<>
{
// 多行 JSX 表达式也没问题
items.map((it) => <li key={it}>{it}</li>)
}
</>
2. 页面级样式隔离
想给某一页加样式又不想污染全站?用 <style scoped> 或 *.scoped.css,编译期注入 data-v-{hash},并支持 :global() / :deep() 选择器宏:
md
<style scoped>
.pricing-card {
border: 1px dashed var(--vp-c-brand-2);
border-radius: 10px;
padding: 1rem;
}
.pricing-card :deep(.VPBadge) {
vertical-align: middle;
}
</style>
带上作用域的卡片,连子组件里的元素都能精确命中 {.pricing-card}
(需要在站点配置打开 themeConfig.markdownScopedCss: true 并注册对应的 Vite 插件。)
3. Markdown 解析规则可以改
这一条是我最看重的:整个 Markdown 管线在自己的仓库里,Markdown-It 插件、token 级接管规则、HTML→JSX 序列化都能按需增删------自定义容器、自定义注解语法、图标语法、标题锚点规则,都能加:
ts
import { defineConfig } from '@10coding/vitepress-react'
import markdownItMySyntax from './md/my-syntax'
export default defineConfig({
markdown: {
config: (md) => {
md.use(markdownItMySyntax)
}
}
})
MDX 也能扩展,但要写 remark/rehype 插件,还得穿过 micromark → mdast → hast → JSX 好几层。这里你面对的是一份 TypeScript 实现的管线,改哪儿一目了然。
4. 主题可以"只换一个零件",也可以整体重画
只想换掉顶栏?用主题组件注册表按名字覆盖,一个组件粒度就够了:
ts
import { defineTheme } from '@10coding/vitepress-react'
import DefaultTheme from '@10coding/vitepress-react/theme'
import { MyNavBar } from './MyNavBar.tsx'
export default defineTheme({
extends: DefaultTheme,
components: {
VPNavBar: MyNavBar // 只换顶栏,其余保持默认
}
})
想全盘自定义?Layout、Content、useData() 都在,写一个自己的主题和写一个 React 应用没什么区别:
tsx
import { Content, useData } from '@10coding/vitepress-react'
import { LandingPage } from './LandingPage.tsx'
import { MySidebar } from './MySidebar.tsx'
export default function Layout() {
const { page, frontmatter } = useData()
if (frontmatter.layout === 'landing') {
return <LandingPage title={page.title} />
}
return (
<div className="docs-shell">
<MySidebar />
<Content />
</div>
)
}
取舍顺序建议是:公开配置项 → Layout 插槽(具名 props) → 组件注册表覆盖 → 自绘 Layout。覆盖越深,升级成本越高。
四、三分钟上手
bash
# 起一个新站(向导会写 package.json 脚本、依赖,连 react/react-dom 都帮你加好)
npx @10coding/vitepress-react init
# 或者手动装
pnpm add -D @10coding/vitepress-react react react-dom
bash
pnpm docs:dev # 开发
pnpm docs:build # 构建
pnpm docs:preview # 预览产物
上面的脚本名来自
init向导(默认加docs:前缀)。如果你跳过了"写入 npm 脚本"这一步,也可以直接pnpm vitepress-react dev|build|preview。
目录结构与 VitePress 一致(.vitepress-react/ 是配置目录,相当于上游的 .vitepress/):
bash
docs/
├─ .vitepress-react/
│ ├─ config.ts # 站点配置(与上游同语义)
│ └─ theme/
│ └─ index.ts # 自定义主题(可选)
├─ index.md
└─ guide/getting-started.md
继承过来的东西:默认主题(导航 / 侧栏 / 大纲 / 本地搜索 / 上下页 / 编辑链接 / 暗色模式 / i18n)、容器与代码组、Shiki 高亮、frontmatter、attrs、数据加载、动态路由、静态生成与 SSR、sitemap。
新加/不同的东西 :正文与主题是 React 组件;<>{expr}</> 与 ::: react 承接动态内容;页面级样式隔离带 :global() / :deep();主题组件注册表;可自由改动的 Markdown 管线。
五、说点实话:它现在不适合什么
- 不接受 Vue 语法 。
.vue组件、{{ }}插值、v-if/:prop/@click这类指令都不适用------请改用 React 写法。如果你的文档生态深度绑定 Vue,上游 VitePress 才是正确选择。 - Vue 专用插件与主题需要移植 。依赖 Vue 运行时或
.vueSFC 的第三方插件不能直接用;React 侧的等价物要自己接。 - 仍在 alpha。API 与默认主题细节可能调整,升级前请看一眼更新日志。
- 它不是 fork 。这是一个独立重写:只对齐内容模型、配置语义和默认主题的"形状",不走"跟上游合并"的路线。好处是 React 侧可以放开手做(比如
:global()/:deep()宏、md 页面级 scoped 样式、可编程的序列化契约)。
六、所以,给谁用?
一句话:你在 React 项目里做文档站,想要 VitePress 那种打磨程度,而内容里又必须放真正的 React 组件。
具体一点,符合下面任意一条就值得试试:
- 团队在 React 生态:能选的文档工具不少,但按开头那份清单逐个对下来,要么缺东西、要么得自己攒插件组件,要么得接受另一套语法与主题约定------就"开箱手感"来说,VitePress 仍然是我们试过最顺手的那个;
- 需求清单和开头那位 PM 类似,而且想要的是现成的、别人验证过的约定(就是 VitePress 那一套语法与默认主题),而不是再赌一次"另一个框架的语法够不够用";
- 想在 Markdown 里放真正的 React 组件(图表、表单、从 API 取数据的表格),但不想为此把整站变成 JSX;
- 需要页面级样式隔离,或者想给 Markdown 加自己的语法(内部规范、注解、术语高亮);
- 想深度定制主题,但不想从零搭一套文档框架。
反过来也成立:如果你要的是"Markdown 当 React 组件写"的那种自由,MDX 更合适;如果你的文档生态本来就在 Vue 侧,直接用上游 VitePress 更合适。vitepress-react 的位置在中间:Markdown 保持是 Markdown,React 该出场时随时出场。
- 文档站:aliuyilin.github.io/vitepress-r...
- 仓库:github.com/ALiuYiLin/v...
- 安装:
pnpm add -D @10coding/vitepress-react react react-dom(npm 7+ 会自动装 peer 依赖)
如果你的文档需要用 React 组件做动态展示(而不是截图或 GIF),同时你又想要 VitePress 那种"开箱即用、约定成熟"的文档体验------那 vitepress-react 就是为这个场景写的。欢迎来提 issue,或者直接拿它起一个站试试。