1. 从零到一为什么选择 Tiptap 与 React 构建现代编辑器如果你正在开发一个需要富文本编辑功能的应用无论是知识库、博客后台还是协同文档你大概率已经厌倦了那些庞大、笨重且难以定制的传统编辑器。contentEditable的原生 API 就像一片雷区光标行为诡异、跨浏览器兼容性噩梦、撤销重做需要自己从头实现……这些问题足以让任何一个前端开发者头疼不已。正是在这种背景下像 Tiptap 这样的基于 Prosemirror 的 Headless 编辑器框架开始受到青睐。它不提供任何现成的 UI 组件只给你一套强大、稳定、可预测的底层编辑模型和一套完整的 API。这意味着编辑器长什么样、有哪些功能按钮、交互逻辑如何完全由你决定。而 React作为目前最流行的 UI 库其声明式、组件化的开发模式与 Tiptap 的 Headless 理念简直是天作之合。你可以用 React 的状态和生命周期来驱动 Tiptap 的实例用 React 组件来构建工具栏、菜单、浮动气泡实现真正意义上的深度集成与无缝交互。这种组合为我们构建一个高度定制化、性能优异且体验流畅的现代编辑器应用提供了完美的技术栈基础。2. 环境搭建与核心依赖解析开始之前我们需要一个 React 项目作为容器。无论是使用 Create React App、Vite 还是 Next.js都可以。这里以目前热门的 Vite React TypeScript 模板为例因为它启动快、配置简单非常适合现代前端开发。首先通过命令行创建一个新项目npm create vitelatest my-tiptap-app -- --template react-ts cd my-tiptap-app npm install接下来安装 Tiptap 及其相关核心包。Tiptap 的包结构非常清晰我们需要安装最基础的几个npm install tiptap/react tiptap/starter-kit tiptap/extension-placeholdertiptap/react这是连接 Tiptap 核心与 React 的桥梁包提供了useEditor等 React Hook让我们能在 React 组件中以声明式的方式创建和管理编辑器实例。tiptap/starter-kit这是一个功能合集包它包含了最常用的一系列扩展Extensions比如段落、标题、粗体、斜体、列表等。对于快速启动一个基础编辑器来说它是必不可少的。tiptap/extension-placeholder一个提供占位符提示功能的扩展。虽然非必需但它能极大提升用户体验建议一并安装。这里有一个关键点需要注意Tiptap 的核心引擎是 Prosemirror但tiptap/core已经将其封装在内我们通常不需要直接安装或操作 Prosemirror。tiptap/react包则进一步处理了 React 的渲染周期与编辑器实例生命周期的同步问题例如在组件卸载时自动销毁编辑器避免内存泄漏。安装完成后你的package.json的dependencies部分应该类似这样{ dependencies: { react: ^18.2.0, react-dom: ^18.2.0, tiptap/react: ^2.0.0, tiptap/starter-kit: ^2.0.0, tiptap/extension-placeholder: ^2.0.0 } }注意Tiptap v2 是一个重大更新版本其 API 设计更加现代化和一致。如果你在查阅旧教程请务必确认其使用的是 v2 版本因为 v1 和 v2 的 API 有较大差异。本文所有示例均基于 v2。3. 构建你的第一个 Tiptap React 编辑器组件理论准备就绪现在让我们动手创建一个最基础的编辑器组件。我们将这个组件命名为TiptapEditor.tsx。首先我们需要从安装的包中导入必要的模块。useEditor是核心 HookEditorContent是用于渲染编辑器内容的 React 组件。import { useEditor, EditorContent } from tiptap/react; import StarterKit from tiptap/starter-kit; import Placeholder from tiptap/extension-placeholder;接下来在组件函数内部我们使用useEditorHook 来初始化并配置编辑器实例。这个 Hook 接收一个配置对象并返回编辑器实例。const TiptapEditor () { const editor useEditor({ // 扩展Extensions是 Tiptap 的功能单元 extensions: [ StarterKit.configure({ // 可以在这里对 StarterKit 中的某个扩展进行微调 // 例如默认情况下 heading 允许 1-6 级我们可以限制为 1-3 级 heading: { levels: [1, 2, 3], }, }), // 添加占位符扩展 Placeholder.configure({ placeholder: 开始书写你的想法..., // 占位符可以只在第一段为空时显示 showOnlyWhenEditable: true, showOnlyCurrent: true, }), ], // 编辑器的初始内容可以是 HTML 字符串或 JSON 格式 content: p你好这是一个初始段落。/p, // 是否可编辑可用于实现“预览模式” editable: true, // 编辑器创建后立即自动聚焦 autofocus: true, }); // 一个简单的工具函数用于将编辑器内容以 HTML 格式打印到控制台 const handleLog () { if (editor) { console.log(editor.getHTML()); } }; return ( div classNameeditor-container {/* 这里是未来放置工具栏的区域 */} div classNamemenu-bar button onClick{() editor?.chain().focus().toggleBold().run()} disabled{!editor?.can().chain().focus().toggleBold().run()} 加粗 /button button onClick{() editor?.chain().focus().toggleItalic().run()} disabled{!editor?.can().chain().focus().toggleItalic().run()} 斜体 /button /div {/* EditorContent 组件负责渲染编辑器的可编辑区域 */} EditorContent editor{editor} classNameeditor-content / button onClick{handleLog} style{{ marginTop: 1rem }} 输出 HTML /button /div ); }; export default TiptapEditor;现在在你的主应用组件例如App.tsx中引入并使用这个TiptapEditor组件。运行npm run dev后你应该能在浏览器中看到一个具备基础格式加粗、斜体和占位符功能的编辑器了。点击“输出 HTML”按钮可以在控制台看到当前编辑器内容对应的 HTML 代码。这个基础组件揭示了几个核心概念Extensions扩展这是 Tiptap 的基石。每一个功能如加粗、列表、表格都是一个独立的扩展。StarterKit是一组常用扩展的打包。你可以按需添加或移除扩展实现功能的模块化。Editor Instance编辑器实例useEditor返回的editor对象是你的控制中心。通过它你可以获取内容、执行命令如格式化文本、监听事件、判断当前状态如是否可执行加粗命令。Commands命令Tiptap 的操作通过链式命令执行。例如editor.chain().focus().toggleBold().run()。chain()开始一个命令链focus()确保编辑器获得焦点toggleBold()是执行的具体命令run()是执行链的终点。can()方法用于在执行前判断该命令在当前选区/状态下是否可用这对于动态禁用工具栏按钮至关重要。4. 深度定制从工具栏到复杂扩展基础编辑器跑通了但它看起来还很简单。接下来我们将深入两个核心定制场景构建一个功能完整的工具栏以及集成更高级的扩展如图片上传和表格。4.1 构建一个状态驱动的 React 工具栏上面的例子中工具栏按钮是“死”的。一个专业的编辑器其工具栏按钮状态如是否高亮、是否禁用应该实时反映当前光标所在位置或所选文本的格式状态。这需要结合 React 的状态管理和 Tiptap 的事件监听。我们可以创建一个独立的MenuBar组件它接收editor实例作为 prop并内部管理各个按钮的状态。import { Editor } from tiptap/react; import { useState, useEffect } from react; interface MenuBarProps { editor: Editor | null; } const MenuBar ({ editor }: MenuBarProps) { // 状态用于跟踪各种格式是否激活 const [isBold, setIsBold] useState(false); const [isItalic, setIsItalic] useState(false); const [headingLevel, setHeadingLevel] useStatenumber | null(null); // 监听编辑器选区变化和事务更新按钮状态 useEffect(() { if (!editor) return; const updateState () { setIsBold(editor.isActive(bold)); setIsItalic(editor.isActive(italic)); // 检查当前是否是标题并获取级别 if (editor.isActive(heading)) { // 获取当前激活的标题级别例如 { level: 2 } const attrs editor.getAttributes(heading); setHeadingLevel(attrs.level); } else { setHeadingLevel(null); } }; // 监听文档和选区变化 editor.on(selectionUpdate, updateState); editor.on(transaction, updateState); // 初始化状态 updateState(); // 清理监听器 return () { editor.off(selectionUpdate, updateState); editor.off(transaction, updateState); }; }, [editor]); if (!editor) { return null; } return ( div classNamemenu-bar style{{ padding: 8px, borderBottom: 1px solid #ccc, display: flex, gap: 4px, flexWrap: wrap }} {/* 标题下拉菜单 */} select value{headingLevel || paragraph} onChange{(e) { const value e.target.value; if (value paragraph) { editor.chain().focus().setParagraph().run(); } else { editor.chain().focus().toggleHeading({ level: parseInt(value) }).run(); } }} option valueparagraph正文/option option value1标题 1/option option value2标题 2/option option value3标题 3/option /select {/* 加粗按钮 */} button onClick{() editor.chain().focus().toggleBold().run()} disabled{!editor.can().chain().focus().toggleBold().run()} style{{ fontWeight: isBold ? bold : normal }} title加粗 B /button {/* 斜体按钮 */} button onClick{() editor.chain().focus().toggleItalic().run()} disabled{!editor.can().chain().focus().toggleItalic().run()} style{{ fontStyle: isItalic ? italic : normal }} title斜体 I /button {/* 有序列表 */} button onClick{() editor.chain().focus().toggleOrderedList().run()} className{editor.isActive(orderedList) ? is-active : } title有序列表 1. /button {/* 无序列表 */} button onClick{() editor.chain().focus().toggleBulletList().run()} className{editor.isActive(bulletList) ? is-active : } title无序列表 • /button {/* 块引用 */} button onClick{() editor.chain().focus().toggleBlockquote().run()} className{editor.isActive(blockquote) ? is-active : } title引用 ❝ /button {/* 水平线 */} button onClick{() editor.chain().focus().setHorizontalRule().run()} title水平线 — /button {/* 撤销 */} button onClick{() editor.chain().focus().undo().run()} disabled{!editor.can().undo()} title撤销 ↩ /button {/* 重做 */} button onClick{() editor.chain().focus().redo().run()} disabled{!editor.can().redo()} title重做 ↪ /button /div ); };然后在主编辑器组件中使用这个MenuBarconst TiptapEditor () { const editor useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), Placeholder.configure({ placeholder: 开始书写... }), ], content: , }); return ( div classNameeditor-container MenuBar editor{editor} / EditorContent editor{editor} classNameeditor-content / /div ); };这个MenuBar组件展示了如何将编辑器的内部状态通过editor.isActive()和editor.getAttributes()与 React 的useState和useEffect绑定从而实现工具栏的响应式更新。editor.on(selectionUpdate, ...)和editor.on(transaction, ...)是监听编辑器状态变化的关键。4.2 集成图片上传与表格扩展StarterKit不包含图片和表格功能我们需要单独安装并配置它们的扩展。首先安装扩展包npm install tiptap/extension-image tiptap/extension-table tiptap/extension-table-row tiptap/extension-table-header tiptap/extension-table-cell然后更新编辑器配置添加这些扩展并为图片扩展实现自定义的上传逻辑。import Image from tiptap/extension-image; import Table from tiptap/extension-table; import TableRow from tiptap/extension-table-row; import TableHeader from tiptap/extension-table-header; import TableCell from tiptap/extension-table-cell; const TiptapEditor () { const editor useEditor({ extensions: [ StarterKit, Placeholder.configure({ placeholder: 开始书写... }), // 配置图片扩展支持自定义上传行为 Image.extend({ addOptions() { return { ...this.parent?.(), // 允许内联默认和块级 inline: true, // 允许图片作为 HTML 节点而非 Mark HTMLAttributes: {}, }; }, addProseMirrorPlugins() { return [ ...(this.parent?.() || []), // 这里可以添加自定义的 Prosemirror 插件例如粘贴图片上传 ]; }, }).configure({ // 允许设置宽度、高度等属性 HTMLAttributes: { class: tiptap-image, }, }), // 配置表格系列扩展 Table.configure({ resizable: true, // 启用列宽调整 HTMLAttributes: { class: tiptap-table, }, }), TableRow, TableHeader, TableCell, ], content: , }); // 图片上传处理函数 const handleImageUpload async (file: File) { if (!editor) return; // 1. 可选在插入位置先插入一个加载占位符 const placeholderId img-${Date.now()}; editor.chain().focus().setImage({ src: , data-id: placeholderId }).run(); // 2. 模拟上传过程实际应调用你的后端 API const formData new FormData(); formData.append(image, file); try { // 假设上传接口返回图片的最终 URL const response await fetch(/your-upload-api, { method: POST, body: formData }); const result await response.json(); const imageUrl result.url; // 3. 找到占位符节点并更新其 src 属性 // 注意这里简化了实际需要遍历文档节点或使用事务来精确替换 // 更常见的做法是上传前不插入占位符上传成功后直接插入最终图片 editor.chain().focus().setImage({ src: imageUrl }).run(); } catch (error) { console.error(上传失败:, error); // 4. 上传失败移除占位符或显示错误 editor.chain().focus().deleteSelection().run(); } }; // 在 MenuBar 中添加图片上传按钮和表格插入按钮 const addImage () { const input document.createElement(input); input.type file; input.accept image/*; input.onchange (event) { const file (event.target as HTMLInputElement).files?.[0]; if (file) { handleImageUpload(file); } }; input.click(); }; const addTable () { editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); }; // ... 在 MenuBar 的 JSX 中添加对应按钮 ... // button onClick{addImage}图片/button // button onClick{addTable}表格/button return ( /* ... */ ); };图片上传是一个需要前后端配合的复杂功能。上述代码展示了一个前端的基本流程触发文件选择、读取文件、调用上传 API、根据返回的 URL 插入图片到编辑器。在实际项目中你需要处理图片压缩、上传进度提示、错误重试、粘贴图片上传等更复杂的场景。表格扩展的集成相对直接安装并配置后就可以通过命令插入表格。resizable: true选项会为表格添加列宽调整功能这通常需要额外的 CSS 样式来支持拖拽交互。5. 状态管理与数据持久化策略编辑器内容需要被保存。Tiptap 编辑器内容可以以多种格式获取最常见的是 HTML 和 JSON。HTMLeditor.getHTML()返回一个 HTML 字符串适合直接存入数据库的TEXT字段或用于渲染预览。它简洁但丢失了部分语义信息比如是“加粗”还是“强语义”可能都是strong。JSONeditor.getJSON()返回一个符合 Prosemirror 文档结构的 JSON 对象。这个格式包含了完整的文档模型信息是“无损”的最适合用于编辑器的持久化存储因为你可以完美地通过content: yourJson来恢复编辑器状态。缺点是数据体积较大且是特定结构。在 React 应用中你通常会将编辑器内容无论是 HTML 还是 JSON纳入你的应用状态管理如 React 状态、Context、Redux、Zustand 等。一个常见的模式是使用onUpdate或onBlur回调来同步内容到状态。const TiptapEditor ({ onContentChange }: { onContentChange: (content: string) void }) { const [content, setContent] useState(); const editor useEditor({ extensions: [StarterKit], content: , // 编辑器内容每次变化时触发 onUpdate: ({ editor }) { const html editor.getHTML(); setContent(html); // 更新本地状态 onContentChange(html); // 回调给父组件 }, // 或者只在失去焦点时保存防抖减少频繁更新 // onBlur: ({ editor }) { // saveContent(editor.getHTML()); // }, }); // 从服务器初始化内容 useEffect(() { if (editor !editor.isDestroyed) { // 假设从 API 获取到初始 HTML fetchInitialContent().then(html { editor.commands.setContent(html); }); } }, [editor]); return EditorContent editor{editor} /; };对于协同编辑等高级场景JSON 格式是必须的因为它能精确描述文档结构的变化Operations。你可以结合onTransaction钩子来捕获每一次事务并将其发送到协同后端如 Yjs、ShareDB。6. 样式定制与主题化Tiptap 是 Headless 的所以样式完全由你控制。编辑器内容被渲染在一个具有特定类的容器内你可以通过 CSS 来定制所有元素的样式。首先给编辑器内容区域一个类名EditorContent editor{editor} classNametiptap-editor /然后编写 CSS。Tiptap 为内容中的各种节点和标记生成了特定的 CSS 类例如.ProseMirror是根容器h1,h2,strong,.is-empty等。/* 基础编辑器容器 */ .tiptap-editor { border: 1px solid #ddd; border-radius: 4px; padding: 1rem; min-height: 300px; outline: none; } /* 聚焦状态 */ .tiptap-editor:focus { border-color: #007bff; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } /* 占位符样式 */ .tiptap-editor p.is-empty:first-child::before { content: attr(data-placeholder); color: #adb5bd; pointer-events: none; height: 0; } /* 标题样式 */ .tiptap-editor h1 { font-size: 2.5em; margin-top: 0.67em; margin-bottom: 0.67em; } .tiptap-editor h2 { font-size: 2em; margin-top: 0.83em; margin-bottom: 0.83em; } /* 图片样式 */ .tiptap-editor img.tiptap-image { max-width: 100%; height: auto; border-radius: 4px; } /* 表格样式 */ .tiptap-editor table.tiptap-table { border-collapse: collapse; width: 100%; margin: 1em 0; } .tiptap-editor table.tiptap-table th, .tiptap-editor table.tiptap-table td { border: 1px solid #dee2e6; padding: 0.5rem; text-align: left; } .tiptap-editor table.tiptap-table th { background-color: #f8f9fa; font-weight: bold; } /* 块引用样式 */ .tiptap-editor blockquote { border-left: 3px solid #0d6efd; margin-left: 0; padding-left: 1rem; color: #6c757d; }你可以根据设计系统的要求深度定制这些样式甚至实现暗黑模式切换只需要动态切换附加在编辑器容器上的 CSS 类即可。7. 性能优化与高级特性探索当文档变得非常庞大例如数万字、包含大量图片和表格时性能可能成为问题。以下是一些优化思路按需加载扩展不是所有用户都需要所有功能。可以考虑动态导入code-splitting某些重型扩展如代码语法高亮tiptap/extension-code-block-lowlight或复杂的图表扩展。节流与防抖对于onUpdate这类频繁触发的事件如果每次变化都立即持久化到远程服务器会造成巨大压力。务必使用防抖debounce函数来限制调用频率。import { debounce } from lodash-es; const debouncedSave debounce((html: string) { saveToServer(html); }, 1000); // 延迟1秒保存 const editor useEditor({ onUpdate: ({ editor }) { debouncedSave(editor.getHTML()); }, });虚拟滚动对于超长文档渲染所有 DOM 节点会严重影响性能。可以考虑实现虚拟滚动只渲染视口附近的节点。但这需要深入操作 Prosemirror 的视图层实现难度较高。社区有一些实验性的方案但尚未成为主流。分离只读视图如果某个页面只需要展示内容不需要编辑就不要初始化完整的 Tiptap 编辑器。可以直接使用generateHTML函数将 JSON 内容渲染为 HTML或者使用一个轻量级的只读编辑器实例。import { generateHTML } from tiptap/html; import StarterKit from tiptap/starter-kit; const jsonContent { /* ... 从服务器获取的 JSON ... */ }; const outputHtml generateHTML(jsonContent, [StarterKit]); // 然后使用 dangerouslySetInnerHTML 或安全的 HTML 渲染库来显示 outputHtml在高级特性方面Tiptap 的扩展生态系统非常丰富。你可以探索协同编辑集成tiptap/extension-collaboration和 Yjs实现实时多人协作。代码高亮集成tiptap/extension-code-block-lowlight和lowlight库。数学公式集成tiptap/extension-katex。图表集成tiptap/extension-drawing或自定义节点来嵌入图表。提及与标签使用tiptap/extension-mention实现 提及功能。8. 常见问题排查与实战心得在实际集成过程中你肯定会遇到一些坑。以下是我总结的几个常见问题及解决方案问题一编辑器内容不更新或状态不同步这通常是因为 React 的严格模式StrictMode或编辑器实例在依赖项变化时被意外重建。确保useEditor的配置对象是稳定的或者使用useMemo来记忆化配置。另外在组件卸载时Tiptap 会自动调用editor.destroy()但如果你在外部手动管理了编辑器生命周期需要确保销毁逻辑正确。问题二自定义扩展或命令不生效首先检查扩展是否正确注册到了编辑器的extensions数组中。其次自定义命令需要在扩展的addCommands()方法中定义并返回一个函数。确保命令链以.run()结束。使用editor.can().someCommand()来调试命令是否在当前状态下可用。问题三粘贴内容格式混乱浏览器自带的粘贴行为会带入大量冗余样式。Tiptap 默认会进行一些清理但你可能需要配置StarterKit中的ClipboardTextSerializer扩展或者使用tiptap/extension-clean-output这类扩展来定义更严格的输出规则。在onCreate或onUpdate钩子中你也可以通过editor.getHTML()获取内容后用 DOM 解析器或正则表达式进行后处理。问题四与现有 UI 库如 Ant Design, MUI样式冲突Tiptap 渲染的 DOM 结构可能会受到全局 CSS 的影响。解决方案是提高编辑器内容样式的优先级或者将编辑器包裹在一个 Shadow DOM 中虽然这会带来新的复杂性。最实用的方法是为编辑器根容器设置一个特定的命名空间类如.my-tiptap-editor然后所有样式规则都从这个类开始写确保样式是局部的。个人心得从“能用”到“好用”的关键始终优先使用 JSON 存储即使你现在只需要 HTML也建议在后台同时存储一份 JSON。未来当你需要添加版本对比、协同编辑或更复杂的内容分析功能时你会感谢这个决定。工具栏状态管理是难点不要试图在几十个按钮的组件里为每个按钮写独立的useEffect和useState。考虑将工具栏状态集中管理例如使用一个自定义 HookuseEditorState(editor)它返回一个包含所有活动状态和可用命令的对象。图片处理是重头戏提前设计好图片上传、预览、错误处理、替换、删除的完整流程。考虑使用 CDN 和图片处理服务如 Cloudinary、Imgix来生成不同尺寸的图片并在编辑器中插入带有srcset的img标签以实现响应式。无障碍访问A11y不能忽视确保工具栏按钮有清晰的aria-label编辑器区域有合适的role和aria-describedby。这对于满足 WCAG 标准和提升用户体验至关重要。集成 Tiptap 与 React 是一个持续打磨的过程。开始时聚焦于核心功能然后根据产品需求逐步添加扩展和优化体验。它的 Headless 特性给了你极大的自由但也意味着你需要投入更多精力来构建和完善交互细节。最终当你拥有一个完全贴合产品气质、性能卓越且体验顺滑的编辑器时这一切的投入都是值得的。