前言
系列教程和源码在飞书文档编写。
本章概述
在本章中,我们将深入学习如何在 React 项目中集成 Tiptap 编辑器。React 是目前最流行的前端框架之一,Tiptap 为 React 提供了专门的集成包和 Hooks,让我们能够以 React 的方式来使用编辑器。
学习目标:
- 在 React 项目中安装和配置 Tiptap
- 创建第一个可用的编辑器
- 理解 useEditor Hook 的配置选项
- 实现自动保存功能
- 使用 EditorProvider 构建完整编辑器
- 处理 Next.js 的 SSR 问题
- 掌握性能优化技巧
前置知识:
- React 基础(组件、Hooks、Props)
- TypeScript 基础(可选,但推荐)
- npm/pnpm 包管理器基础
预计学习时间: 90-120 分钟
本章变更概览
✨ 新增内容:
- Tiptap React 依赖包(3 个)
- 基础编辑器组件(3 个文件)
- 自动保存编辑器(1 个文件)
- EditorProvider 完整编辑器(4 个文件)
- Next.js SSR 版本(1 个文件)
🔄 修改内容:
- 无(本章为新建项目)
📁 涉及文件:
- 基础编辑器:
src/Tiptap.tsx、src/App.tsx、src/App.css - 自动保存:
src/components/AutoSaveEditor.tsx - EditorProvider:
src/components/MenuBar.tsx、src/components/StatusBar.tsx、src/components/FullEditor.tsx、src/components/FullEditor.css - Next.js:
app/editor/page.tsx
第一部分:基础入门
📚 本部分内容: 学习如何在 React 项目中安装和使用 Tiptap,创建第一个可用的编辑器。
1. 安装和配置
1.1 创建 React 项目(可选)
如果你还没有 React 项目,可以使用 Vite 快速创建:
# 使用 pnpm(推荐)
pnpm create vite my-tiptap-app --template react-ts
# 进入项目目录
cd my-tiptap-app
# 安装依赖
pnpm install
为什么选择 Vite?
- ⚡ 启动速度快(使用 ESBuild)
- 🔥 热更新快速
- 📦 开箱即用的 TypeScript 支持
- 🛠️ 现代化的构建工具
1.2 安装 Tiptap 依赖
Tiptap 在 React 中需要安装三个核心包:
pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit
包的作用说明:
| 包名 | 作用 | 说明 |
|---|---|---|
@tiptap/react | React 集成包 | 提供 useEditor、EditorProvider、EditorContent 等 |
@tiptap/pm | ProseMirror 核心 | Tiptap 的底层依赖,提供文档模型和状态管理 |
@tiptap/starter-kit | 常用扩展集合 | 包含 15+ 个常用扩展(Bold、Italic、Heading 等) |
💡 版本兼容性: Tiptap v3.x 需要 React 18+,如果使用 React 17,请安装 Tiptap v2.x
2. 第一个编辑器
2.1 实现步骤
步骤 1:创建编辑器组件
在 src 目录下创建 Tiptap.tsx 文件:
// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增
function Tiptap() { // ✨ 新增
const editor = useEditor({ // ✨ 新增
extensions: [StarterKit], // ✨ 新增
content: '<p>Hello World! 🌍</p>', // ✨ 新增
}) // ✨ 新增
return <EditorContent editor={editor} /> // ✨ 新增
} // ✨ 新增
export default Tiptap // ✨ 新增
代码详解:
-
导入必要的模块
useEditor: React Hook,用于创建和管理编辑器实例EditorContent: React 组件,用于渲染编辑器的可编辑区域StarterKit: 包含最常用扩展的集合
-
使用 useEditor Hook
extensions: 配置编辑器使用的扩展数组content: 编辑器的初始内容(支持 HTML 或 JSON)- 返回编辑器实例,首次渲染时可能为
null
-
渲染编辑器
EditorContent组件接收编辑器实例- 自动处理编辑器的渲染和更新
步骤 2:在 App 中使用
修改 src/App.tsx:
// src/App.tsx
import Tiptap from './Tiptap' // ✨ 新增
import './App.css'
function App() {
return (
<div className="app"> {/* ✨ 新增 */}
<h1>我的 Tiptap 编辑器</h1> {/* ✨ 新增 */}
<Tiptap /> {/* ✨ 新增 */}
</div> {/* ✨ 新增 */}
)
}
export default App
步骤 3:添加基础样式
创建或修改 src/App.css:
/* src/App.css */
/* ✨ 新增:应用容器样式 */
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
/* ✨ 新增:编辑器容器样式 */
.tiptap {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
min-height: 200px;
outline: none;
}
/* ✨ 新增:编辑器获得焦点时的样式 */
.tiptap:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* ✨ 新增:段落样式 */
.tiptap p {
margin: 0.5rem 0;
}
/* ✨ 新增:标题样式 */
.tiptap h1 {
font-size: 2rem;
margin: 1rem 0;
}
.tiptap h2 {
font-size: 1.5rem;
margin: 0.875rem 0;
}
.tiptap h3 {
font-size: 1.25rem;
margin: 0.75rem 0;
}
样式说明:
.tiptap是 Tiptap 自动添加的类名- 建议添加边框、内边距、最小高度等基础样式
- 焦点样式提供更好的用户体验
步骤 4:启动项目
pnpm dev
打开浏览器访问 http://localhost:5173,你应该能看到一个可编辑的文本区域。
测试功能:
- ✅ 输入文字
- ✅ 删除文字
- ✅ 换行(按 Enter)
- ✅ 撤销/重做(Ctrl+Z / Ctrl+Shift+Z)
2.2 完整源码
💡 说明: 以下是第一个编辑器的完整代码,可以直接复制使用。
📄 src/Tiptap.tsx
// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
function Tiptap() {
const editor = useEditor({
extensions: [
StarterKit,
],
content: '<p>Hello World! 🌍</p>',
})
return <EditorContent editor={editor} />
}
export default Tiptap
📄 src/App.tsx
// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'
function App() {
return (
<div className="app">
<h1>我的 Tiptap 编辑器</h1>
<Tiptap />
</div>
)
}
export default App
📄 src/App.css
/* src/App.css */
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.tiptap {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
min-height: 200px;
outline: none;
}
.tiptap:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.tiptap p {
margin: 0.5rem 0;
}
.tiptap h1 {
font-size: 2rem;
margin: 1rem 0;
}
.tiptap h2 {
font-size: 1.5rem;
margin: 0.875rem 0;
}
.tiptap h3 {
font-size: 1.25rem;
margin: 0.75rem 0;
}
第二部分:核心概念
📚 本部分内容: 深入理解 useEditor Hook 的配置选项和事件回调,为后续实战打下基础。
3. useEditor Hook 详解
useEditor 是 Tiptap React 集成的核心 Hook。让我们深入了解它的配置选项。
3.1 基本配置选项
const editor = useEditor({
// 必需:扩展配置
extensions: [StarterKit],
// 初始内容
content: '<p>初始内容</p>',
// 是否可编辑
editable: true,
// 是否自动获取焦点
autofocus: false,
// 是否立即渲染(SSR 相关)
immediatelyRender: true,
})
配置项详解:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
extensions | Extension[] | 必需 | 编辑器使用的扩展数组 |
content | string | JSONContent | '' | 初始内容(HTML 或 JSON) |
editable | boolean | true | 是否可编辑,可动态切换 |
autofocus | boolean | 'start' | 'end' | 'all' | number | false | 自动获取焦点的位置 |
immediatelyRender | boolean | true | 是否立即渲染(SSR 时设为 false) |
autofocus 选项说明:
false: 不自动获取焦点true或'start': 光标在开头'end': 光标在结尾'all': 选中所有内容number: 光标在指定位置
3.2 事件回调配置
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
// 内容创建时触发
onCreate: ({ editor }) => {
console.log('编辑器已创建', editor)
},
// 内容更新时触发
onUpdate: ({ editor }) => {
console.log('内容已更新', editor.getHTML())
},
// 选区变化时触发
onSelectionUpdate: ({ editor }) => {
console.log('选区已变化', editor.state.selection)
},
// 获得焦点时触发
onFocus: ({ editor, event }) => {
console.log('编辑器获得焦点', event)
},
// 失去焦点时触发
onBlur: ({ editor, event }) => {
console.log('编辑器失去焦点', event)
},
// 销毁时触发
onDestroy: () => {
console.log('编辑器已销毁')
},
})
事件回调详解:
| 事件 | 触发时机 | 用途 | 注意事项 |
|---|---|---|---|
onCreate | 编辑器初始化完成 | 初始化操作、日志记录 | 只触发一次 |
onUpdate | 每次内容变化 | 保存内容、同步状态 | 频繁触发,建议防抖 |
onSelectionUpdate | 光标或选区变化 | 更新工具栏状态 | 频繁触发 |
onFocus | 获得焦点 | 显示工具栏 | - |
onBlur | 失去焦点 | 隐藏工具栏、表单验证 | - |
onDestroy | 组件卸载 | 清理资源、保存数据 | 只触发一次 |
第三部分:进阶功能
🚀 本部分内容: 通过三个实战案例学习进阶功能,每个案例都是独立完整的。
4. 实战案例一:自动保存编辑器
4.1 功能介绍
自动保存是编辑器的重要功能,可以防止用户意外丢失内容。我们将实现:
- 内容变化时自动保存到 localStorage
- 使用防抖避免频繁保存
- 页面刷新后自动加载保存的内容
- 显示保存状态提示
4.2 实现步骤
步骤 1:创建自动保存组件
创建 src/components/AutoSaveEditor.tsx:
// src/components/AutoSaveEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增
import { useEffect, useRef } from 'react' // ✨ 新增
function AutoSaveEditor() { // ✨ 新增
const saveTimeoutRef = useRef<NodeJS.Timeout>() // ✨ 新增
const editor = useEditor({ // ✨ 新增
extensions: [StarterKit], // ✨ 新增
// ✨ 新增:从 localStorage 加载初始内容
content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>', // ✨ 新增
// ✨ 新增:内容更新时触发
onUpdate: ({ editor }) => { // ✨ 新增
// 清除之前的定时器
if (saveTimeoutRef.current) { // ✨ 新增
clearTimeout(saveTimeoutRef.current) // ✨ 新增
} // ✨ 新增
// 设置新的定时器,500ms 后保存
saveTimeoutRef.current = setTimeout(() => { // ✨ 新增
const html = editor.getHTML() // ✨ 新增
localStorage.setItem('editor-content', html) // ✨ 新增
console.log('内容已自动保存') // ✨ 新增
}, 500) // ✨ 新增
}, // ✨ 新增
}) // ✨ 新增
// ✨ 新增:组件卸载时清理定时器
useEffect(() => { // ✨ 新增
return () => { // ✨ 新增
if (saveTimeoutRef.current) { // ✨ 新增
clearTimeout(saveTimeoutRef.current) // ✨ 新增
} // ✨ 新增
} // ✨ 新增
}, []) // ✨ 新增
return ( // ✨ 新增
<div> // ✨ 新增
<div className="save-indicator"> // ✨ 新增
💾 自动保存已启用 // ✨ 新增
</div> // ✨ 新增
<EditorContent editor={editor} /> // ✨ 新增
</div> // ✨ 新增
) // ✨ 新增
} // ✨ 新增
export default AutoSaveEditor // ✨ 新增
代码详解:
-
使用 useRef 存储定时器
- 避免不必要的重渲染
- 存储定时器 ID 用于清除
-
防抖保存
- 每次内容变化时清除之前的定时器
- 设置新的定时器,500ms 后执行保存
- 避免频繁保存,提高性能
-
加载和保存内容
- 从 localStorage 加载初始内容
- 保存 HTML 格式的内容
-
清理副作用
- 组件卸载时清理定时器
- 避免内存泄漏
步骤 2:添加样式
在 src/App.css 中添加:
/* ✨ 新增:保存指示器样式 */
.save-indicator {
padding: 0.5rem 1rem;
background-color: #f0fdf4;
border: 1px solid #86efac;
border-radius: 4px;
margin-bottom: 1rem;
color: #166534;
font-size: 14px;
}
4.3 完整源码
💡 说明: 以下是自动保存编辑器的完整代码。
📄 src/components/AutoSaveEditor.tsx
// src/components/AutoSaveEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'
function AutoSaveEditor() {
const saveTimeoutRef = useRef<NodeJS.Timeout>()
const editor = useEditor({
extensions: [StarterKit],
content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>',
onUpdate: ({ editor }) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
saveTimeoutRef.current = setTimeout(() => {
const html = editor.getHTML()
localStorage.setItem('editor-content', html)
console.log('内容已自动保存')
}, 500)
},
})
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
return (
<div>
<div className="save-indicator">
💾 自动保存已启用
</div>
<EditorContent editor={editor} />
</div>
)
}
export default AutoSaveEditor
📄 src/App.css(新增部分)
/* 保存指示器样式 */
.save-indicator {
padding: 0.5rem 1rem;
background-color: #f0fdf4;
border: 1px solid #86efac;
border-radius: 4px;
margin-bottom: 1rem;
color: #166534;
font-size: 14px;
}
4.4 测试功能
在 src/App.tsx 中使用自动保存编辑器:
import AutoSaveEditor from './components/AutoSaveEditor'
function App() {
return (
<div className="app">
<h1>自动保存编辑器</h1>
<AutoSaveEditor />
</div>
)
}
测试步骤:
- 在编辑器中输入一些文字
- 等待 500ms,查看控制台是否显示"内容已自动保存"
- 刷新页面,内容应该保留
- 清空浏览器 localStorage,刷新页面应显示默认内容
继续下一部分...
5. 实战案例二:EditorProvider 和完整工具栏
5.1 为什么需要 EditorProvider
当你的应用有多个组件需要访问编辑器实例时,使用 EditorProvider 和 Context 是更好的选择。
问题场景: 工具栏组件和编辑器组件都需要访问编辑器实例。
传统方案(Props 传递):
// ❌ 不推荐:需要层层传递 editor
function App() {
const editor = useEditor({ ... })
return (
<div>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
)
}
问题:
- 需要手动传递
editorprop - 组件层级深时,需要层层传递
- 代码冗余,不易维护
EditorProvider 方案:
// ✅ 推荐:使用 Context 共享 editor
<EditorProvider extensions={[StarterKit]}>
<Toolbar />
<EditorContent />
</EditorProvider>
优势:
- 不需要手动传递
editorprop - 任何子组件都可以通过
useCurrentEditor访问编辑器 - 代码更简洁,更易维护
5.2 实现步骤
我们将创建一个完整的编辑器,包含工具栏、编辑区域和状态栏。
步骤 1:创建工具栏组件
创建 src/components/MenuBar.tsx:
// src/components/MenuBar.tsx
import { useCurrentEditor } from '@tiptap/react' // ✨ 新增
function MenuBar() { // ✨ 新增
const { editor } = useCurrentEditor() // ✨ 新增
if (!editor) return null // ✨ 新增
return ( // ✨ 新增
<div className="menu-bar"> // ✨ 新增
{/* ✨ 新增:文本格式按钮 */}
<div className="button-group"> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleBold().run()} // ✨ 新增
className={editor.isActive('bold') ? 'is-active' : ''} // ✨ 新增
title="加粗 (Ctrl+B)" // ✨ 新增
> // ✨ 新增
<strong>B</strong> // ✨ 新增
</button> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleItalic().run()} // ✨ 新增
className={editor.isActive('italic') ? 'is-active' : ''} // ✨ 新增
title="斜体 (Ctrl+I)" // ✨ 新增
> // ✨ 新增
<em>I</em> // ✨ 新增
</button> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleStrike().run()} // ✨ 新增
className={editor.isActive('strike') ? 'is-active' : ''} // ✨ 新增
> // ✨ 新增
<s>S</s> // ✨ 新增
</button> // ✨ 新增
</div> // ✨ 新增
{/* ✨ 新增:标题按钮 */}
<div className="button-group"> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} // ✨ 新增
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} // ✨ 新增
> // ✨ 新增
H1 // ✨ 新增
</button> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} // ✨ 新增
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} // ✨ 新增
> // ✨ 新增
H2 // ✨ 新增
</button> // ✨ 新增
</div> // ✨ 新增
{/* ✨ 新增:列表按钮 */}
<div className="button-group"> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleBulletList().run()} // ✨ 新增
className={editor.isActive('bulletList') ? 'is-active' : ''} // ✨ 新增
> // ✨ 新增
• 列表 // ✨ 新增
</button> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().toggleOrderedList().run()} // ✨ 新增
className={editor.isActive('orderedList') ? 'is-active' : ''} // ✨ 新增
> // ✨ 新增
1. 列表 // ✨ 新增
</button> // ✨ 新增
</div> // ✨ 新增
{/* ✨ 新增:撤销/重做 */}
<div className="button-group"> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().undo().run()} // ✨ 新增
disabled={!editor.can().undo()} // ✨ 新增
> // ✨ 新增
撤销 // ✨ 新增
</button> // ✨ 新增
<button // ✨ 新增
onClick={() => editor.chain().focus().redo().run()} // ✨ 新增
disabled={!editor.can().redo()} // ✨ 新增
> // ✨ 新增
重做 // ✨ 新增
</button> // ✨ 新增
</div> // ✨ 新增
</div> // ✨ 新增
) // ✨ 新增
} // ✨ 新增
export default MenuBar // ✨ 新增
代码详解:
-
useCurrentEditor Hook
- 从 Context 中获取编辑器实例
- 返回
{ editor }对象 editor可能为null,需要空值检查
-
Commands 链式调用
chain(): 开始链式调用focus(): 让编辑器获得焦点toggleBold(): 切换加粗状态run(): 执行命令链
-
检查激活状态
isActive('bold'): 检查是否应用了加粗- 用于高亮工具栏按钮
-
检查命令可用性
can().undo(): 检查是否可以撤销- 用于禁用按钮
步骤 2:创建状态栏组件
创建 src/components/StatusBar.tsx:
// src/components/StatusBar.tsx
import { useCurrentEditor } from '@tiptap/react' // ✨ 新增
function StatusBar() { // ✨ 新增
const { editor } = useCurrentEditor() // ✨ 新增
if (!editor) return null // ✨ 新增
// ✨ 新增:获取字符数
const characterCount = editor.state.doc.textContent.length // ✨ 新增
// ✨ 新增:获取单词数
const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0) // ✨ 新增
const wordCount = words.length // ✨ 新增
return ( // ✨ 新增
<div className="status-bar"> // ✨ 新增
<span>{characterCount} 字符</span> // ✨ 新增
<span>{wordCount} 单词</span> // ✨ 新增
</div> // ✨ 新增
) // ✨ 新增
} // ✨ 新增
export default StatusBar // ✨ 新增
步骤 3:创建完整编辑器组件
创建 src/components/FullEditor.tsx:
// src/components/FullEditor.tsx
import { EditorProvider, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增
import MenuBar from './MenuBar' // ✨ 新增
import StatusBar from './StatusBar' // ✨ 新增
import './FullEditor.css' // ✨ 新增
function FullEditor() { // ✨ 新增
return ( // ✨ 新增
<div className="full-editor"> // ✨ 新增
<EditorProvider // ✨ 新增
extensions={[StarterKit]} // ✨ 新增
content="<p>开始编辑你的文档...</p>" // ✨ 新增
> // ✨ 新增
<MenuBar /> // ✨ 新增
<EditorContent /> // ✨ 新增
<StatusBar /> // ✨ 新增
</EditorProvider> // ✨ 新增
</div> // ✨ 新增
) // ✨ 新增
} // ✨ 新增
export default FullEditor // ✨ 新增
步骤 4:添加样式
创建 src/components/FullEditor.css:
/* src/components/FullEditor.css */
/* ✨ 新增:编辑器容器 */
.full-editor {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
/* ✨ 新增:工具栏 */
.menu-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
background-color: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.button-group {
display: flex;
gap: 0.25rem;
}
.menu-bar button {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.menu-bar button:hover:not(:disabled) {
background-color: #f3f4f6;
}
.menu-bar button.is-active {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
}
.menu-bar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ✨ 新增:编辑器内容 */
.full-editor .tiptap {
padding: 1rem;
min-height: 300px;
outline: none;
}
/* ✨ 新增:状态栏 */
.status-bar {
display: flex;
gap: 1rem;
padding: 0.5rem 1rem;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}
5.3 完整源码
💡 说明: 以下是 EditorProvider 版本的完整代码。
📄 src/components/MenuBar.tsx
// src/components/MenuBar.tsx
import { useCurrentEditor } from '@tiptap/react'
function MenuBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
return (
<div className="menu-bar">
{/* 文本格式 */}
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="加粗 (Ctrl+B)"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="斜体 (Ctrl+I)"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
>
<s>S</s>
</button>
</div>
{/* 标题 */}
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
</div>
{/* 列表 */}
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
• 列表
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'is-active' : ''}
>
1. 列表
</button>
</div>
{/* 撤销/重做 */}
<div className="button-group">
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
撤销
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
重做
</button>
</div>
</div>
)
}
export default MenuBar
📄 src/components/StatusBar.tsx
// src/components/StatusBar.tsx
import { useCurrentEditor } from '@tiptap/react'
function StatusBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
const characterCount = editor.state.doc.textContent.length
const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0)
const wordCount = words.length
return (
<div className="status-bar">
<span>{characterCount} 字符</span>
<span>{wordCount} 单词</span>
</div>
)
}
export default StatusBar
📄 src/components/FullEditor.tsx
// src/components/FullEditor.tsx
import { EditorProvider, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import MenuBar from './MenuBar'
import StatusBar from './StatusBar'
import './FullEditor.css'
function FullEditor() {
return (
<div className="full-editor">
<EditorProvider
extensions={[StarterKit]}
content="<p>开始编辑你的文档...</p>"
>
<MenuBar />
<EditorContent />
<StatusBar />
</EditorProvider>
</div>
)
}
export default FullEditor
📄 src/components/FullEditor.css
/* src/components/FullEditor.css */
.full-editor {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.menu-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
background-color: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.button-group {
display: flex;
gap: 0.25rem;
}
.menu-bar button {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.menu-bar button:hover:not(:disabled) {
background-color: #f3f4f6;
}
.menu-bar button.is-active {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
}
.menu-bar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.full-editor .tiptap {
padding: 1rem;
min-height: 300px;
outline: none;
}
.status-bar {
display: flex;
gap: 1rem;
padding: 0.5rem 1rem;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}
5.4 测试功能
在 src/App.tsx 中使用完整编辑器:
import FullEditor from './components/FullEditor'
function App() {
return (
<div className="app">
<h1>完整编辑器</h1>
<FullEditor />
</div>
)
}
测试清单:
- ✅ 文本格式(加粗、斜体、删除线)
- ✅ 标题(H1、H2)
- ✅ 列表(无序、有序)
- ✅ 撤销/重做
- ✅ 字符数和单词数统计
- ✅ 工具栏按钮高亮状态
继续下一部分...
6. 实战案例三:Next.js SSR 处理
6.1 问题分析
Next.js 支持服务端渲染(SSR),但 Tiptap 依赖浏览器 API,需要特殊处理。
错误示例:
// ❌ 这段代码在 Next.js 中会报错
import { useEditor, EditorContent } from '@tiptap/react'
export default function Page() {
const editor = useEditor({ ... })
return <EditorContent editor={editor} />
}
// 错误:ReferenceError: document is not defined
原因:
- Next.js 在服务端执行代码时,没有
document对象 - Tiptap 在初始化时会访问
document - 导致服务端渲染失败
6.2 解决方案
使用 immediatelyRender: false 和条件渲染:
// ✅ 正确:使用 immediatelyRender
'use client' // Next.js App Router 需要添加
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'
export default function Page() {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
immediatelyRender: false, // 关键:延迟渲染
})
if (!isMounted) {
return <div>加载编辑器中...</div>
}
return <EditorContent editor={editor} />
}
关键点:
- 添加
'use client'指令(App Router) - 使用
immediatelyRender: false - 使用条件渲染检测客户端
- 提供加载占位符
6.3 完整源码
💡 说明: 以下是 Next.js 版本的完整代码。
📄 app/editor/page.tsx
// app/editor/page.tsx
'use client'
import { EditorProvider, EditorContent, useCurrentEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'
function MenuBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
return (
<div className="flex gap-2 p-2 border-b bg-gray-50">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`px-3 py-1 rounded transition-colors ${
editor.isActive('bold')
? 'bg-blue-500 text-white'
: 'bg-white hover:bg-gray-100'
}`}
>
加粗
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`px-3 py-1 rounded transition-colors ${
editor.isActive('italic')
? 'bg-blue-500 text-white'
: 'bg-white hover:bg-gray-100'
}`}
>
斜体
</button>
</div>
)
}
function Editor() {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
if (!isMounted) {
return (
<div className="p-4 text-center text-gray-500 border rounded-lg">
加载编辑器中...
</div>
)
}
return (
<EditorProvider
extensions={[StarterKit]}
content="<p>开始编辑...</p>"
immediatelyRender={false}
>
<div className="border rounded-lg overflow-hidden">
<MenuBar />
<EditorContent className="prose max-w-none p-4 min-h-[300px] focus:outline-none" />
</div>
</EditorProvider>
)
}
export default function EditorPage() {
return (
<div className="container mx-auto p-8 max-w-4xl">
<h1 className="text-3xl font-bold mb-4">Next.js 编辑器</h1>
<Editor />
</div>
)
}
6.4 测试功能
启动 Next.js 开发服务器:
npm run dev
访问 /editor 页面,测试:
- ✅ 页面正常加载
- ✅ 编辑器正常显示
- ✅ 加粗、斜体功能正常
- ✅ 刷新页面无报错
第四部分:优化和最佳实践
🎯 本部分内容: 学习性能优化技巧和最佳实践。
7. 性能优化
7.1 避免不必要的重渲染
问题: 编辑器每次更新都会触发父组件重渲染
// ❌ 不好的做法
function App() {
const [content, setContent] = useState('')
const editor = useEditor({
extensions: [StarterKit],
onUpdate: ({ editor }) => {
// 每次更新都会触发 setContent,导致重渲染
setContent(editor.getHTML())
},
})
return (
<div>
<EditorContent editor={editor} />
<div>内容长度: {content.length}</div>
</div>
)
}
解决方案: 使用 useRef
// ✅ 好的做法
function App() {
const contentRef = useRef('')
const [displayLength, setDisplayLength] = useState(0)
const editor = useEditor({
extensions: [StarterKit],
onUpdate: ({ editor }) => {
// 使用 ref 存储内容,不触发重渲染
contentRef.current = editor.getHTML()
// 使用防抖更新显示
debounce(() => {
setDisplayLength(contentRef.current.length)
}, 500)()
},
})
return (
<div>
<EditorContent editor={editor} />
<div>内容长度: {displayLength}</div>
</div>
)
}
7.2 使用 React.memo 优化子组件
// ✅ 使用 memo 避免不必要的重渲染
import { memo } from 'react'
const MenuBar = memo(function MenuBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
return (
<div className="menu-bar">
<button onClick={() => editor.chain().focus().toggleBold().run()}>
加粗
</button>
{/* 其他按钮 */}
</div>
)
})
export default MenuBar
第五部分:总结和练习
8. 测试功能
完整测试清单:
基础编辑:
- ✅ 输入文字
- ✅ 删除文字
- ✅ 换行
- ✅ 撤销/重做
文本格式:
- ✅ 加粗(Ctrl+B)
- ✅ 斜体(Ctrl+I)
- ✅ 删除线
标题:
- ✅ H1、H2、H3
- ✅ 段落
列表:
- ✅ 无序列表
- ✅ 有序列表
自动保存:
- ✅ 内容自动保存到 localStorage
- ✅ 刷新页面后内容保留
EditorProvider:
- ✅ 工具栏按钮正常工作
- ✅ 按钮高亮状态正确
- ✅ 字符数和单词数统计
Next.js SSR:
- ✅ 页面正常加载
- ✅ 无服务端渲染错误
9. 本章总结
在本章中,我们深入学习了 Tiptap 与 React 的集成:
核心知识点
✅ 安装和配置
- 安装
@tiptap/react、@tiptap/pm、@tiptap/starter-kit - 使用 Vite 创建 React 项目
✅ useEditor Hook
- 创建编辑器实例
- 配置扩展、内容、事件回调
- 理解配置选项和事件回调
✅ 实战案例
- 自动保存编辑器(防抖、localStorage)
- EditorProvider 和工具栏(Context、useCurrentEditor)
- Next.js SSR 处理(immediatelyRender、条件渲染)
✅ 性能优化
- 避免不必要的重渲染(useRef)
- 使用 React.memo 优化子组件
最佳实践
-
始终进行空值检查
if (!editor) return null -
使用 EditorProvider 共享状态
<EditorProvider extensions={[StarterKit]}> <MenuBar /> <EditorContent /> </EditorProvider> -
命令执行前获得焦点
editor.chain().focus().toggleBold().run() -
使用防抖处理频繁更新
onUpdate: debounce(({ editor }) => { // 保存逻辑 }, 500) -
Next.js 中使用 immediatelyRender
const editor = useEditor({ extensions: [StarterKit], immediatelyRender: false, })
关键 API
| API | 用途 | 示例 |
|---|---|---|
useEditor | 创建编辑器 | useEditor({ extensions: [StarterKit] }) |
EditorContent | 渲染编辑器 | <EditorContent editor={editor} /> |
EditorProvider | 提供 Context | <EditorProvider extensions={[StarterKit]}> |
useCurrentEditor | 获取编辑器 | const { editor } = useCurrentEditor() |
chain() | 链式调用 | editor.chain().focus().toggleBold().run() |
isActive() | 检查状态 | editor.isActive('bold') |
can() | 检查可用性 | editor.can().undo() |
下一章预告: 我们将学习 Vue、Angular、Svelte 等其他框架的集成方式。
10. 练习题
练习 1:创建字数统计编辑器
要求:
- 使用
EditorProvider - 实时显示字符数和单词数
- 超过 100 字时显示警告
💡 提示
使用 editor.state.doc.textContent 获取纯文本内容,然后计算长度。
练习 2:实现只读模式切换
要求:
- 添加一个切换按钮
- 点击按钮切换编辑器的可编辑状态
- 只读模式下工具栏禁用
💡 提示
使用 editor.setEditable(false) 切换只读模式。
练习 3:实现内容导出功能
要求:
- 添加导出按钮
- 支持导出 HTML 和 JSON 格式
- 使用浏览器下载 API
💡 提示
const html = editor.getHTML()
const json = editor.getJSON()
// 下载文件
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'document.html'
a.click()
11. 常见问题
Q1: 编辑器为什么是 null?
A: 首次渲染时,编辑器还未初始化。解决方案:
if (!editor) return null
// 或
editor?.chain().focus().toggleBold().run()
Q2: 如何动态更新编辑器内容?
A: 使用 setContent 方法:
useEffect(() => {
if (editor && content) {
editor.commands.setContent(content)
}
}, [editor, content])
Q3: 样式为什么不生效?
A: Tiptap 是无头编辑器,需要自己添加 CSS:
.tiptap {
padding: 1rem;
outline: none;
}
.tiptap p {
margin: 0.5rem 0;
}
Q4: 快捷键为什么不工作?
A: 确保编辑器有焦点:
editor.chain().focus().toggleBold().run()
Q5: 如何在 TypeScript 中使用?
A: 安装类型定义(通常已包含):
pnpm add -D @types/react
12. 扩展阅读
附录:本章所有源码文件
📦 说明: 以下是本章涉及的所有文件的完整代码,按照功能模块分类。
模块一:基础编辑器
📄 src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
function Tiptap() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World! 🌍</p>',
})
return <EditorContent editor={editor} />
}
export default Tiptap
📄 src/App.tsx
import Tiptap from './Tiptap'
import './App.css'
function App() {
return (
<div className="app">
<h1>我的 Tiptap 编辑器</h1>
<Tiptap />
</div>
)
}
export default App
📄 src/App.css
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.tiptap {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
min-height: 200px;
outline: none;
}
.tiptap:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.tiptap p {
margin: 0.5rem 0;
}
.tiptap h1 {
font-size: 2rem;
margin: 1rem 0;
}
.tiptap h2 {
font-size: 1.5rem;
margin: 0.875rem 0;
}
.tiptap h3 {
font-size: 1.25rem;
margin: 0.75rem 0;
}
模块二:自动保存编辑器
📄 src/components/AutoSaveEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'
function AutoSaveEditor() {
const saveTimeoutRef = useRef<NodeJS.Timeout>()
const editor = useEditor({
extensions: [StarterKit],
content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>',
onUpdate: ({ editor }) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
saveTimeoutRef.current = setTimeout(() => {
const html = editor.getHTML()
localStorage.setItem('editor-content', html)
console.log('内容已自动保存')
}, 500)
},
})
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
return (
<div>
<div className="save-indicator">
💾 自动保存已启用
</div>
<EditorContent editor={editor} />
</div>
)
}
export default AutoSaveEditor
📄 src/App.css(新增部分)
.save-indicator {
padding: 0.5rem 1rem;
background-color: #f0fdf4;
border: 1px solid #86efac;
border-radius: 4px;
margin-bottom: 1rem;
color: #166534;
font-size: 14px;
}
模块三:EditorProvider 版本
📄 src/components/MenuBar.tsx
import { useCurrentEditor } from '@tiptap/react'
function MenuBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
return (
<div className="menu-bar">
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="加粗 (Ctrl+B)"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="斜体 (Ctrl+I)"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
>
<s>S</s>
</button>
</div>
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
</div>
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
• 列表
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'is-active' : ''}
>
1. 列表
</button>
</div>
<div className="button-group">
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
撤销
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
重做
</button>
</div>
</div>
)
}
export default MenuBar
📄 src/components/StatusBar.tsx
import { useCurrentEditor } from '@tiptap/react'
function StatusBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
const characterCount = editor.state.doc.textContent.length
const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0)
const wordCount = words.length
return (
<div className="status-bar">
<span>{characterCount} 字符</span>
<span>{wordCount} 单词</span>
</div>
)
}
export default StatusBar
📄 src/components/FullEditor.tsx
import { EditorProvider, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import MenuBar from './MenuBar'
import StatusBar from './StatusBar'
import './FullEditor.css'
function FullEditor() {
return (
<div className="full-editor">
<EditorProvider
extensions={[StarterKit]}
content="<p>开始编辑你的文档...</p>"
>
<MenuBar />
<EditorContent />
<StatusBar />
</EditorProvider>
</div>
)
}
export default FullEditor
📄 src/components/FullEditor.css
.full-editor {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.menu-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
background-color: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.button-group {
display: flex;
gap: 0.25rem;
}
.menu-bar button {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.menu-bar button:hover:not(:disabled) {
background-color: #f3f4f6;
}
.menu-bar button.is-active {
background-color: #3b82f6;
color: white;
border-color: #3b82f6;
}
.menu-bar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.full-editor .tiptap {
padding: 1rem;
min-height: 300px;
outline: none;
}
.status-bar {
display: flex;
gap: 1rem;
padding: 0.5rem 1rem;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}
模块四:Next.js 版本
📄 app/editor/page.tsx
'use client'
import { EditorProvider, EditorContent, useCurrentEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'
function MenuBar() {
const { editor } = useCurrentEditor()
if (!editor) return null
return (
<div className="flex gap-2 p-2 border-b bg-gray-50">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`px-3 py-1 rounded transition-colors ${
editor.isActive('bold')
? 'bg-blue-500 text-white'
: 'bg-white hover:bg-gray-100'
}`}
>
加粗
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`px-3 py-1 rounded transition-colors ${
editor.isActive('italic')
? 'bg-blue-500 text-white'
: 'bg-white hover:bg-gray-100'
}`}
>
斜体
</button>
</div>
)
}
function Editor() {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
if (!isMounted) {
return (
<div className="p-4 text-center text-gray-500 border rounded-lg">
加载编辑器中...
</div>
)
}
return (
<EditorProvider
extensions={[StarterKit]}
content="<p>开始编辑...</p>"
immediatelyRender={false}
>
<div className="border rounded-lg overflow-hidden">
<MenuBar />
<EditorContent className="prose max-w-none p-4 min-h-[300px] focus:outline-none" />
</div>
</EditorProvider>
)
}
export default function EditorPage() {
return (
<div className="container mx-auto p-8 max-w-4xl">
<h1 className="text-3xl font-bold mb-4">Next.js 编辑器</h1>
<Editor />
</div>
)
}