AI协同写作应用-TipTap编辑器实例

4 阅读19分钟

前言

系列教程和源码在飞书文档编写

正在做可以写到简历的企业级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. 打开浏览器,访问应用
  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>
  )
}

完整源码

📁 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. 打开浏览器,查看三个并排的编辑器
  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>
  )
}

完整源码

📁 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>
  )
}

测试功能

  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 的核心对象,包含所有状态和方法
  • ✅ 使用 useEditor Hook 创建编辑器实例
  • ✅ 编辑器支持丰富的配置选项和事件回调
  • ✅ 必须在组件卸载时正确销毁编辑器,避免内存泄漏
  • ✅ 使用 useMemouseCallback 优化性能
  • ✅ 多编辑器实例需要独立管理,避免状态混乱
  • ✅ 理解编辑器生命周期,正确处理各个阶段

练习题

练习 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: 使用 editableeditorProps

const editor = useEditor({
  extensions: [StarterKit],
  editable: true,
  editorProps: {
    // 禁用拖放
    handleDrop: () => true,
    // 禁用粘贴
    handlePaste: () => true,
    // 禁用特定按键
    handleKeyDown: (view, event) => {
      if (event.key === 'Enter') {
        return true // 阻止回车
      }
      return false
    },
  },
})