前言
系列教程和源码在飞书文档编写
正在做可以写到简历的企业级AI前端和基建项目实战,欢迎一起来学习做项目
本章概述
在前面的章节中,我们已经学会了如何创建一个基本的 Tiptap 编辑器。本章将深入探讨编辑器实例(Editor Instance)的方方面面,包括:
- 编辑器实例的创建和初始化
- 编辑器的配置选项
- 编辑器的生命周期管理
- 编辑器的销毁和清理
- 多编辑器实例的管理
通过本章的学习,你将全面掌握编辑器实例的使用,为后续的高级功能打下坚实基础。
第一部分:编辑器实例基础
什么是编辑器实例
编辑器实例(Editor Instance)是 Tiptap 的核心对象,它包含了编辑器的所有状态、配置和方法。每个编辑器实例都是独立的,拥有自己的:
- 文档内容:编辑器中的所有内容
- 选区状态:光标位置和选中的文本
- 扩展配置:启用的扩展和它们的配置
- 命令方法:用于操作编辑器的命令
- 事件监听:监听编辑器的各种事件
编辑器实例的创建
在 React 中,我们使用 useEditor Hook 来创建编辑器实例:
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
})
编辑器实例的属性
编辑器实例提供了许多有用的属性:
// 检查编辑器是否可编辑
console.log(editor.isEditable) // true 或 false
// 检查编辑器是否为空
console.log(editor.isEmpty) // true 或 false
// 检查编辑器是否被销毁
console.log(editor.isDestroyed) // true 或 false
// 获取编辑器的 Schema
console.log(editor.schema)
// 获取编辑器的 State
console.log(editor.state)
// 获取编辑器的 View
console.log(editor.view)
编辑器实例的方法
编辑器实例提供了丰富的方法来操作编辑器:
// 设置内容
editor.commands.setContent('<p>New content</p>')
// 获取内容
const html = editor.getHTML()
const json = editor.getJSON()
const text = editor.getText()
// 聚焦编辑器
editor.commands.focus()
// 清空内容
editor.commands.clearContent()
// 销毁编辑器
editor.destroy()
第二部分:编辑器配置选项详解
核心配置选项
useEditor 接受一个配置对象,包含以下核心选项:
1. extensions(扩展)
指定编辑器使用的扩展:
const editor = useEditor({
extensions: [
StarterKit,
Image,
Link,
// 更多扩展...
],
})
2. content(初始内容)
设置编辑器的初始内容,支持 HTML、JSON 或纯文本:
// HTML 格式
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello <strong>World</strong>!</p>',
})
// JSON 格式
const editor = useEditor({
extensions: [StarterKit],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'World' },
{ type: 'text', text: '!' },
],
},
],
},
})
3. editable(可编辑性)
控制编辑器是否可编辑:
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
editable: true, // 默认为 true
})
// 动态切换可编辑性
editor.setEditable(false) // 设置为只读
editor.setEditable(true) // 设置为可编辑
4. autofocus(自动聚焦)
控制编辑器是否自动获得焦点:
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
autofocus: true, // 自动聚焦到编辑器开头
// autofocus: 'end', // 聚焦到编辑器末尾
// autofocus: 'all', // 选中所有内容
// autofocus: 10, // 聚焦到第 10 个字符位置
})
5. editorProps(编辑器属性)
传递给 ProseMirror 的属性:
const editor = useEditor({
extensions: [StarterKit],
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none',
spellcheck: 'false',
},
handleDrop: (view, event, slice, moved) => {
// 自定义拖放处理
return false
},
handlePaste: (view, event, slice) => {
// 自定义粘贴处理
return false
},
},
})
6. injectCSS(注入 CSS)
控制是否注入 Tiptap 的默认样式:
const editor = useEditor({
extensions: [StarterKit],
injectCSS: true, // 默认为 true
})
7. parseOptions(解析选项)
控制内容解析的行为:
const editor = useEditor({
extensions: [StarterKit],
parseOptions: {
preserveWhitespace: 'full', // 保留所有空白字符
},
})
事件回调配置
编辑器支持多种事件回调:
1. onCreate(创建时)
编辑器创建完成时触发:
const editor = useEditor({
extensions: [StarterKit],
onCreate: ({ editor }) => {
console.log('编辑器已创建', editor)
},
})
2. onUpdate(更新时)
编辑器内容更新时触发:
const editor = useEditor({
extensions: [StarterKit],
onUpdate: ({ editor }) => {
console.log('内容已更新', editor.getHTML())
},
})
3. onSelectionUpdate(选区更新时)
选区(光标位置)更新时触发:
const editor = useEditor({
extensions: [StarterKit],
onSelectionUpdate: ({ editor }) => {
console.log('选区已更新', editor.state.selection)
},
})
4. onTransaction(事务时)
每次事务(Transaction)发生时触发:
const editor = useEditor({
extensions: [StarterKit],
onTransaction: ({ editor, transaction }) => {
console.log('事务发生', transaction)
},
})
5. onFocus 和 onBlur(焦点事件)
编辑器获得或失去焦点时触发:
const editor = useEditor({
extensions: [StarterKit],
onFocus: ({ editor, event }) => {
console.log('编辑器获得焦点')
},
onBlur: ({ editor, event }) => {
console.log('编辑器失去焦点')
},
})
6. onDestroy(销毁时)
编辑器销毁时触发:
const editor = useEditor({
extensions: [StarterKit],
onDestroy: () => {
console.log('编辑器已销毁')
},
})
第三部分:进阶功能实战
案例一:配置完整的编辑器实例
让我们创建一个功能完整的编辑器,包含所有常用配置。
功能需求
- 支持多种文本格式
- 自动保存内容
- 显示字符计数
- 支持只读模式切换
- 完整的事件监听
实现步骤
步骤 1:安装依赖
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-character-count @tiptap/extension-placeholder
步骤 2:创建编辑器组件
📁 src/components/FullFeaturedEditor.tsx
// ✨ 导入必要的依赖
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import { useState, useEffect } from 'react'
// ✨ 定义组件 Props
interface FullFeaturedEditorProps {
initialContent?: string
onSave?: (content: string) => void
}
export default function FullFeaturedEditor({
initialContent = '',
onSave
}: FullFeaturedEditorProps) {
// ✨ 状态管理
const [isEditable, setIsEditable] = useState(true)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
// ✨ 创建编辑器实例
const editor = useEditor({
// 配置扩展
extensions: [
StarterKit.configure({
history: {
depth: 100, // 撤销/重做的历史深度
},
}),
CharacterCount.configure({
limit: 10000, // 字符数限制
}),
Placeholder.configure({
placeholder: '开始输入内容...',
}),
],
// 设置初始内容
content: initialContent,
// 设置可编辑性
editable: isEditable,
// 自动聚焦
autofocus: 'end',
// 编辑器属性
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg focus:outline-none min-h-[200px] p-4',
},
},
// 事件回调
onCreate: ({ editor }) => {
console.log('✅ 编辑器已创建')
},
onUpdate: ({ editor }) => {
setSaveStatus('unsaved')
// 自动保存(防抖)
const timer = setTimeout(() => {
handleSave(editor.getHTML())
}, 1000)
return () => clearTimeout(timer)
},
onFocus: () => {
console.log('📝 编辑器获得焦点')
},
onBlur: () => {
console.log('👋 编辑器失去焦点')
},
onDestroy: () => {
console.log('🗑️ 编辑器已销毁')
},
})
// ✨ 保存处理函数
const handleSave = async (content: string) => {
setSaveStatus('saving')
try {
await onSave?.(content)
setSaveStatus('saved')
} catch (error) {
console.error('保存失败:', error)
setSaveStatus('unsaved')
}
}
// ✨ 切换可编辑性
const toggleEditable = () => {
setIsEditable(!isEditable)
editor?.setEditable(!isEditable)
}
// ✨ 清空内容
const clearContent = () => {
if (confirm('确定要清空所有内容吗?')) {
editor?.commands.clearContent()
}
}
// ✨ 组件卸载时销毁编辑器
useEffect(() => {
return () => {
editor?.destroy()
}
}, [editor])
if (!editor) {
return <div>加载中...</div>
}
return (
<div className="border rounded-lg shadow-sm">
{/* 工具栏 */}
<div className="border-b p-2 flex items-center justify-between bg-gray-50">
<div className="flex gap-2">
<button
onClick={toggleEditable}
className="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600"
>
{isEditable ? '🔓 可编辑' : '🔒 只读'}
</button>
<button
onClick={clearContent}
className="px-3 py-1 rounded bg-red-500 text-white hover:bg-red-600"
disabled={!isEditable}
>
🗑️ 清空
</button>
</div>
{/* 状态信息 */}
<div className="flex items-center gap-4 text-sm text-gray-600">
{/* 保存状态 */}
<span>
{saveStatus === 'saved' && '✅ 已保存'}
{saveStatus === 'saving' && '⏳ 保存中...'}
{saveStatus === 'unsaved' && '⚠️ 未保存'}
</span>
{/* 字符计数 */}
<span>
{editor.storage.characterCount.characters()} / 10000 字符
</span>
{/* 编辑器状态 */}
<span>
{editor.isEmpty ? '📄 空文档' : '📝 有内容'}
</span>
</div>
</div>
{/* 编辑器内容区 */}
<EditorContent editor={editor} />
</div>
)
}
步骤 3:使用编辑器组件
📁 src/App.tsx
import FullFeaturedEditor from './components/FullFeaturedEditor'
export default function App() {
const handleSave = async (content: string) => {
// 模拟保存到服务器
await new Promise(resolve => setTimeout(resolve, 500))
console.log('内容已保存:', content)
}
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">完整功能编辑器</h1>
<FullFeaturedEditor
initialContent="<p>这是一个功能完整的编辑器示例</p>"
onSave={handleSave}
/>
</div>
)
}
完整源码
📁 src/components/FullFeaturedEditor.tsx(点击展开)
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import { useState, useEffect } from 'react'
interface FullFeaturedEditorProps {
initialContent?: string
onSave?: (content: string) => void
}
export default function FullFeaturedEditor({
initialContent = '',
onSave
}: FullFeaturedEditorProps) {
const [isEditable, setIsEditable] = useState(true)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
const editor = useEditor({
extensions: [
StarterKit.configure({
history: {
depth: 100,
},
}),
CharacterCount.configure({
limit: 10000,
}),
Placeholder.configure({
placeholder: '开始输入内容...',
}),
],
content: initialContent,
editable: isEditable,
autofocus: 'end',
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg focus:outline-none min-h-[200px] p-4',
},
},
onCreate: ({ editor }) => {
console.log('✅ 编辑器已创建')
},
onUpdate: ({ editor }) => {
setSaveStatus('unsaved')
const timer = setTimeout(() => {
handleSave(editor.getHTML())
}, 1000)
return () => clearTimeout(timer)
},
onFocus: () => {
console.log('📝 编辑器获得焦点')
},
onBlur: () => {
console.log('👋 编辑器失去焦点')
},
onDestroy: () => {
console.log('🗑️ 编辑器已销毁')
},
})
const handleSave = async (content: string) => {
setSaveStatus('saving')
try {
await onSave?.(content)
setSaveStatus('saved')
} catch (error) {
console.error('保存失败:', error)
setSaveStatus('unsaved')
}
}
const toggleEditable = () => {
setIsEditable(!isEditable)
editor?.setEditable(!isEditable)
}
const clearContent = () => {
if (confirm('确定要清空所有内容吗?')) {
editor?.commands.clearContent()
}
}
useEffect(() => {
return () => {
editor?.destroy()
}
}, [editor])
if (!editor) {
return <div>加载中...</div>
}
return (
<div className="border rounded-lg shadow-sm">
<div className="border-b p-2 flex items-center justify-between bg-gray-50">
<div className="flex gap-2">
<button
onClick={toggleEditable}
className="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600"
>
{isEditable ? '🔓 可编辑' : '🔒 只读'}
</button>
<button
onClick={clearContent}
className="px-3 py-1 rounded bg-red-500 text-white hover:bg-red-600"
disabled={!isEditable}
>
🗑️ 清空
</button>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>
{saveStatus === 'saved' && '✅ 已保存'}
{saveStatus === 'saving' && '⏳ 保存中...'}
{saveStatus === 'unsaved' && '⚠️ 未保存'}
</span>
<span>
{editor.storage.characterCount.characters()} / 10000 字符
</span>
<span>
{editor.isEmpty ? '📄 空文档' : '📝 有内容'}
</span>
</div>
</div>
<EditorContent editor={editor} />
</div>
)
}
测试功能
- 打开浏览器,访问应用
- 在编辑器中输入内容,观察字符计数和保存状态
- 点击"只读"按钮,尝试编辑(应该无法编辑)
- 点击"可编辑"按钮,恢复编辑功能
- 点击"清空"按钮,清空所有内容
- 打开浏览器控制台,观察事件日志
案例二:多编辑器实例管理
在某些场景下,我们需要在同一个页面中管理多个编辑器实例,例如:
- 评论系统(每条评论一个编辑器)
- 多文档编辑(同时编辑多个文档)
- 对比视图(并排显示两个编辑器)
功能需求
- 创建多个独立的编辑器实例
- 每个编辑器有独立的内容和状态
- 支持在编辑器之间复制内容
- 显示当前活动的编辑器
实现步骤
步骤 1:创建多编辑器组件
📁 src/components/MultiEditorManager.tsx
// ✨ 导入必要的依赖
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'
// ✨ 定义编辑器数据类型
interface EditorData {
id: string
title: string
content: string
}
// ✨ 单个编辑器组件
function SingleEditor({
data,
isActive,
onFocus,
onUpdate
}: {
data: EditorData
isActive: boolean
onFocus: () => void
onUpdate: (content: string) => void
}) {
const editor = useEditor({
extensions: [StarterKit],
content: data.content,
onUpdate: ({ editor }) => {
onUpdate(editor.getHTML())
},
onFocus: () => {
onFocus()
},
})
if (!editor) return null
return (
<div
className={`border rounded-lg p-4 ${
isActive ? 'ring-2 ring-blue-500' : ''
}`}
>
<h3 className="font-bold mb-2">{data.title}</h3>
<EditorContent editor={editor} className="prose prose-sm" />
<div className="mt-2 text-xs text-gray-500">
字符数: {editor.storage.characterCount?.characters() || 0}
</div>
</div>
)
}
// ✨ 多编辑器管理组件
export default function MultiEditorManager() {
// ✨ 编辑器列表状态
const [editors, setEditors] = useState<EditorData[]>([
{ id: '1', title: '文档 1', content: '<p>这是第一个编辑器</p>' },
{ id: '2', title: '文档 2', content: '<p>这是第二个编辑器</p>' },
{ id: '3', title: '文档 3', content: '<p>这是第三个编辑器</p>' },
])
// ✨ 当前活动的编辑器 ID
const [activeEditorId, setActiveEditorId] = useState<string>('1')
// ✨ 更新编辑器内容
const handleUpdate = (id: string, content: string) => {
setEditors(prev =>
prev.map(editor =>
editor.id === id ? { ...editor, content } : editor
)
)
}
// ✨ 添加新编辑器
const addEditor = () => {
const newId = String(editors.length + 1)
setEditors(prev => [
...prev,
{
id: newId,
title: `文档 ${newId}`,
content: '<p>新建文档</p>',
},
])
setActiveEditorId(newId)
}
// ✨ 删除编辑器
const removeEditor = (id: string) => {
if (editors.length <= 1) {
alert('至少保留一个编辑器')
return
}
setEditors(prev => prev.filter(editor => editor.id !== id))
if (activeEditorId === id) {
setActiveEditorId(editors[0].id)
}
}
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h2 className="text-xl font-bold">多编辑器管理</h2>
<p className="text-sm text-gray-600">
当前活动: {editors.find(e => e.id === activeEditorId)?.title}
</p>
</div>
<button
onClick={addEditor}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
➕ 添加编辑器
</button>
</div>
{/* 编辑器列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{editors.map(editor => (
<div key={editor.id} className="relative">
{/* 删除按钮 */}
<button
onClick={() => removeEditor(editor.id)}
className="absolute top-2 right-2 z-10 px-2 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
>
✕
</button>
{/* 编辑器 */}
<SingleEditor
data={editor}
isActive={activeEditorId === editor.id}
onFocus={() => setActiveEditorId(editor.id)}
onUpdate={(content) => handleUpdate(editor.id, content)}
/>
</div>
))}
</div>
{/* 统计信息 */}
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="font-bold mb-2">统计信息</h3>
<ul className="text-sm space-y-1">
<li>编辑器数量: {editors.length}</li>
<li>总字符数: {editors.reduce((sum, e) => sum + e.content.length, 0)}</li>
</ul>
</div>
</div>
)
}
步骤 2:使用多编辑器组件
📁 src/App.tsx
import MultiEditorManager from './components/MultiEditorManager'
export default function App() {
return (
<div className="container mx-auto p-8">
<MultiEditorManager />
</div>
)
}
完整源码
📁 src/components/MultiEditorManager.tsx(点击展开)
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'
interface EditorData {
id: string
title: string
content: string
}
function SingleEditor({
data,
isActive,
onFocus,
onUpdate
}: {
data: EditorData
isActive: boolean
onFocus: () => void
onUpdate: (content: string) => void
}) {
const editor = useEditor({
extensions: [StarterKit],
content: data.content,
onUpdate: ({ editor }) => {
onUpdate(editor.getHTML())
},
onFocus: () => {
onFocus()
},
})
if (!editor) return null
return (
<div
className={`border rounded-lg p-4 ${
isActive ? 'ring-2 ring-blue-500' : ''
}`}
>
<h3 className="font-bold mb-2">{data.title}</h3>
<EditorContent editor={editor} className="prose prose-sm" />
<div className="mt-2 text-xs text-gray-500">
字符数: {editor.storage.characterCount?.characters() || 0}
</div>
</div>
)
}
export default function MultiEditorManager() {
const [editors, setEditors] = useState<EditorData[]>([
{ id: '1', title: '文档 1', content: '<p>这是第一个编辑器</p>' },
{ id: '2', title: '文档 2', content: '<p>这是第二个编辑器</p>' },
{ id: '3', title: '文档 3', content: '<p>这是第三个编辑器</p>' },
])
const [activeEditorId, setActiveEditorId] = useState<string>('1')
const handleUpdate = (id: string, content: string) => {
setEditors(prev =>
prev.map(editor =>
editor.id === id ? { ...editor, content } : editor
)
)
}
const addEditor = () => {
const newId = String(editors.length + 1)
setEditors(prev => [
...prev,
{
id: newId,
title: `文档 ${newId}`,
content: '<p>新建文档</p>',
},
])
setActiveEditorId(newId)
}
const removeEditor = (id: string) => {
if (editors.length <= 1) {
alert('至少保留一个编辑器')
return
}
setEditors(prev => prev.filter(editor => editor.id !== id))
if (activeEditorId === id) {
setActiveEditorId(editors[0].id)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h2 className="text-xl font-bold">多编辑器管理</h2>
<p className="text-sm text-gray-600">
当前活动: {editors.find(e => e.id === activeEditorId)?.title}
</p>
</div>
<button
onClick={addEditor}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
➕ 添加编辑器
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{editors.map(editor => (
<div key={editor.id} className="relative">
<button
onClick={() => removeEditor(editor.id)}
className="absolute top-2 right-2 z-10 px-2 py-1 bg-red-500 text-white text-xs rounded hover:bg-red-600"
>
✕
</button>
<SingleEditor
data={editor}
isActive={activeEditorId === editor.id}
onFocus={() => setActiveEditorId(editor.id)}
onUpdate={(content) => handleUpdate(editor.id, content)}
/>
</div>
))}
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h3 className="font-bold mb-2">统计信息</h3>
<ul className="text-sm space-y-1">
<li>编辑器数量: {editors.length}</li>
<li>总字符数: {editors.reduce((sum, e) => sum + e.content.length, 0)}</li>
</ul>
</div>
</div>
)
}
测试功能
- 打开浏览器,查看三个并排的编辑器
- 点击任意编辑器,观察蓝色边框(表示活动状态)
- 在不同编辑器中输入内容,验证内容独立性
- 点击"添加编辑器"按钮,添加新编辑器
- 点击编辑器右上角的"✕"按钮,删除编辑器
- 观察底部的统计信息更新
案例三:编辑器生命周期管理
理解编辑器的生命周期对于避免内存泄漏和性能问题至关重要。
功能需求
- 正确初始化编辑器
- 监听生命周期事件
- 正确清理和销毁编辑器
- 处理组件重新渲染
实现步骤
步骤 1:创建生命周期演示组件
📁 src/components/EditorLifecycle.tsx
// ✨ 导入必要的依赖
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState, useEffect, useRef } from 'react'
export default function EditorLifecycle() {
// ✨ 状态管理
const [logs, setLogs] = useState<string[]>([])
const [showEditor, setShowEditor] = useState(true)
const [editorKey, setEditorKey] = useState(0)
const logRef = useRef<HTMLDivElement>(null)
// ✨ 添加日志
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString()
setLogs(prev => [...prev, `[${timestamp}] ${message}`])
}
// ✨ 创建编辑器实例
const editor = useEditor({
extensions: [StarterKit],
content: '<p>观察编辑器的生命周期</p>',
// 生命周期:创建
onCreate: ({ editor }) => {
addLog('✅ onCreate: 编辑器已创建')
console.log('编辑器实例:', editor)
},
// 生命周期:更新
onUpdate: ({ editor }) => {
addLog('📝 onUpdate: 内容已更新')
},
// 生命周期:选区更新
onSelectionUpdate: ({ editor }) => {
addLog('🎯 onSelectionUpdate: 选区已更新')
},
// 生命周期:事务
onTransaction: ({ editor, transaction }) => {
// 注意:这个事件触发非常频繁,建议谨慎使用
// addLog('⚡ onTransaction: 事务发生')
},
// 生命周期:获得焦点
onFocus: ({ editor, event }) => {
addLog('👁️ onFocus: 编辑器获得焦点')
},
// 生命周期:失去焦点
onBlur: ({ editor, event }) => {
addLog('👋 onBlur: 编辑器失去焦点')
},
// 生命周期:销毁
onDestroy: () => {
addLog('🗑️ onDestroy: 编辑器已销毁')
},
}, [editorKey]) // 依赖 editorKey,用于重新创建编辑器
// ✨ 组件卸载时清理
useEffect(() => {
addLog('🎬 组件已挂载')
return () => {
addLog('🎬 组件即将卸载')
// useEditor 会自动处理销毁,但我们可以手动调用
editor?.destroy()
}
}, [])
// ✨ 自动滚动日志到底部
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight
}
}, [logs])
// ✨ 切换编辑器显示
const toggleEditor = () => {
setShowEditor(!showEditor)
if (!showEditor) {
addLog('👁️ 显示编辑器')
} else {
addLog('🙈 隐藏编辑器')
}
}
// ✨ 重新创建编辑器
const recreateEditor = () => {
addLog('🔄 重新创建编辑器')
setEditorKey(prev => prev + 1)
}
// ✨ 清空日志
const clearLogs = () => {
setLogs([])
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 左侧:编辑器 */}
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={toggleEditor}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{showEditor ? '🙈 隐藏编辑器' : '👁️ 显示编辑器'}
</button>
<button
onClick={recreateEditor}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
🔄 重新创建
</button>
</div>
{showEditor && editor && (
<div className="border rounded-lg p-4">
<h3 className="font-bold mb-2">编辑器</h3>
<EditorContent editor={editor} className="prose prose-sm" />
</div>
)}
{!showEditor && (
<div className="border rounded-lg p-4 text-center text-gray-500">
编辑器已隐藏
</div>
)}
</div>
{/* 右侧:日志 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold">生命周期日志</h3>
<button
onClick={clearLogs}
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600"
>
清空日志
</button>
</div>
<div
ref={logRef}
className="border rounded-lg p-4 h-96 overflow-y-auto bg-gray-50 font-mono text-sm"
>
{logs.length === 0 ? (
<div className="text-gray-400">暂无日志</div>
) : (
logs.map((log, index) => (
<div key={index} className="mb-1">
{log}
</div>
))
)}
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>💡 提示:</p>
<ul className="list-disc list-inside space-y-1">
<li>在编辑器中输入内容,观察 onUpdate 事件</li>
<li>点击编辑器内外,观察 onFocus 和 onBlur 事件</li>
<li>选择文本,观察 onSelectionUpdate 事件</li>
<li>点击"隐藏编辑器",观察销毁过程</li>
<li>点击"重新创建",观察创建过程</li>
</ul>
</div>
</div>
</div>
)
}
步骤 2:使用生命周期组件
📁 src/App.tsx
import EditorLifecycle from './components/EditorLifecycle'
export default function App() {
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">编辑器生命周期</h1>
<EditorLifecycle />
</div>
)
}
完整源码
📁 src/components/EditorLifecycle.tsx(点击展开)
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState, useEffect, useRef } from 'react'
export default function EditorLifecycle() {
const [logs, setLogs] = useState<string[]>([])
const [showEditor, setShowEditor] = useState(true)
const [editorKey, setEditorKey] = useState(0)
const logRef = useRef<HTMLDivElement>(null)
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString()
setLogs(prev => [...prev, `[${timestamp}] ${message}`])
}
const editor = useEditor({
extensions: [StarterKit],
content: '<p>观察编辑器的生命周期</p>',
onCreate: ({ editor }) => {
addLog('✅ onCreate: 编辑器已创建')
console.log('编辑器实例:', editor)
},
onUpdate: ({ editor }) => {
addLog('📝 onUpdate: 内容已更新')
},
onSelectionUpdate: ({ editor }) => {
addLog('🎯 onSelectionUpdate: 选区已更新')
},
onFocus: ({ editor, event }) => {
addLog('👁️ onFocus: 编辑器获得焦点')
},
onBlur: ({ editor, event }) => {
addLog('👋 onBlur: 编辑器失去焦点')
},
onDestroy: () => {
addLog('🗑️ onDestroy: 编辑器已销毁')
},
}, [editorKey])
useEffect(() => {
addLog('🎬 组件已挂载')
return () => {
addLog('🎬 组件即将卸载')
editor?.destroy()
}
}, [])
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight
}
}, [logs])
const toggleEditor = () => {
setShowEditor(!showEditor)
if (!showEditor) {
addLog('👁️ 显示编辑器')
} else {
addLog('🙈 隐藏编辑器')
}
}
const recreateEditor = () => {
addLog('🔄 重新创建编辑器')
setEditorKey(prev => prev + 1)
}
const clearLogs = () => {
setLogs([])
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={toggleEditor}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{showEditor ? '🙈 隐藏编辑器' : '👁️ 显示编辑器'}
</button>
<button
onClick={recreateEditor}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
🔄 重新创建
</button>
</div>
{showEditor && editor && (
<div className="border rounded-lg p-4">
<h3 className="font-bold mb-2">编辑器</h3>
<EditorContent editor={editor} className="prose prose-sm" />
</div>
)}
{!showEditor && (
<div className="border rounded-lg p-4 text-center text-gray-500">
编辑器已隐藏
</div>
)}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold">生命周期日志</h3>
<button
onClick={clearLogs}
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600"
>
清空日志
</button>
</div>
<div
ref={logRef}
className="border rounded-lg p-4 h-96 overflow-y-auto bg-gray-50 font-mono text-sm"
>
{logs.length === 0 ? (
<div className="text-gray-400">暂无日志</div>
) : (
logs.map((log, index) => (
<div key={index} className="mb-1">
{log}
</div>
))
)}
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>💡 提示:</p>
<ul className="list-disc list-inside space-y-1">
<li>在编辑器中输入内容,观察 onUpdate 事件</li>
<li>点击编辑器内外,观察 onFocus 和 onBlur 事件</li>
<li>选择文本,观察 onSelectionUpdate 事件</li>
<li>点击"隐藏编辑器",观察销毁过程</li>
<li>点击"重新创建",观察创建过程</li>
</ul>
</div>
</div>
</div>
)
}
测试功能
- 打开浏览器,观察右侧日志面板
- 在编辑器中输入内容,观察 onUpdate 事件
- 点击编辑器内外,观察 onFocus 和 onBlur 事件
- 选择文本,观察 onSelectionUpdate 事件
- 点击"隐藏编辑器",观察 onDestroy 事件
- 点击"显示编辑器",观察 onCreate 事件
- 点击"重新创建",观察完整的销毁和创建过程
第四部分:优化和最佳实践
1. 避免内存泄漏
问题
如果不正确销毁编辑器,会导致内存泄漏。
解决方案
import { useEditor } from '@tiptap/react'
import { useEffect } from 'react'
function MyEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
})
// ✅ 正确:在组件卸载时销毁编辑器
useEffect(() => {
return () => {
editor?.destroy()
}
}, [editor])
return <EditorContent editor={editor} />
}
2. 优化重新渲染
问题
编辑器配置对象在每次渲染时都会重新创建,导致不必要的重新渲染。
解决方案
import { useEditor } from '@tiptap/react'
import { useMemo, useCallback } from 'react'
function MyEditor() {
// ✅ 使用 useCallback 缓存事件处理函数
const handleUpdate = useCallback(({ editor }) => {
console.log('内容更新:', editor.getHTML())
}, [])
// ✅ 使用 useMemo 缓存扩展配置
const extensions = useMemo(() => [
StarterKit.configure({
history: {
depth: 100,
},
}),
], [])
const editor = useEditor({
extensions,
content: '<p>Hello World!</p>',
onUpdate: handleUpdate,
})
return <EditorContent editor={editor} />
}
3. 处理异步初始化
问题
编辑器实例可能在初始渲染时为 null。
解决方案
function MyEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
})
// ✅ 正确:检查编辑器是否存在
if (!editor) {
return <div>加载中...</div>
}
return (
<div>
<button onClick={() => editor.commands.toggleBold()}>
加粗
</button>
<EditorContent editor={editor} />
</div>
)
}
4. 动态更新配置
问题
如何在运行时动态更新编辑器配置?
解决方案
function MyEditor() {
const [isEditable, setIsEditable] = useState(true)
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
editable: isEditable,
})
// ✅ 使用 useEffect 同步状态
useEffect(() => {
if (editor) {
editor.setEditable(isEditable)
}
}, [editor, isEditable])
return (
<div>
<button onClick={() => setIsEditable(!isEditable)}>
切换可编辑性
</button>
<EditorContent editor={editor} />
</div>
)
}
5. 多编辑器实例的性能优化
问题
页面中有多个编辑器实例时,性能可能下降。
解决方案
// ✅ 使用 React.memo 避免不必要的重新渲染
const SingleEditor = React.memo(({
content,
onChange
}: {
content: string
onChange: (content: string) => void
}) => {
const editor = useEditor({
extensions: [StarterKit],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
})
useEffect(() => {
return () => {
editor?.destroy()
}
}, [editor])
if (!editor) return null
return <EditorContent editor={editor} />
})
// ✅ 使用虚拟滚动(对于大量编辑器)
import { FixedSizeList } from 'react-window'
function ManyEditors({ editors }: { editors: EditorData[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<SingleEditor
content={editors[index].content}
onChange={(content) => handleUpdate(index, content)}
/>
</div>
)
return (
<FixedSizeList
height={600}
itemCount={editors.length}
itemSize={200}
width="100%"
>
{Row}
</FixedSizeList>
)
}
6. 错误处理
问题
编辑器初始化或操作可能失败。
解决方案
function MyEditor() {
const [error, setError] = useState<string | null>(null)
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
onCreate: ({ editor }) => {
try {
// 初始化逻辑
console.log('编辑器已创建')
} catch (err) {
setError('编辑器初始化失败')
console.error(err)
}
},
onUpdate: ({ editor }) => {
try {
// 更新逻辑
const content = editor.getHTML()
} catch (err) {
setError('内容更新失败')
console.error(err)
}
},
})
if (error) {
return <div className="text-red-500">错误: {error}</div>
}
if (!editor) {
return <div>加载中...</div>
}
return <EditorContent editor={editor} />
}
第五部分:总结和练习
本章总结
在本章中,我们深入学习了 Tiptap 编辑器实例的方方面面:
-
编辑器实例基础
- 理解编辑器实例的概念和作用
- 掌握编辑器实例的属性和方法
- 学会创建和使用编辑器实例
-
编辑器配置选项
- 核心配置:extensions、content、editable、autofocus 等
- 事件回调:onCreate、onUpdate、onFocus、onBlur 等
- 高级配置:editorProps、parseOptions 等
-
进阶功能实战
- 案例一:配置完整的编辑器实例(自动保存、字符计数、只读模式)
- 案例二:多编辑器实例管理(动态添加/删除、独立状态)
- 案例三:编辑器生命周期管理(监听事件、正确清理)
-
优化和最佳实践
- 避免内存泄漏
- 优化重新渲染
- 处理异步初始化
- 动态更新配置
- 多编辑器性能优化
- 错误处理
关键要点
- ✅ 编辑器实例是 Tiptap 的核心对象,包含所有状态和方法
- ✅ 使用
useEditorHook 创建编辑器实例 - ✅ 编辑器支持丰富的配置选项和事件回调
- ✅ 必须在组件卸载时正确销毁编辑器,避免内存泄漏
- ✅ 使用
useMemo和useCallback优化性能 - ✅ 多编辑器实例需要独立管理,避免状态混乱
- ✅ 理解编辑器生命周期,正确处理各个阶段
练习题
练习 1:创建带主题切换的编辑器
创建一个编辑器,支持亮色/暗色主题切换,要求:
- 使用
editorProps动态设置 class - 主题切换时编辑器样式随之改变
- 保持编辑器内容不变
💡 提示
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const editor = useEditor({
extensions: [StarterKit],
editorProps: {
attributes: {
class: theme === 'light'
? 'prose prose-slate'
: 'prose prose-invert bg-gray-900 text-white',
},
},
})
useEffect(() => {
if (editor) {
editor.view.updateState(editor.state)
}
}, [theme, editor])
练习 2:实现编辑器状态持久化
创建一个编辑器,自动保存内容到 localStorage,要求:
- 页面刷新后恢复之前的内容
- 使用
onUpdate事件自动保存 - 使用防抖避免频繁保存
💡 提示
const STORAGE_KEY = 'editor-content'
const [initialContent, setInitialContent] = useState(() => {
return localStorage.getItem(STORAGE_KEY) || '<p>开始输入...</p>'
})
const editor = useEditor({
extensions: [StarterKit],
content: initialContent,
onUpdate: ({ editor }) => {
const content = editor.getHTML()
// 使用防抖
const timer = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, content)
}, 1000)
return () => clearTimeout(timer)
},
})
练习 3:创建编辑器性能监控
创建一个编辑器,监控并显示性能指标,要求:
- 显示编辑器初始化时间
- 显示内容更新频率
- 显示当前文档大小
- 显示事务处理时间
💡 提示
const [metrics, setMetrics] = useState({
initTime: 0,
updateCount: 0,
docSize: 0,
avgTransactionTime: 0,
})
const startTime = useRef(Date.now())
const transactionTimes = useRef<number[]>([])
const editor = useEditor({
extensions: [StarterKit],
onCreate: () => {
const initTime = Date.now() - startTime.current
setMetrics(prev => ({ ...prev, initTime }))
},
onUpdate: ({ editor }) => {
setMetrics(prev => ({
...prev,
updateCount: prev.updateCount + 1,
docSize: editor.getHTML().length,
}))
},
onTransaction: ({ transaction }) => {
const start = performance.now()
// 处理事务
const end = performance.now()
transactionTimes.current.push(end - start)
const avg = transactionTimes.current.reduce((a, b) => a + b, 0) /
transactionTimes.current.length
setMetrics(prev => ({ ...prev, avgTransactionTime: avg }))
},
})
常见问题
Q1: 为什么编辑器实例在初始渲染时是 null?
A: useEditor 是异步创建编辑器的,第一次渲染时返回 null。解决方案:
const editor = useEditor({ /* ... */ })
if (!editor) {
return <div>加载中...</div>
}
return <EditorContent editor={editor} />
Q2: 如何在编辑器外部访问编辑器实例?
A: 使用 useRef 或状态提升:
// 方法 1:使用 ref
const editorRef = useRef<Editor | null>(null)
const editor = useEditor({
extensions: [StarterKit],
onCreate: ({ editor }) => {
editorRef.current = editor
},
})
// 方法 2:状态提升
function Parent() {
const [editorInstance, setEditorInstance] = useState<Editor | null>(null)
return (
<>
<Toolbar editor={editorInstance} />
<MyEditor onEditorCreate={setEditorInstance} />
</>
)
}
Q3: 编辑器内容更新后,如何触发父组件重新渲染?
A: 使用 onUpdate 回调:
function Parent() {
const [content, setContent] = useState('')
return (
<MyEditor
onUpdate={(newContent) => setContent(newContent)}
/>
)
}
function MyEditor({ onUpdate }: { onUpdate: (content: string) => void }) {
const editor = useEditor({
extensions: [StarterKit],
onUpdate: ({ editor }) => {
onUpdate(editor.getHTML())
},
})
return <EditorContent editor={editor} />
}
Q4: 如何在编辑器销毁前保存内容?
A: 使用 onDestroy 回调:
const editor = useEditor({
extensions: [StarterKit],
onDestroy: () => {
const content = editor?.getHTML()
if (content) {
localStorage.setItem('editor-content', content)
// 或发送到服务器
saveToServer(content)
}
},
})
Q5: 多个编辑器实例之间如何共享配置?
A: 创建共享的配置对象:
// 共享配置
const sharedConfig = {
extensions: [StarterKit],
editorProps: {
attributes: {
class: 'prose prose-sm',
},
},
}
// 编辑器 1
const editor1 = useEditor({
...sharedConfig,
content: '<p>编辑器 1</p>',
})
// 编辑器 2
const editor2 = useEditor({
...sharedConfig,
content: '<p>编辑器 2</p>',
})
Q6: 如何禁用特定的编辑功能?
A: 使用 editable 和 editorProps:
const editor = useEditor({
extensions: [StarterKit],
editable: true,
editorProps: {
// 禁用拖放
handleDrop: () => true,
// 禁用粘贴
handlePaste: () => true,
// 禁用特定按键
handleKeyDown: (view, event) => {
if (event.key === 'Enter') {
return true // 阻止回车
}
return false
},
},
})