Tiptap React 官方集成包演进全解:@tiptap/react 的 Decorations、组件化 API 与渲染性能实践
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
本文以仓库内 packages/react/CHANGELOG.md 为骨架,系统梳理@tiptap/react(当前版本 3.30.3)在 v3 时代引入的 Decorations 装饰 API、<Tiptap />声明式组件、React MarkView / Widget 渲染器、Floating UI 菜单迁移、SSR 渲染策略与 NodeView 性能优化等关键能力。读完你将掌握如何在 React 应用中用官方 React 绑定搭建编辑器、用组件渲染装饰层与行内标记视图,并理解immediatelyRender、trackNodeViewPosition、widgetkey等选项背后的设计动机与正确用法。
一、@tiptap/react 包概况与演进主线
@tiptap/react是 Tiptap 官方提供的 React 绑定层。从 package.json 可以看到它当前版本为 3.30.3,类型为 ESM 模块("type": "module"),同时通过exports字段暴露两个子入口:
.—— 主入口,包含编辑器组件、Hooks 与各种 Renderer;./menus—— 菜单入口(BubbleMenu/FloatingMenu),3.20.0 起被独立出来,以便把 floating-ui 保持为可选依赖。
其依赖仅@types/use-sync-external-store、fast-equals与use-sync-external-store,与@tiptap/core、@tiptap/pm以 workspace 形式保持同版本发布,peerDependencies 支持 React 17/18/19。
从 src/index.ts 可见包的完整导出面,包括EditorContent、NodeViewContent、NodeViewWrapper、ReactNodeViewRenderer、ReactRenderer、ReactMarkViewRenderer、ReactWidgetRenderer、Tiptap、useEditor、useEditorState、useReactNodeView等,并透传导出@tiptap/core的全部符号。该文件首行是'use client'指令——这是 3.27.4 引入的改动,让@tiptap/react可在 React Server Components 环境中被安全导入而不崩溃。
纵览 CHANGELOG 从 3.0.1 到 3.30.3 的演进,主线可以概括为四件事:
- 渲染层革命:引入 MarkView(标记自定义视图)、Widget(装饰组件)与新的 Decorations API,让“不改文档即可改外观”成为一等公民;
- 组件化声明式 API:从命令式
useEditor + <EditorContent>扩展出<Tiptap />复合组件; - 菜单系统底层替换:移除 tippy.js 全面迁往 Floating UI,并持续补全定位与 DOM 挂载能力;
- React 与 ProseMirror 双渲染循环的对齐工程:围绕 flushSync、portal、SSR/hydration 做了大量修复与性能优化。
下文按主题展开。
二、Decorations API:让扩展直接声明文档装饰(3.30.0)
Decorations(装饰)在 ProseMirror 中并不是新概念,但过去你必须手写一个 ProseMirror 插件、在插件 state 里维护装饰集合,并在每次事务发生时手动映射位置。3.30.0 把这件事收进了扩展层:扩展可以直接通过新的addDecorations()hook 声明装饰,框架会把所有扩展声明的装饰聚合到同一个插件里,因此多个扩展可以同时装饰同一份文档而互不打架。
CHANGELOG 给出的最小示例(节选自 CHANGELOG 第 3.30.0 节):
addDecorations() { return { create: ({ state }) => // findMatches 可以是任何返回 { from, to } 数组的函数 findMatches(state.doc).map(match => Decoration.Inline(match.from, match.to, { class: 'highlight' }), ), } }三种装饰类型对应不同渲染目标:
Decoration.Inline()—— 给一段文本区间加样式;Decoration.Node()—— 把属性放到某个块级节点的 DOM 元素上;Decoration.Widget()—— 在某个单一位置上渲染你自定义的元素。
典型用途包括:搜索结果高亮、拼写错误标记、协作者光标、以及“在每个块旁边放一个拖拽手柄”等。
减少每次按键的工作量
默认情况下,文档每次变化装饰都会重建。小文档无所谓,大文档却很浪费,因此 API 提供了两个“收窄范围”的手段:
shouldUpdate():跳过你不关心的事务。若你的装饰只依赖标题,就忽略其它一切变更;update: 'changedRanges'配合createInRange():只重新扫描真正发生变化的块。在长文档上,这相当于把“每次按键全量扫描”变成“每次按键只扫一个段落”。
如果装饰的数据来自编辑器之外(例如从服务器加载的评论),应使用update: 'manual',并自己通过editor.commands.updateDecorations()手动刷新。
Widget 选项中的细节
Widget 同时接受 ProseMirror 选项side、relaxedSide、stopEvent和ignoreSelection,可用于控制 widget 相对文档位置的吸附方向、是否拦截事件、是否计入选区等行为。这部分选项在ReactWidgetRenderer中会原样透传(见下节源码)。
CHANGELOG 版本发布节奏同时记录了 3.30.0 里若干相关的 React 修复:例如 React node view 在选区覆盖到其已移开的位置时不再错误地呈现选中态(31e176c)。
三、把真实组件渲染进 Widget 装饰:ReactWidgetRenderer
Decorations 的 React 绑定由ReactWidgetRenderer(以及 Vue 侧的VueWidgetRenderer)承担。它们把真实组件渲染进 widget 装饰,并且仍然处于你现有应用的 React 上下文内——Provider、Context、store 全部照常工作。这从 ReactWidgetRenderer.tsx 的实现可以看得很清楚:
- 组件经
new ReactRenderer(component, ...)挂载到编辑器的 React 树中(因此 hooks 与 context 可用); - 装饰物料的创建通过
createWidgetDecoration<ReactRenderer>完成; materialize阶段会重新调用renderer.render()并返回其 DOM 元素,以保证即便首次渲染较晚,编辑器内容组件可用后 portal 也能正确注册。
一个典型用法示例(源码注释中给出):
addDecorations() { return { create: ({ editor, state }) => findMatches(state.doc).map(match => ReactWidgetRenderer(MyWidget, { editor, pos: match.pos, key: `match-${match.id}`, props: { label: match.label }, }), ), } }key 是组件本地状态能否存活的开关
Widget 需要一个key。只要复用同一个 key,当文档在它周围变化时组件实例会保持挂载,这样诸如“打开的菜单”“计数器”“输入到一半的内容”这类本地状态在编辑操作后依然存活。CHANGELOG 特别强调:请使用你自己数据里的稳定 id(如comment-${id}),而不要用文档位置或列表下标作 key——否则组件会被卸载重挂,状态随之丢失。源码中ReactWidgetRendererOptions.key的注释也印证了这一点。
配套的包装参数包括as(包裹元素标签,默认'span',因 widget 通常是行内的)与className,它们只在渲染器首次创建时生效;相同key的后续渲染会保留最初的标签与 class,这也是组件实例被复用的另一面。
四、MarkView:用 React 组件渲染标记(3.0.1)
3.0.1(及其 beta 期)给 Tiptap 带来了对 ProseMirror MarkView 的支持:你可以为某类 mark 渲染自定义视图——例如为文字颜色 mark 渲染一个颜色选择器,或为链接 mark 渲染一个链接编辑器。这个能力在 React 侧通过ReactMarkViewRenderer接入。
纯 JS 的 MarkView 基线
Mark.create({ // Other options... addMarkView() { return ({ mark, HTMLAttributes }) => { const dom = document.createElement("b"); const contentDOM = document.createElement("span"); dom.appendChild(contentDOM); return { dom, contentDOM, }; }; }, });React 绑定:ReactMarkViewRenderer
import { Mark } from "@tiptap/core"; import { ReactMarkViewRenderer } from "@tiptap/react"; import Component from "./Component.jsx"; export default Mark.create({ name: "reactComponent", parseHTML() { return [ { tag: "react-component", }, ]; }, renderHTML({ HTMLAttributes }) { return ["react-component", HTMLAttributes]; }, addMarkView() { return ReactMarkViewRenderer(Component); }, });对应的 React 组件形如下方代码。这里的关键是<MarkViewContent />占位符:它把 ProseMirror 的 contentDOM 挂到组件树中你指定的位置,使标记内部的文本内容仍可正常编辑,而组件其余 UI(如按钮)则可以设为contentEditable={false}。CHANGELOG 与 源码目录 中可见MarkViewRendererProps类型与 props 注入约定。
import { MarkViewContent, MarkViewRendererProps } from "@tiptap/react"; import React from "react"; export default (props: MarkViewRendererProps) => { const [count, setCount] = React.useState(0); return ( <span className="content">import { Mark } from "@tiptap/core"; import { VueMarkViewRenderer } from "@tiptap/vue-3"; export default Mark.create({ name: "vueComponent", parseHTML() { return [{ tag: "vue-component" }]; }, renderHTML({ HTMLAttributes }) { return ["vue-component", HTMLAttributes]; }, addMarkView() { return VueMarkViewRenderer(Component); }, });Vue 组件模板中对应地使用<mark-view-content />承接内容、通过markViewProps接收 props。CHANGELOG 还记录了MarkViewContent的as可设为除<span>外的其它 HTML 标签(2ea0475),以及 3.5.2 修复 React MarkView 内容会被插入MarkViewContent中错误元素的问题。可继续在 src/ReactMarkViewRenderer.tsx 与仓库 GuideMarkViews 相关示例 中查看完整配套写法。
五、声明式组件化 API:Tiptap / useTiptap(3.18.0,3.20.0)
3.18.0 引入了一个可选的、更符合 React 习惯的集成方式——声明式<Tiptap />组件。官方称其为纯增量改动,旧的命令式写法在本大版本内继续支持,计划在下一大版本逐步弃用旧式设置。CHANGELOG 给出的示例:
import { Tiptap, useEditor } from "@tiptap/react"; function MyEditor() { const editor = useEditor({ extensions: [StarterKit], content: "<h1>Hello from Tiptap</h1>", }); return ( <Tiptap instance={editor}> <Tiptap.Content /> <Tiptap.BubbleMenu>My Bubble Menu</Tiptap.BubbleMenu> <Tiptap.FloatingMenu>My Floating Menu</Tiptap.FloatingMenu> <MenuBar /> {/* MenuBar 可用新的 useTiptap hook 从 context 读取 editor 实例 */} </Tiptap> ); }结合 src/Tiptap.tsx 的源码,这套组件化 API 的结构是:
<Tiptap>根 Provider:同时提供TiptapContext(新)与兼容旧版的EditorContext(旧useCurrentEditor()仍可用),并通过editor(推荐)或instance(3.27.2 起类型上保证二者必居其一,旧的instance标注为已弃用)接收编辑器实例;若未传入非空实例会直接抛错;<Tiptap.Content />:从 context 读取 editor 后渲染EditorContent,无需手动传 editor prop;useTiptap():读取 context 中的 editor 实例,供MenuBar之类子组件使用;useTiptapState():useEditorState的薄封装,自动使用 context 中的 editor。
从源码可推断,Tiptap实际是一个通过Object.assign组合了Content子组件的包装组件(displayName 分别为'Tiptap'与'Tiptap.Content')。3.20.0 同批还保证了由useTiptap拿到的 editor 实例非空,简化了类型体操。
六、Hooks:useEditor / useEditorState 与 SSR 策略
useEditor:编辑器实例的生命周期管理
src/useEditor.ts 中,useEditor依赖内部EditorInstanceManager完成创建、更新与销毁。其核心逻辑包括:
- 通过
useSyncExternalStore订阅实例变化,服务端快照永远返回null; - 回调型选项(
onCreate、onUpdate等)不参与选项比较,始终绑定最新闭包; extensions数组做长度与逐个引用的浅比较,以支持“在 options 里内联扩展数组”的常见写法;- 渲染期间若仅选项变化则调用
editor.setOptions()复用实例,只有 deps 变化或实例已销毁才重建; - 通过“推迟两个 tick 再销毁”的
scheduleDestroy机制避免 Strict Mode 下的重复挂载误杀实例。
immediatelyRender 与 SSR/hydration(3.23.2、3.23.5)
immediatelyRender是 SSR 场景下的关键选项,其默认值历史上发生过多次修正:
- 3.0.1时代在 SSR 模式下若未显式设置会抛错;
- 3.23.2改为“默认
true,但在检测到 SSR 时自动降为false”,开发模式下仅打警告不再抛错。CHANGELOG 说明此前省略该选项在 Next.js 等 SSR 环境下“开发模式抛错、生产模式静默返回 null”,是 AI 生成代码初始化编辑器时的常见崩溃源; - 3.23.5修正了客户端型 Next.js 应用:此前只要存在
window.next,即使显式传immediatelyRender: true也会被强制为false。新逻辑是仅在真正 SSR(typeof window === 'undefined')或“处于 Next.js 且未显式传值”时才强制关闭。
源码中对应实现为:isSSR = typeof window === 'undefined',isNext = isSSR || window.next,随后按上述规则在开发模式输出中文案警告。因此实践中:纯客户端渲染传immediatelyRender: true,SSR/Next.js 传false(或省略让 hook 自动探测)。
useEditorState:按需订阅编辑器状态
src/useEditorState.ts 实现了“选取部分编辑器状态、变化才重渲染”的订阅模型:它同时监听transaction与update事件(同一事务同时触发两者时去重),并通过useSyncExternalStoreWithSelector+ 默认的deepEqual(来自 fast-equals,3.12.0 起替换了不再维护的 fast-deep-equal)做值比较。典型用法:
const { currentSelection } = useEditorState({ editor, selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }), })需要留意的是它只监听transaction与update两个事件。3.29.0 的修复正源于此:editor.setEditable()只触发update而从不触发transaction,过去导致组件在可编辑状态切换时不重渲染,现已修复。
RSC 兼容(3.27.4)与事件绑定(3.28.0)
- 3.27.4:
'use client'指令让@tiptap/react可被 Server Components 导入而不崩溃;同时经@tiptap/react再导出的 core 符号也会跨越 client 边界,因此在服务端代码中应直接从@tiptap/core导入它们; - 3.28.0:
useEditor初始化编辑器时补绑了onMount/onUnmount事件处理器(1ecf814)。
七、菜单组件:Floating UI 迁移、入口拆分与定位能力
3.0.1:tippy.js → Floating UI(破坏性变更)
3.0.1 起移除了 tippy.js,改用更轻量、可定制性更强的 Floating UI,影响@tiptap/extension-floating-menu、@tiptap/extension-bubble-menu、@tiptap/extension-mention、@tiptap/suggestion、@tiptap/react、@tiptap/vue-2、@tiptap/vue-3。迁移要点:
- 移除
FloatingMenu/BubbleMenu组件上的tippyOptions,替换为新的options对象; - 需要自行安装 peer 依赖
@floating-ui/dom:
npm install @floating-ui/dom@^1.6.03.20.0:独立@tiptap/react/menus入口
为避免未使用菜单的打包体积被 floating-ui 拖累,3.20.0 把BubbleMenu/FloatingMenu移入@tiptap/react/menus子路径(3.19.0 曾先行发布同款变更)。该入口对应源码目录 packages/react/src/menus,包含BubbleMenu.tsx、FloatingMenu.tsx、getAutoPluginKey.ts、useMenuElementProps.ts等实现文件。
定位与 DOM 能力补全时间线
CHANGELOG 记录了一系列针对菜单的改进,可按版本速查:
| 版本 | 变更点 |
|---|---|
| 3.4.3 | BubbleMenu增加可选的getPosition定位回调,允许完全接管菜单坐标 |
| 3.5.1 | FloatingMenu支持appendTo,BubbleMenu在 React/Vue 2/Vue 3 中透传该 prop,用于规避裁剪与 z-index 问题 |
| 3.6.3 | ReactFloatingMenu的 hook 依赖与BubbleMenu对齐,能响应appendTo、pluginKey、shouldShow、options变化;BubbleMenu修复浮层选项 prop 变更后不更新的问题,并保证appendTo正确透传给底层插件 |
| 3.16.0 | FloatingMenu支持updateEvent,可通过setMeta('floatingMenu', 'updatePosition')编程式刷新定位 |
| 3.20.3 | BubbleMenu/FloatingMenu把className、style、data-*、事件处理器等 HTML props 转发到定位后的菜单容器;省略pluginKey时自动生成稳定的每实例插件 key,避免多实例互相冲突 |
对 React 侧而言,菜单组件的问题大多源于 React 渲染与 ProseMirror 插件的双轨生命周期:例如 3.9.0/3.8.0 两次发布均针对“组件每次重渲染都导致菜单插件重载”的回归进行修复。源码可在 packages/react/src/menus/BubbleMenu.tsx 与 packages/react/src/menus/FloatingMenu.tsx 查阅,仓库中亦有对应的 BubbleMenu.spec.ts 与 FloatingMenu.spec.ts 测试用例佐证这些行为。
八、React NodeView 渲染:让 React 与 ProseMirror 两个渲染循环对齐
NodeView(把节点渲染为 React 组件)是 React 绑定的核心难点,因为 ProseMirror 的 DOM 视图与 React 虚拟 DOM 并存,容易在更新顺序上互相错位。CHANGELOG 用大量 patch 记录了这场“对齐工程”,最有代表性的是 flushSync 的去而复返:
- beta 期(3.0.0-beta.20):从 NodeView 渲染中移除
flushSync,因为它造成性能回退,且被 PMViewDesc 检查时仍会意外 reconcile 未使用的 NodeView; - 3.0.1:重新引入
flushSync,用于同步 React 与 ProseMirror 的渲染(cce6497); - 3.22.2:修复
flushSync()在<EditorContent />生命周期中执行时报错的问题(8ab8bee)。
渲染性能方面随后持续收紧:
- 3.12.0:修复 React node view 在 ProseMirror 与 React 渲染周期失步时从
this.getPos()拿到非法位置、进而更新报错的问题(41601d1); - 3.22.1:NodeView 在节点位置变化(如同级节点在同一父级内被移动)但内容与装饰未变时不再错误地不重渲染(
ee03ac0);同时避免 ProseMirror 已摘除 node view 位置查找时、延迟选区更新阶段 React node view 崩溃(6f3b9fc); - 3.23.5:NodeView 在装饰或位置变化但内容未变时不再重渲染,并新增可选的
trackNodeViewPosition——开启后组件在每次位置移动时重渲染,从而保证渲染输出里调用的getPos()始终是最新值;同时删除内部nodeViewPositionRegistry,并在ReactRenderer.updateProps()中加入浅比较 props 以消除多余渲染; - 3.28.0:把同一微任务内批量产生的 React node view portal store 通知合并处理,规避大量 node view 同时挂载时的 nested update 警告(
8614730); - 3.29.1 / 3.29.2:分别修复“在 React NodeView 渲染的块内按 Enter 时光标跳回上一块”“拆分块后光标落点错误”等与选区/光标相关的回归;
- 3.30.3:修复
contentComponent不可用时ReactNodeViewRenderer崩溃的问题(1cb7ad3)。
渲染器的生命周期与清理
底层渲染器是 src/ReactRenderer.tsx,负责把 React 组件渲染进独立 portal 并保持与 ProseMirror DOM 同步。它的清理行为也是迭代重点:
- 3.3.0:
ReactRenderer.destroy()现在会在存在父节点时把自身.element从 DOM 中移除。此前很多 demo 把 renderer 的.element追加进document.body,destroy 只销毁 portal 却遗留.react-rendererDOM 节点,会累积泄漏; - 3.15.2:修复 Strict Mode 下已被销毁的 renderer 被重新加回的竞态问题。
NodeView 的选中态还有一个独立开关selectedOnTextSelection(3.22.5):开启后,当 TextSelection 完全落在节点区间内(而不只是 NodeSelection)时,selectedprop 也会为true。可配合 ReactNodeViewRenderer.spec.ts 中的测试理解其行为边界。
九、给你的升级核对清单
若要在项目中使用上述能力,可按 CHANGELOG 的破坏性变更做一次核对:
- 若从 v2 迁移:移除一切
tippyOptions,改用options对象,并npm install @floating-ui/dom@^1.6.0; - 若只用编辑器主体:从
@tiptap/react主入口导入useEditor与EditorContent; - 若使用 Bubble/Floating 菜单:从
@tiptap/react/menus导入,合理设置appendTo(容器裁剪/z-index 问题)并按需提供pluginKey; - SSR / Next.js 环境:显式传
immediatelyRender: false;纯客户端则传true;RSC 环境直接依赖'use client'指令即可安全导入; - 使用 NodeView:可开启
trackNodeViewPosition以在渲染输出中使用最新getPos(),否则优先把位置读取限制在事件回调内以减少重渲染; - 使用装饰/widget:通过
addDecorations()声明装饰,用shouldUpdate()或update: 'changedRanges'+createInRange()控制性能,widget 务必用稳定 id 作为key。
上述每一项都能在当前仓库找到实现与测试作为依据:核心 Renderer 与 Hooks 位于 packages/react/src,行为测试分布在 BubbleMenu.spec.ts、EditorContent.spec.ts、ReactNodeViewRenderer.spec.ts、ReactWidgetRenderer.spec.ts 与 useEditorState.spec.ts 等文件中,完整变更历史则逐条记录在 packages/react/CHANGELOG.md。
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考