AI协同写作应用-编辑器实例
第 4 章:编辑器实例
深入理解 Tiptap 编辑器实例的创建、配置和管理
本章概述
在前面的章节中,我们已经学会了如何创建一个基本的 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// 获取编辑器的 Schemaconsole.log(editor.schema)// 获取编辑器的 Stateconsole.log(editor.state)// 获取编辑器的 Viewconsole.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'// 定义组件 Propsinterface 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> )}
完整源码
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. 打开浏览器,访问应用 -
2. 在编辑器中输入内容,观察字符计数和保存状态 -
3. 点击”只读”按钮,尝试编辑(应该无法编辑) -
4. 点击”可编辑”按钮,恢复编辑功能 -
5. 点击”清空”按钮,清空所有内容 -
6. 打开浏览器控制台,观察事件日志
案例二:多编辑器实例管理
在某些场景下,我们需要在同一个页面中管理多个编辑器实例,例如:
-
• 评论系统(每条评论一个编辑器) -
• 多文档编辑(同时编辑多个文档) -
• 对比视图(并排显示两个编辑器)
功能需求
-
• 创建多个独立的编辑器实例 -
• 每个编辑器有独立的内容和状态 -
• 支持在编辑器之间复制内容 -
• 显示当前活动的编辑器
实现步骤
步骤 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> )}
完整源码
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. 打开浏览器,查看三个并排的编辑器 -
2. 点击任意编辑器,观察蓝色边框(表示活动状态) -
3. 在不同编辑器中输入内容,验证内容独立性 -
4. 点击”添加编辑器”按钮,添加新编辑器 -
5. 点击编辑器右上角的”✕”按钮,删除编辑器 -
6. 观察底部的统计信息更新
案例三:编辑器生命周期管理
理解编辑器的生命周期对于避免内存泄漏和性能问题至关重要。
功能需求
-
• 正确初始化编辑器 -
• 监听生命周期事件 -
• 正确清理和销毁编辑器 -
• 处理组件重新渲染
实现步骤
步骤 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> )}
完整源码
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> )}
测试功能
-
1. 打开浏览器,观察右侧日志面板 -
2. 在编辑器中输入内容,观察 onUpdate 事件 -
3. 点击编辑器内外,观察 onFocus 和 onBlur 事件 -
4. 选择文本,观察 onSelectionUpdate 事件 -
5. 点击”隐藏编辑器”,观察 onDestroy 事件 -
6. 点击”显示编辑器”,观察 onCreate 事件 -
7. 点击”重新创建”,观察完整的销毁和创建过程
第四部分:优化和最佳实践
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 编辑器实例的方方面面:
-
1. 编辑器实例基础 -
• 理解编辑器实例的概念和作用 -
• 掌握编辑器实例的属性和方法 -
• 学会创建和使用编辑器实例 -
2. 编辑器配置选项 -
• 核心配置:extensions、content、editable、autofocus 等 -
• 事件回调:onCreate、onUpdate、onFocus、onBlur 等 -
• 高级配置:editorProps、parseOptions 等 -
3. 进阶功能实战 -
• 案例一:配置完整的编辑器实例(自动保存、字符计数、只读模式) -
• 案例二:多编辑器实例管理(动态添加/删除、独立状态) -
• 案例三:编辑器生命周期管理(监听事件、正确清理) -
4. 优化和最佳实践 -
• 避免内存泄漏 -
• 优化重新渲染 -
• 处理异步初始化 -
• 动态更新配置 -
• 多编辑器性能优化 -
• 错误处理
关键要点
-
• ✅ 编辑器实例是 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:使用 refconst 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', }, },}// 编辑器 1const editor1 = useEditor({ ...sharedConfig, content: '<p>编辑器 1</p>',})// 编辑器 2const 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 }, },})



夜雨聆风