在 Markdown 中使用 React
在本 React 版 VitePress(vitepress-react)中,每个 Markdown 文件都会被编译成静态 HTML,再经 JSX 序列化器生成页面组件。正文文本(含 {…}、{{…}})一律是字面量,与标准 Markdown / 上游 VitePress 一致;不存在 Vue 版的 {{ }} 插值、指令或 v-pre,也不需要转义。作者显式写的标签与 <>{…}</> 则按 JSX 交给 React(见 §3)。
页面动态能力来自三个机制,先用 §0 速查表给结论,再逐条看代码示例 → 真实渲染 → 编译后的 TSX(示意):
三个机制
<script>里的 page-scope 状态(注入到Page()函数体,正文 JSX(<>{…}</>等)与其共享);<script>具名导出的组件(<Counter />);- 正文/容器中的 JSX 区域(原样恢复,由 React/oxc 编译)。
SSR 兼容性
所有用法都要兼容 SSR。避免在组件顶层直接读写 window / document,浏览器专属逻辑请放进 useEffect 或客户端专属封装里。参见 SSR 兼容性。
0. 规则速查表
| 写法/位置 | 处理结果 |
|---|---|
普通正文(含 {x}、{{x}}、CSS 片段 .a { … }) | 字符串字面量:一律按字面文本原样显示,不求值、不报错 |
正文 <>{expr}</>(Fragment) | 显式 JSX:求值并渲染(引用 page-scope 绑定 {count}、纯字面 {1 + 1}、任意 JS);可独立成行或嵌在句子中间 |
字面花括号(想显示 {x} 本身) | 直接写即可:{x} 就是字面文本;\{ 退化为 md 默认显示 {,不再承担语义 |
独立成行的 <标签 …> 块 | React 接管:整行(可跨行配平)占位 → 原样恢复成 JSX |
正文行内的 <b>/<Badge/>/<>{x}</> | React 接管:同句片段占位 → 恢复成 JSX(片段前后文字仍是普通 md) |
ATX 标题行内的标签与 <>{expr}</>(## 标题 <Badge/>、## 计数 <>{count}</>) | 与正文同规则:接管并原样恢复成 JSX;anchor id / aria-label / 大纲文本按 token 类型过滤区域,只取纯文本 |
::: react … ::: 容器 | 任意多行 JSX(含 items.map(...) 表达式),原样交给 React |
Vue 指令/绑定(:members/@click/v-*/{{ x }}) | 不做识别:按 JSX 原样编译 —— :x/@x 是非法 JSX 属性名 → 编译期报错;v-* 当普通属性透传;{{ x }} 当表达式容器 |
| 代码 fence / 行内代码 | 字面量,永不求值/接管 |
<script> | import/具名导出 → 模块顶层;其余(useState 等)→ Page() 体 |
<style scoped> / *.scoped.css 导入 | 不是 JSX 区域:页面级 scoped 样式方案,见 md 页面 scoped 样式 |
| attrs 加类/id | {#id} / {.class}(分隔符为花括号,与上游一致) |
React 接管意味着属性按 JSX 写:class → className,style → 对象,事件 → 驼峰函数(onClick)。写错即作者语法错误,oxc 报错并带 md 行号注释(见 §9)。
1. 正文字面与 JSX 动态
Vue 版文档里的 {{ }} 在这里不存在,正文也不做 {expr} 求值——正文里的花括号一律按字面输出:
{count}、{{x}}、\{x\}、CSS 片段.a { color: red }都是字面文本,分别原样显示{count}/{{x}}/{x},不报错、不需要转义;- 要显示动态内容,显式包成 JSX Fragment:
<>{count}</>、<>{fmt(page.title)}</>、<>{items.length > 0 ? '有' : '无'}</>,可独立成行,也可嵌在句子中间; - 完整交互(带状态/事件)仍写组件标签
<Counter />(见 §2)。
一条规则,两个入口
<>…</> 是作者显式划出的 JSX children 区域:内部按 JSX 语法解析——普通文本原样、动态值写 {expr}、元素(<b>)与组件标签(<Badge/>)可以直接混排,不需要额外约定。所谓"行内 / 块级"只是 markdown 解析的两个入口,不是两套语法:
行内:嵌在句子里,
字 <>{x} 和 <b>加粗</b></> 尾→ fragment 作为段落里的一个行内 JSX 节点;块级:单独成行(可跨多行、内部允许空行),从首行
<>一直收集到行尾配平的</>后整段占位,如:md<> { // 多行 JSX 表达式 / 注释 items.map((it) => <li key={it}>{it}</li>) } </>这种写法与
::: react容器互补:常规"一整块 JSX"(元素/表达式/组件混排、可跨空行)直接写<>…</>;容器保留给它取代不了的场景——内容不以<>开头、不做<>…</>配平约束(如含裸</>的教学文本)、不含{/<的原始 JSX 文本区,或想用显式:::行收尾避免整篇扫描找配平,以及位于缩进上下文(列表/引用内;块级 fragment 要求列 0)。
约束:标签必须严格 <>…</>(漏斜杠 / 带空格即退回字面文本);内部是 JSX children,属性按 JSX 写(className、驼峰事件;Vue 指令会按 JSX 编译并在写错时报错,见 §7);未配平到文末的 <> 会整体回退为普通 markdown,不会吞掉后续段落。
两个常见坑
- Fragment 标签必须是无空格开标签
<>、带斜杠的闭标签</>;写成< >…或漏掉/(如只写<>收尾)都不会被识别,整行会按字面文本原样输出。 <>{expr}</>里只能放可渲染值(字符串/数字/元素/数组);useData()返回的theme/page/frontmatter是对象,直接放<>{data.theme}</>会整页崩溃(React: "Objects are not valid as a React child")。要看对象请JSON.stringify包一层,或只取标量字段:<>{JSON.stringify(data.theme)}</>/<>{data.theme.title}</>
输入 / 输出对比
输入:
{1 + 1} ← 字面文本
<>{1 + 1}</> ← JSX 表达式输出:
{1 + 1} ← 字面文本
2 ← JSX 表达式
{统计}、{#foo}、{.cls} 这类写法也是字面文本,原样显示;给元素加类/id 用 attrs 语法 {#id} / {.class}(见 md 页面 scoped 样式)。唯一注意点是段落/标题末尾的 {…} 可能被 attrs 当作 {#id}/{.cls} 消费(同上游行为):想展示字面花括号时,把它放到句子中间即可。
2. <script> 块:组件与页面作用域
根级 <script> 块放在 frontmatter 之后。块内容按两种位置编译:
- import 语句与具名导出(
export function/const) → 提升到模块顶层,可作为正文组件标签(<Counter />)使用; - 其余语句(含
useState/useEffect与普通变量) → 注入到页面组件Page()函数体内,和正文 JSX(<>{…}</>、::: react)共享同一作用域。
因此正文 <>{count}</> 里引用的 count 和你在 script 里声明的 useState 是同一份状态。注意交互更复杂时仍建议用具名组件封装状态逻辑。
2.1 具名导出组件:<Counter />
输入
<script>
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>count: {count}</button>
}
</script>
## Markdown Content
<Counter />输出(实际渲染)
2.2 page-scope 状态 + 正文 <>{expr}</>
下面的实时计数把 useState 写在 page-scope(非具名导出),正文用 <>{count}</> 显示、直接写 JSX 按钮行:
输入
<script>
import { useState } from 'react'
const [count, setCount] = useState(0)
const items = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Gamma' }
]
</script>
当前计数: <>{count}</>
<button onClick={() => setCount(count + 1)}>+1</button>输出(实际渲染)
当前计数: 0
编译后的 TSX(示意)
// 模块顶层(import / 具名导出)
import { useState } from 'react'
export default function Page() {
// ---- page scope(script 中非 import/export 的语句)----
const [count, setCount] = useState(0)
const items = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Gamma' }
]
return (
<div className="vp-doc">
<p>{"当前计数: "}<>{count}</></p>
{/* JSX md:… */}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}要点:useState 的返回数组注入 Page() 函数体,正文 <>{count}</>、onClick 引用的是同一份闭包状态 → 点击按钮即响应式重渲染;它等价于把这段代码写进一个 React 组件函数再返回 JSX。上面声明的 items 会在 §5 的 ::: react 示例中复用(page-scope 对本页所有正文可见)。
3. 正文里的标签行与行内 JSX
- 独立成行、以
<开头的 HTML 标签或 React 组件行:整行占位(可跨行配平),渲染后原样恢复成 JSX 交给 React/oxc 编译——不区分是否含={。 - 句子中间:想要动态值或标签,显式写 Fragment 或标签片段,如
温度: <>{temp}°C</>、行内 <b>加粗</b>;片段前后的文字仍是普通 Markdown。 - 只有被
<…>/<>…</>显式包住的内容按 JSX 求值;正文其余位置的裸{…}都是字面文本。 - JSX 属性要按 JSX 写(
class→className、style→ 对象、事件用驼峰函数)。
代码(写在 md 中)
行内接管: <b>加粗</b> 与 <Badge type="tip" text="new" /> 都生效。真实渲染
行内接管: 加粗 与 new 都生效。
编译后的 TSX(示意)
// 自动注入:import { VPBadge as Badge } from '@10coding/vitepress-react/theme'
<p>
{"行内接管: "}
<b>加粗</b>
{" 与 "}
<Badge type="tip" text="new" />
{" 都生效。"}
</p>注意:作者写的标签是原样交给 React 的(<b>加粗</b>,内部文本不做字符串包裹),只有 markdown 层自己生成的 HTML 才会经过属性转换(class → className 等)。
普通 HTML 标签(<b>)与主题组件(<Badge>/<VPTeamMembers>…)都按 JSX 编译;组件会从 vitepress/theme 自动导入。含 Vue 指令(:members、@click、<template #slot>)的行同样按 JSX 编译,所以写 Vue 语法会得到编译错误,而不是被静默丢弃(见 §7)。
多行 / 含 JS 表达式的 JSX 块
独立成行的 <>…</>(可跨多行、内部允许空行,可含 items.map(...))本身就是块级 JSX 区域,见 §1;::: react 容器保留给容器特有的场景(内容不以 <> 开头、不做 <>…</> 配平、需要显式 ::: 收尾,或位于缩进上下文),见 §5。
3.1 在标题中使用组件与表达式
标题与正文走同一套 JSX 区域规则:标题里的标签和 <>{…}</> 都会被接管、原样恢复成 JSX;而 anchor id、aria-label 与大纲文本只取纯文本 —— 文本提取按 token 类型过滤掉区域,所以 id 不会被组件标签或占位串污染。
| Markdown | 解析出的标题 | 说明 |
|---|---|---|
# 文档 <Badge type="info" text="new" /> | 文档 | 组件照常渲染,id = 文档 |
# 计数 <>{count}</> | 计数 | 表达式求值,id = 计数 |
# 文档 `<Badge/>` | 文档 <Badge/> | 行内代码是字面量,不当作组件 |
为什么 id 不会被污染
区域在 token 层就是独立类型(vp_jsx_inline),anchor 的文本提取与大纲提取都按类型过滤它 —— 不是"标题里不接管",而是"接管了但不参与纯文本"。
等价于 Vue 版 docs/components/ComponentInHeader.vue 的最小组件
docs/components/ComponentInHeader.tsx 就放在 docs 里,import 后即可用:
<script>
import ComponentInHeader from '../../components/ComponentInHeader.tsx'
</script>
#### 把组件放进标题 <ComponentInHeader />实时效果:
把组件放进标题
上面标题里的 ⚡ 就是 ComponentInHeader;大纲标题只取纯文本,不含组件内容。
4. 导入与复用组件
如果组件只被少数页面使用,可以在页面的 <script> 里显式导入(可正确代码分割):
<script>
import CustomComponent from '../../components/CustomComponent.tsx'
</script>
# Docs
This is a .md using a custom component
<CustomComponent />如果组件在绝大多数页面使用,可以在自定义主题/布局层统一包装与注入,参见扩展默认主题。
重要
自定义组件标签名必须 PascalCase,并在 <script> 顶层 import 或具名导出。作者写的标签会原样交给 React 编译,不会退回字面文本 —— 未定义时会在 SSR/浏览器渲染时报 Foo is not defined。
默认主题也导出可直接用的组件(VPBadge、VPTeamMembers、VPTeamPage 等),甚至文档里裸写 <Badge type="tip" text="new" /> 这类 Vue 全局注册标签,编译时会自动从 vitepress/theme 导入。
5. 多行 JSX:::: react 容器
块级 <>…</> 已支持跨多行(含空行),常规"一整块 JSX"用它写即可。::: react 容器保留给块级 fragment 取代不了的场景:内容不以 <> 开头、不需要 <>…</> 配平(如含不成对尖括号/裸 </> 的教学文本)、不含 { 或 < 的纯文本 JSX 区,或想用显式 ::: 行收尾,以及缩进上下文(列表/引用内;fragment 要求列 0)。容器只认首行 ::: react 与收尾 ::: 两行,中间内容对 markdown-it 完全不透明、原样交给 React:
代码(写在 md 中)
::: react
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
:::真实渲染
- Alpha
- Beta
- Gamma
编译后的 TSX(示意)
export default function Page() {
// page scope 提供 items(见 §2.2)
const items = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Gamma' }
]
return (
<div className="vp-doc">
{/* JSX md:<行> */}
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}为什么有真实 <ul> 而没有 <p> 包裹
块级区域在 markdown-it 眼里是一个块级占位元素(html_block,不会被包进段落),序列化阶段再把原文还原成 JSX —— 所以 <ul> 是真正的块级节点。
6. 代码块与指令
代码块天然是字面量,不需要 v-pre 包装:
输入
```text
Hello {1 + 1}
```输出
Hello {1 + 1}代码 fence / 行内代码里的任何内容都永不求值、不接管;想展示字面 JSX 代码,请这样写。
7. 保留字面的情形与 Vue 语法的结果
| 场景 | 结果 |
|---|---|
| 代码块 / 行内代码 | 字面展示(§6) |
Vue 指令语法(:members、@click、<template #slot>、v-if、{{ x }}) | 不做特征识别:按 JSX 编译 —— :x/@x → 编译期报错(oxc,报错行号可回到源 md);v-* 透传为普通属性;{{ x }} 当表达式容器 |
| 不做标签配平 / 无动态部分 | 保持字面:未闭合的 <div,或内部既无 {…} 也无标签的 <>、<></>、<>纯文字</>、a <> b |
<script> | 走 plugin-sfc 提取(组件/page-scope),不当作 JSX 区域(§2) |
<style> / <style scoped> / *.scoped.css 导入 | 不是 JSX 区域:全局样式运行时注入;页面级 scoped 样式见 md 页面 scoped 样式 |
8. 样式与客户端专属内容
- 全局样式:不带
scoped的根级<style>仍是全局样式(运行时注入全站)。 - 页面级 scoped 样式:想要 Vue-like 的页面级作用域时,用
<style scoped>内联块或导入*.scoped.css——在站点配置开themeConfig.markdownScopedCss: true并注册jsxScopedVitePlugin()(本示例站点已开启),编译后选择器带[data-v-{hash}]只作用于对应页面。用法与实时示例见 md 页面 scoped 样式。组件文件内部的局部样式仍用 CSS Modules 或内联样式(Vue SFC 的<style module>语义不提供)。 - VitePress 内置支持 CSS 预处理器(
.scss、.sass、.less、.styl、.stylus),在组件文件(如Counter.tsx旁的Counter.module.scss)中按 Vite 常规方式使用即可。 - 组件在 SSR 与浏览器都会渲染。需要“只在浏览器出现”的内容(读取
window、用 portal 挂到body),把副作用放进useEffect或借助ClientOnly延迟渲染:
<script>
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
export function Toast() {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return createPortal(<div className="toast">hello</div>, document.body)
}
</script>
<ClientOnly>
<Toast />
</ClientOnly>记住:所有客户端专属代码都要兼容 SSR——如果它在服务端抛错,站点构建会失败。
文件版等价示例:Vue 版 ModalDemo.vue(按钮 + Teleport 弹窗)在 React 里
的等价实现是 docs/components/ModalDemo.tsx——用 createPortal 挂到
body(React 版的 Teleport),样式在 ModalDemo.css;弹层默认不渲染,
SSR / 水合安全(Esc 或点遮罩关闭)。页面导入后直接 <ModalDemo />:
<script>
import ModalDemo from '../../components/ModalDemo.tsx'
</script>
<ModalDemo />实时效果:
9. 出错了怎么办
JSX 区域恢复时会在源码前插入:
{/* JSX md:12 */} {/* ← oxc 报错时提示来自 md 第 12 行附近 */}再结合编译错误里的 page.md.tsx 行列,即可回到原 md 定位:
[PARSE_ERROR] Unexpected token
╭─[ page.md.tsx:…:… ]