news 2026/9/10 5:09:25

Tiptap React 官方集成包演进全解:@tiptap/react 的 Decorations、组件化 API 与渲染性能实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Tiptap React 官方集成包演进全解:@tiptap/react 的 Decorations、组件化 API 与渲染性能实践

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 绑定搭建编辑器、用组件渲染装饰层与行内标记视图,并理解immediatelyRendertrackNodeViewPosition、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-storefast-equalsuse-sync-external-store,与@tiptap/core@tiptap/pm以 workspace 形式保持同版本发布,peerDependencies 支持 React 17/18/19。

从 src/index.ts 可见包的完整导出面,包括EditorContentNodeViewContentNodeViewWrapperReactNodeViewRendererReactRendererReactMarkViewRendererReactWidgetRendererTiptapuseEditoruseEditorStateuseReactNodeView等,并透传导出@tiptap/core的全部符号。该文件首行是'use client'指令——这是 3.27.4 引入的改动,让@tiptap/react可在 React Server Components 环境中被安全导入而不崩溃。

纵览 CHANGELOG 从 3.0.1 到 3.30.3 的演进,主线可以概括为四件事:

  1. 渲染层革命:引入 MarkView(标记自定义视图)、Widget(装饰组件)与新的 Decorations API,让“不改文档即可改外观”成为一等公民;
  2. 组件化声明式 API:从命令式useEditor + <EditorContent>扩展出<Tiptap />复合组件;
  3. 菜单系统底层替换:移除 tippy.js 全面迁往 Floating UI,并持续补全定位与 DOM 挂载能力;
  4. 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 提供了两个“收窄范围”的手段:

  1. shouldUpdate():跳过你不关心的事务。若你的装饰只依赖标题,就忽略其它一切变更;
  2. update: 'changedRanges'配合createInRange():只重新扫描真正发生变化的块。在长文档上,这相当于把“每次按键全量扫描”变成“每次按键只扫一个段落”。

如果装饰的数据来自编辑器之外(例如从服务器加载的评论),应使用update: 'manual',并自己通过editor.commands.updateDecorations()手动刷新。

Widget 选项中的细节

Widget 同时接受 ProseMirror 选项siderelaxedSidestopEventignoreSelection,可用于控制 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 还记录了MarkViewContentas可设为除<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
  • 回调型选项(onCreateonUpdate等)不参与选项比较,始终绑定最新闭包;
  • 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 实现了“选取部分编辑器状态、变化才重渲染”的订阅模型:它同时监听transactionupdate事件(同一事务同时触发两者时去重),并通过useSyncExternalStoreWithSelector+ 默认的deepEqual(来自 fast-equals,3.12.0 起替换了不再维护的 fast-deep-equal)做值比较。典型用法:

const { currentSelection } = useEditorState({ editor, selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }), })

需要留意的是它只监听transactionupdate两个事件。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.0useEditor初始化编辑器时补绑了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.0

3.20.0:独立@tiptap/react/menus入口

为避免未使用菜单的打包体积被 floating-ui 拖累,3.20.0 把BubbleMenu/FloatingMenu移入@tiptap/react/menus子路径(3.19.0 曾先行发布同款变更)。该入口对应源码目录 packages/react/src/menus,包含BubbleMenu.tsxFloatingMenu.tsxgetAutoPluginKey.tsuseMenuElementProps.ts等实现文件。

定位与 DOM 能力补全时间线

CHANGELOG 记录了一系列针对菜单的改进,可按版本速查:

版本变更点
3.4.3BubbleMenu增加可选的getPosition定位回调,允许完全接管菜单坐标
3.5.1FloatingMenu支持appendToBubbleMenu在 React/Vue 2/Vue 3 中透传该 prop,用于规避裁剪与 z-index 问题
3.6.3ReactFloatingMenu的 hook 依赖与BubbleMenu对齐,能响应appendTopluginKeyshouldShowoptions变化;BubbleMenu修复浮层选项 prop 变更后不更新的问题,并保证appendTo正确透传给底层插件
3.16.0FloatingMenu支持updateEvent,可通过setMeta('floatingMenu', 'updatePosition')编程式刷新定位
3.20.3BubbleMenu/FloatingMenuclassNamestyledata-*、事件处理器等 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.0ReactRenderer.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 的破坏性变更做一次核对:

  1. 若从 v2 迁移:移除一切tippyOptions,改用options对象,并npm install @floating-ui/dom@^1.6.0
  2. 若只用编辑器主体:从@tiptap/react主入口导入useEditorEditorContent
  3. 若使用 Bubble/Floating 菜单:从@tiptap/react/menus导入,合理设置appendTo(容器裁剪/z-index 问题)并按需提供pluginKey
  4. SSR / Next.js 环境:显式传immediatelyRender: false;纯客户端则传true;RSC 环境直接依赖'use client'指令即可安全导入;
  5. 使用 NodeView:可开启trackNodeViewPosition以在渲染输出中使用最新getPos(),否则优先把位置读取限制在事件回调内以减少重渲染;
  6. 使用装饰/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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 5:08:11

Python程序员必备Linux命令:从开发到部署的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 5:07:44

SAP委外加工价格差异解析:OBYC科目配置与月结避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 5:05:15

OpenAI Compatible接口最小联调:从curl到400错误排查

前阵子帮同事排查一个联调问题&#xff0c;现象很简单&#xff1a;客户端把请求发过去&#xff0c;返回 400&#xff0c;报错信息里写着the reasoning_content in the thinking mode must be passed back to the api。这条报错把 OpenAI Compatible 接口联调里最容易忽略的细节…

作者头像 李华
网站建设 2026/9/10 5:03:53

Java后端学习Day4:从语法听懂到能写代码的破局之路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 5:03:21

DeepSeek Harness插件架构解析:从依赖注入到能力编排

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华