跳转到内容

在 Markdown 中使用 React ​

在本 React 版 VitePress(vitepress-react)中,每个 Markdown 文件都会被编译成静态 HTML,再经 JSX 序列化器生成页面组件。正文文本(含 {…}、{{…}})一律是字面量,与标准 Markdown / 上游 VitePress 一致;不存在 Vue 版的 {{ }} 插值、指令或 v-pre,也不需要转义。作者显式写的标签与 <>{…}</> 则按 JSX 交给 React(见 §3)。

页面动态能力来自三个机制,先用 §0 速查表给结论,再逐条看代码示例 → 真实渲染 → 编译后的 TSX(示意):

三个机制

  1. <script> 里的 page-scope 状态(注入到 Page() 函数体,正文 JSX(<>{…}</> 等)与其共享);
  2. <script> 具名导出的组件(<Counter />);
  3. 正文/容器中的 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}</>

输入 / 输出对比

输入:

md
{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 />​

输入

md
<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 按钮行:

输入

md
<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(示意)

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 中)

html
行内接管: <b>加粗</b> 与 <Badge type="tip" text="new" /> 都生效。

真实渲染

行内接管: 加粗 与 new 都生效。

编译后的 TSX(示意)

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 后即可用:

md
<script>
import ComponentInHeader from '../../components/ComponentInHeader.tsx'
</script>

#### 把组件放进标题 <ComponentInHeader />

实时效果:

把组件放进标题 ​

上面标题里的 ⚡ 就是 ComponentInHeader;大纲标题只取纯文本,不含组件内容。

4. 导入与复用组件 ​

如果组件只被少数页面使用,可以在页面的 <script> 里显式导入(可正确代码分割):

ts
<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 中)

md
::: react
<ul>
  {items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
:::

真实渲染

  • Alpha
  • Beta
  • Gamma

编译后的 TSX(示意)

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 包装:

输入

md
```text
Hello {1 + 1}
```

输出

text
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 延迟渲染:
md
<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 />:

md
<script>
import ModalDemo from '../../components/ModalDemo.tsx'
</script>

<ModalDemo />

实时效果:

9. 出错了怎么办 ​

JSX 区域恢复时会在源码前插入:

tsx
{/* JSX md:12 */}   {/* ← oxc 报错时提示来自 md 第 12 行附近 */}

再结合编译错误里的 page.md.tsx 行列,即可回到原 md 定位:

text
[PARSE_ERROR] Unexpected token
   ╭─[ page.md.tsx:…:… ]