@revealjs/react 架构解析:Reveal.js 的 React 封装、同步模型与组件职责全解
@revealjs/react 架构解析:Reveal.js 的 React 封装、同步模型与组件职责全解
本篇技术指南以仓库内 react/AGENTS.md 为骨架,结合 @revealjs/react(Reveal.js 官方 React 封装)的源码、类型定义与测试布局,系统讲解该封装的目录结构、Deck 生命周期不变量、同步/配置策略、各组件职责、Markdown 与代码高亮机制,以及修改行为时必须遵守的测试与验证纪律。读完本文,你将能够独立读懂 react/src 下的每一处实现细节,并具备在 React(含 StrictMode)环境中正确使用 Deck、Slide、Stack、Fragment、Code、Markdown 六个组件的能力,同时掌握 sync()、syncSlide()、configure() 三类 Reveal 操作在 React 渲染模型下的最佳调用时机。
一、包定位与源码布局
react/ 目录承载的是独立发布的 npm 包 @revealjs/react(版本见 react/package.json),它是对 Reveal.js 的一个"薄封装":你仍然用 React 组件描述幻灯片,其余初始化、事件、同步等工作由封装层完成。与 reveal.js 核心包的关键区别是:该包只包含 React 绑定,不打包 Reveal CSS、主题与插件,这些仍需要你自行引入。
1.1 目录职责划分
根据 react/AGENTS.md 的 "Current Layout" 一节,源码布局遵循以下约定:
| 路径 | 职责 |
|---|---|
| react/src | 全部源码 |
| react/src/components | 组件实现,文件命名采用 kebab-case(如 deck.tsx、slide.tsx) |
| react/src/utils | 共享辅助函数 |
| react/src/index.ts | 公共导出入口 |
| react/src/types.ts | 共享类型定义 |
| react/src/reveal-context.ts | Reveal 实例的 React Context |
| react/src/tests/setup.ts | 测试环境初始化 |
| react/demo/src | 演示应用 |
组件测试与组件同目录存放,命名为 *.test.tsx(例如 deck.test.tsx 与 deck.tsx 相邻)。查看 react/src/components 可以确认六个组件与五个测试文件一一对应。
1.2 事实来源清单
AGENTS.md 的 "Source Of Truth" 一节明确划定了三类事实来源,这也是后续章节引用源码时的依据:
- 实现(Implementation):
deck.tsx、slide.tsx、stack.tsx、fragment.tsx、code.tsx、markdown.tsx; - 共享辅助(Shared helpers):
slide-attributes.ts(slide 属性映射)与markdown.ts(Markdown 解析与注释属性处理); - 行为测试(Behavioral tests):
deck.test.tsx、slide.test.tsx、fragment.test.tsx、code.test.tsx、markdown.test.tsx; - 面向使用者的行为摘要:react/README.md。
从 react/src/index.ts 可以验证公共 API 的最终形态:Deck、Slide、Stack、Markdown、Fragment、Code、RevealContext 六个导出项,加上 useReveal() 钩子,以及 DeckProps、SlideProps 等类型导出。
二、Deck 生命周期不变量:创建、销毁与 StrictMode
Deck 是整个封装的"总控",它负责 Reveal 实例的完整生命周期。react/AGENTS.md 用三条不变量约束这一行为,全部可以在 deck.tsx 中得到印证。
2.1 不变量一:一挂载一实例,一卸载一销毁
Deck 在 mount 时通过 new Reveal(deckDivRef.current, { ...config, plugins }) 创建一个 Reveal 实例,并在 unmount 时调用 instance.destroy() 销毁(见 deck.tsx)。初始化是异步的:instance.initialize().then(...) 成功后才通过 setDeck(instance) 把实例放入 React 状态,并触发 onReady 回调。
这解释了 README 中"初始化是异步的"这一描述:onReady 在 reveal.initialize() resolve 之后触发,之后实例才可通过 useReveal() 与 deckRef 获取。
2.2 不变量二:StrictMode 安全,禁止双重初始化
React 18+ 的 StrictMode 会在开发环境对每个 effect 执行"挂载 → 卸载 → 再挂载"。如果 Deck 每次都重新 new Reveal(),就会出现双重初始化。源码的解法值得细读:
- 复用实例:首次 mount 创建实例后存入
revealRef;第二次 mount 时检测到revealRef.current已存在且isReady()为真,则直接复用并setDeck,跳过构造(deck.tsx); - 延迟销毁 + 请求号防误杀:unmount 时不是立即销毁,而是通过
Promise.resolve().then()把销毁推迟到下一个微任务,并用teardownRequestRef计数。若 StrictMode 立即重挂载使计数失效,回调会直接 bail out,避免销毁一个仍存活的实例(deck.tsx)。
这一设计对应 AGENTS.md 中"do not reintroduce double initialization"的硬性要求,也是修改生命周期逻辑时必须保留的边界。
2.3 不变量三:事件用 on/off 配对管理
事件 props(onReady、onSync、onSlideChange、onFragmentShown 等)在初始化后用 deck.on(name, handler) 绑定,并在回调变更或组件卸载时用 deck.off(name, handler) 清理(deck.tsx)。支持的事件清单与 DeckProps 类型(types.ts)完全一致:
sync、slidesync、slidechanged、slidetransitionend、fragmentshown、fragmenthidden、overviewshown、overviewhidden、paused、resumed。
由于绑定 effect 的依赖数组包含全部回调,两次渲染间更换某个回调会自动卸载旧监听并挂上新监听,这正是 README "How it works" 一节描述的行为。
三、同步策略:sync() 昂贵,只在结构变化时调用
Reveal.sync() 会让 Reveal 重新扫描 DOM 中的幻灯片结构并重建内部书签,属于昂贵操作。AGENTS.md 的 "Sync Policy" 给出了明确纪律:
sync()应少调用;- 只有当渲染出的幻灯片结构发生变化时才调用——例如新增/删除/重排幻灯片,或把幻灯片移入/移出垂直堆栈;
- 已有幻灯片内部的普通 React 内容更新不得触发整卡同步;
- 但 deck 就绪后仍会执行一次 deck 级别的
sync()。
3.1 结构签名:用"签名"代替"全量比对"
deck.tsx 的实现将上述策略落到了实处:每次布局 effect 运行时,getSlidesStructureSignature 会把 .slides 容器内的 <section> 树序列化成一个 JSON 字符串签名——顶层 section 用数字 ID 表示,含有子 section 的则表示为 <a href="https://link.gitcode.com/i/6472aba3c4206a41dd2d1b91a32c3523" target="_blank">id, [子签名...]] 的嵌套数组(见 getSectionStructure,[deck.tsx)。
lastSyncedSlidesSignatureRef 保存上次已同步的签名,只有签名发生变化才调用 reveal.sync()。由于签名只描述结构而不包含内部文本,幻灯片内部的文本、样式更新不会误触发整卡同步。
3.2 第一次同步从何而来
首次渲染时 lastSyncedSlidesSignatureRef 为 null,与当前签名必然不等,因此 deck 就绪后会自动执行那"一次 deck 级同步";此后结构不变则不再触发。这一行为与 AGENTS.md 中"One deck-level sync is still expected once the deck is ready"完全吻合。
四、配置策略:浅比较 + 就绪后才 configure
config 是 Deck 的核心 prop,直接映射 Reveal 官方配置对象。AGENTS.md 的 "Configure Policy" 提出三条规则,逐一对照源码:
4.1 规则一:config 只做浅比较
deck.tsx 中的 hasShallowConfigChanges 对前后两份 config 做浅层逐键比较:键数量不同、某键缺失、或某键的值引用不同,都视为"有变化"。这意味着:父组件每次渲染都新建一个字面量对象、但值完全相同,也不会触发 configure()——这正是浅比较的意义所在。
4.2 规则二:configure 只在初始化完成且就绪后执行
config 应用逻辑位于 useLayoutEffect 中,第一道守卫是 if (!deck || !revealRef.current?.isReady()) return;(deck.tsx),确保 configure() 永远不早于初始化完成。构造函数中传入的 config 会被记录到 appliedConfigRef,作为后续比较基线。
4.3 规则三:configure 自带同步,wrapper 不得紧跟冗余 sync
configure() 在 Reveal 内部会自行执行一次同步,因此应用新 config 后不应立即再跑一次 sync()。源码用 skipNextSyncRef 标记实现:config 变化时置位 skipNextSyncRef.current = true 再调用 configure();下一次结构同步 effect 读到该标记后直接跳过本轮并更新已同步签名(deck.tsx)。如果你在修改中想在 configure() 后追加 sync(),就会违反这条策略并引入一次无谓的昂贵同步。
五、组件职责矩阵
AGENTS.md 的 "Component Responsibilities" 明确了六个组件各自的边界,防止职责膨胀:
| 组件 | 职责边界 |
|---|---|
Deck |
Reveal 生命周期、config 应用、事件接线、结构级 sync |
Slide |
slide 级 data-* 属性映射;仅当挂载后有效属性变化时调用 syncSlide() |
Markdown |
在 React 内复刻核心 Markdown 插件行为:解析、分隔符、注释、.slide:/.element: 属性 |
Code |
显式代码块渲染 + Reveal highlight 集成(非 markdown 代码块) |
Stack / Fragment |
保持轻量,除非有强理由扩展 |
5.1 Slide:属性映射与 syncSlide
slide.tsx 展示了一条清晰的链路:Slide 接收一批"便利 prop"(background、backgroundColor、visibility、autoAnimate、transition、transitionSpeed、autoSlide、notes、backgroundInteractive、preload 等),由 slide-attributes.ts 中的 SLIDE_DATA_ATTRIBUTES 映射表统一转换为对应的 data-* 属性(如 background → data-background、autoSlide → data-autoslide),最终落到渲染出的 <section> 上;未转换的原始 data-* 属性也会原样透传。
关键机制是 getDataAttributesSignature:把全部 data-* 属性按键排序后序列化为字符串签名。useLayoutEffect 中,仅当 deck 实例变化(首次渲染由父 Deck 的 sync() 覆盖)或属性签名变化时,才调用 deck.syncSlide(slide) 做单卡局部同步(slide.tsx)。这正是 README 所说"Slide 内部的普通内容更新不会触发整卡 sync"的底层实现——syncSlide 是 Reveal 提供的单卡级同步 API,代价远低于 sync()。
5.2 Code:显式代码块与高亮恢复
code.tsx 负责非 Markdown 场景下的代码块渲染:
normalizeCode会统一换行、去除首尾空行并计算最小公共缩进(trim默认开启);lineNumbers、startFrom、noEscape分别映射为data-line-numbers、data-ln-start-from、data-noescape;- 高亮通过
deck.getPlugin('highlight')获取 highlight 插件,再调用highlightBlock(block)完成; - 高亮签名(代码、语言、类名、行号配置的拼接串)用于跳过重复高亮;同时
cleanupGeneratedFragments会清除插件为多步高亮(如1|3)生成的<code class="fragment">临时节点,防止重渲染时重复累积(code.tsx)。
这与 AGENTS.md "Markdown And Highlighting Notes" 中"高亮应在重渲染及 Reveal 可用后恢复,且无需整卡 sync"的要求呼应:Code 的 useLayoutEffect 依赖 deck,Reveal 一就绪即触发恢复;同时通过 deck.syncFragments 局部同步高亮产生的 fragment,而不触碰 deck 级同步。
5.3 Stack 与 Fragment:刻意保持轻量
stack.tsx 仅有十余行——渲染一个带可选 className/style 的 <section> 作为垂直堆栈容器;fragment.tsx 负责把内容包装成带 fragment 类(可加 animation 动画类)的元素,并通过 as(自定义标签,默认 span)或 asChild(克隆唯一子元素并合并 className/style,要求子元素不能是 React Fragment)两种方式渲染,index 映射为 data-fragment-index。二者保持轻量是刻意为之:复杂的 deck 级行为应沉淀在 Deck/Markdown/Code 中,而不是扩散到这两个基础组件。
六、Markdown 与高亮机制:对齐核心插件
Markdown 组件是 README 中唯一"不注册 Reveal markdown 插件"却能渲染 Reveal 兼容 markdown 的组件,其实现集中在 markdown.tsx 与 markdown.ts 两处。
6.1 数据源三种形态与默认分隔符
Markdown 接受 children(字符串)、markdown prop、src(外部文件,经 fetch 加载,支持 charset 指定编码,失败时渲染 data-state="alert" 的错误 section,见 markdown.tsx)三种输入。一等分隔符 prop 及其默认值在 markdown.ts 定义:
| prop | 默认正则 | 作用 |
|---|---|---|
separator |
\r?\n---\r?\n |
水平幻灯片分隔 |
verticalSeparator |
null(默认关闭) |
垂直幻灯片分隔 |
notesSeparator |
^\s*notes?: |
演讲者备注分隔 |
elementAttributesSeparator |
\.element\s*?(.+?)$ |
.element: 注释属性 |
slideAttributesSeparator |
\.slide:\s*?(\S.+?)$ |
.slide: 注释属性 |
options 可透传其他 Marked 配置,其中 animateLists 会在自定义 renderer 中把每个 <li> 加上 fragment 类实现列表逐项出现,smartypants 则启用 marked-smartypants 扩展(markdown.ts)。normalizeMarkdownSource 还会自动去除模板字符串造成的公共缩进或制表符(markdown.ts)。
6.2 与核心插件一致的分割与注释属性
buildMarkdownNodes 明确注释"mirrors the core plugin's slidify pass"——它用同一套分隔符正则把源码切成水平/垂直两级 section 结构(type: 'slide' 与 type: 'stack'),保证与 Reveal 原生 markdown 插件产生完全相同的 <section> 嵌套(markdown.ts)。createSlideHtml 则负责把 notes: 段落后的内容包进 <aside class="notes">(markdown.ts)。
渲染后,MarkdownLeafSection 通过 useLayoutEffect 对真实 DOM 执行 addAttributes 遍历——这是对核心插件注释属性逻辑的移植:.element: 注释会附加到紧邻的(或列表最后一项)元素上,.slide: 注释附加到所在 section(markdown.ts),从而支持 README 示例中的 <!-- .element: class="fragment" --> 与 <!-- .slide: data-background="#111827" --> 写法。
6.3 高亮恢复不依赖整卡同步
MarkdownLeafSection 的另一个 useLayoutEffect 会查找 section 内所有 pre code,对未标注 data-highlighted 的块调用 highlight 插件,完成后用 deck.syncFragments(section) 局部同步(markdown.tsx)。这正是 AGENTS.md 所要求的"Markdown 代码高亮在重渲染和 Reveal 可用后恢复,且不需要完整 deck sync"——高亮状态被限制在 Markdown 自己的 section 内处理,不会扩大为 deck 级同步。
七、事件订阅与 Reveal API 访问
虽然 AGENTS.md 侧重维护者视角,但事件与 API 访问是理解 Deck 设计动机("event wiring"职责)的入口,react/README.md 提供了面向使用者的完整示例。
7.1 事件 props 示例
在 Deck 上直接声明 onReady、onSync、onSlideChange、onFragmentShown 等回调即可订阅对应 Reveal 事件,回调签名与 Reveal 原生一致:
import { Deck, Slide } from '@revealjs/react';
export function Presentation() {
return (
<Deck
onReady={(deck) => console.log('Reveal ready', deck)}
onSync={() => console.log('Deck synced')}
onSlideChange={(event) => console.log('Slide changed', event.indexh, event.indexv)}
onFragmentShown={(event) => console.log('Fragment shown', event.fragment)}
>
<Slide>Intro</Slide>
<Slide>Next</Slide>
</Deck>
);
}
7.2 useReveal() 与 deckRef
组件树内通过 useReveal() 获取上下文中的 Reveal 实例(reveal-context.ts 定义了 createContext<RevealApi | null>(null),index.ts 导出该钩子):
import { Deck, Slide, useReveal } from '@revealjs/react';
function NextButton() {
const deck = useReveal();
return <button onClick={() => deck?.next()}>Next slide</button>;
}
组件树外则通过 Deck 的 deckRef prop(类型为 Ref<RevealApi | null>)获取实例;deck.tsx 中的 effect 会在实例就绪后写入 ref,并在卸载或 ref 变化时置空,保证 ref 与实例生命周期严格同步。
八、config 与 plugins 的使用边界
将 react/README.md 的配置示例与 deck.tsx 的实现对照,可以确认两条使用边界:
config 是响应式的:Deck 的 config prop 可直接映射 Reveal 配置对象,且因为浅比较机制,父组件每次渲染新建相同值的对象字面量不会触发 configure(),只有值真正变化才会应用:
<Deck
config={{
width: 1280,
height: 720,
hash: true,
controls: true,
progress: true,
transition: 'slide',
}}
plugins={[RevealHighlight]}
>
<Slide>Configured deck</Slide>
</Deck>
plugins 是初始化专属的:DeckProps<a href="https://link.gitcode.com/i/d4e7ff8ddfef32bfe0ed1ac5ac8b991c" target="_blank">'plugins'] 的注释明确写着"Registered during deck initialization only. Subsequent prop updates are ignored."([types.ts)。实现上 initialPluginsRef 只在首次 mount 捕获一次 plugins 并传入 new Reveal(...) 构造参数(deck.tsx),后续渲染即使传入新数组也会被忽略——这与 Reveal 的插件生命周期一致,因为 Reveal 的插件只能在初始化时注册。
九、修改行为时的纪律与验证
AGENTS.md 的最后两节是面向贡献者的工程规范,也是判断代码改动是否合规的检查清单:
9.1 修改行为时的三条规则
- 若
sync、configure、markdown或 highlight 行为发生变化,必须在同一次改动中更新对应的同目录测试(colocated tests); - 若公共 React API 或文档化行为发生变化,必须同步更新 react/README.md;
- 优先聚焦的回归测试,而非大而全的快照测试——快照会掩盖有意义的差异,聚焦测试才能精确描述行为边界。
9.2 验证命令
完成封装改动后,在仓库根目录执行以下命令完成验证(见 react/package.json 的 scripts,分别对应 vitest run 与 vite build):
npm test --prefix react
npm run build --prefix react
npm test --prefix react:运行 Vitest 测试套件,覆盖 deck/slide/fragment/code/markdown 的行为测试(测试环境由 react/src/tests/setup.ts 引入@testing-library/jest-dom/vitest初始化);npm run build --prefix react:用 Vite 构建dist/index.mjs与dist/index.d.ts(配置见 react/vite.config.ts 与 react/tsconfig.json),验证类型与产物可正常产出。
十、快速上手与约束提示
最后给出最精简的上手路径,帮助你验证本文所讲的一切(详细示例见 react/README.md)。该包要求 React/React-DOM ≥ 18、Reveal.js ≥ 5(见 react/package.json 的 peerDependencies):
npm i @revealjs/react reveal.js react react-dom
# 或
yarn add @revealjs/react reveal.js react react-dom
随后引入核心样式与组件:
import { Deck, Slide } from '@revealjs/react';
import 'reveal.js/reveal.css';
import 'reveal.js/theme/black.css';
export function Presentation() {
return (
<Deck>
<Slide>
<h1>Hello</h1>
<p>My first Reveal deck in React.</p>
</Slide>
<Slide background="#111827">
<h2>Second slide</h2>
</Slide>
</Deck>
);
}
需要特别说明的三点约束:第一,包只提供 React 绑定,Reveal CSS、主题与插件(如 reveal.js/plugin/highlight 及 monokai.css)必须自行 import;第二,plugins 在初始化时一次性注册,运行期更新会被忽略;第三,Markdown 需要从 HTTP 服务器提供页面(src 加载失败时会渲染提示错误信息的 section),本地 file:// 协议下外部 markdown 无法通过 fetch 读取。
结语
从 AGENTS.md 的六条设计纪律到 react/src 的逐一实现,@revealjs/react 的核心哲学可以概括为三句话:生命周期归 Deck,属性映射归 Slide,结构同步按签名、单卡同步按属性、配置应用按浅比较。理解这套同步与配置模型,是安全修改封装行为、正确接入插件与事件、以及排查 StrictMode 下诡异双初始化问题的前提。改动任何行为前,请记住 react/AGENTS.md 的最终要求——同步更新同目录测试、同步更新 README、用聚焦回归测试守住边界。