第 3 章:Tiptap 与 React 集成

5 阅读21分钟

前言

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

本章概述

在本章中,我们将深入学习如何在 React 项目中集成 Tiptap 编辑器。React 是目前最流行的前端框架之一,Tiptap 为 React 提供了专门的集成包和 Hooks,让我们能够以 React 的方式来使用编辑器。

学习目标:

  • 在 React 项目中安装和配置 Tiptap
  • 创建第一个可用的编辑器
  • 理解 useEditor Hook 的配置选项
  • 实现自动保存功能
  • 使用 EditorProvider 构建完整编辑器
  • 处理 Next.js 的 SSR 问题
  • 掌握性能优化技巧

前置知识:

  • React 基础(组件、Hooks、Props)
  • TypeScript 基础(可选,但推荐)
  • npm/pnpm 包管理器基础

预计学习时间: 90-120 分钟


本章变更概览

新增内容:

  • Tiptap React 依赖包(3 个)
  • 基础编辑器组件(3 个文件)
  • 自动保存编辑器(1 个文件)
  • EditorProvider 完整编辑器(4 个文件)
  • Next.js SSR 版本(1 个文件)

🔄 修改内容:

  • 无(本章为新建项目)

📁 涉及文件:

  • 基础编辑器:src/Tiptap.tsxsrc/App.tsxsrc/App.css
  • 自动保存:src/components/AutoSaveEditor.tsx
  • EditorProvider:src/components/MenuBar.tsxsrc/components/StatusBar.tsxsrc/components/FullEditor.tsxsrc/components/FullEditor.css
  • Next.js:app/editor/page.tsx

第一部分:基础入门

📚 本部分内容: 学习如何在 React 项目中安装和使用 Tiptap,创建第一个可用的编辑器。

1. 安装和配置

1.1 创建 React 项目(可选)

如果你还没有 React 项目,可以使用 Vite 快速创建:

# 使用 pnpm(推荐)
pnpm create vite my-tiptap-app --template react-ts

# 进入项目目录
cd my-tiptap-app

# 安装依赖
pnpm install

为什么选择 Vite?

  • ⚡ 启动速度快(使用 ESBuild)
  • 🔥 热更新快速
  • 📦 开箱即用的 TypeScript 支持
  • 🛠️ 现代化的构建工具

1.2 安装 Tiptap 依赖

Tiptap 在 React 中需要安装三个核心包:

pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

包的作用说明:

包名作用说明
@tiptap/reactReact 集成包提供 useEditor、EditorProvider、EditorContent 等
@tiptap/pmProseMirror 核心Tiptap 的底层依赖,提供文档模型和状态管理
@tiptap/starter-kit常用扩展集合包含 15+ 个常用扩展(Bold、Italic、Heading 等)

💡 版本兼容性: Tiptap v3.x 需要 React 18+,如果使用 React 17,请安装 Tiptap v2.x


2. 第一个编辑器

2.1 实现步骤

步骤 1:创建编辑器组件

src 目录下创建 Tiptap.tsx 文件:

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增

function Tiptap() { // ✨ 新增
  const editor = useEditor({ // ✨ 新增
    extensions: [StarterKit], // ✨ 新增
    content: '<p>Hello World! 🌍</p>', // ✨ 新增
  }) // ✨ 新增

  return <EditorContent editor={editor} /> // ✨ 新增
} // ✨ 新增

export default Tiptap // ✨ 新增

代码详解:

  1. 导入必要的模块

    • useEditor: React Hook,用于创建和管理编辑器实例
    • EditorContent: React 组件,用于渲染编辑器的可编辑区域
    • StarterKit: 包含最常用扩展的集合
  2. 使用 useEditor Hook

    • extensions: 配置编辑器使用的扩展数组
    • content: 编辑器的初始内容(支持 HTML 或 JSON)
    • 返回编辑器实例,首次渲染时可能为 null
  3. 渲染编辑器

    • EditorContent 组件接收编辑器实例
    • 自动处理编辑器的渲染和更新

步骤 2:在 App 中使用

修改 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap' // ✨ 新增
import './App.css'

function App() {
  return (
    <div className="app"> {/* ✨ 新增 */}
      <h1>我的 Tiptap 编辑器</h1> {/* ✨ 新增 */}
      <Tiptap /> {/* ✨ 新增 */}
    </div> {/* ✨ 新增 */}
  )
}

export default App

步骤 3:添加基础样式

创建或修改 src/App.css

/* src/App.css */

/* ✨ 新增:应用容器样式 */
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

/* ✨ 新增:编辑器容器样式 */
.tiptap {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
}

/* ✨ 新增:编辑器获得焦点时的样式 */
.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

/* ✨ 新增:段落样式 */
.tiptap p {
  margin: 0.5rem 0;
}

/* ✨ 新增:标题样式 */
.tiptap h1 {
  font-size: 2rem;
  margin: 1rem 0;
}

.tiptap h2 {
  font-size: 1.5rem;
  margin: 0.875rem 0;
}

.tiptap h3 {
  font-size: 1.25rem;
  margin: 0.75rem 0;
}

样式说明:

  • .tiptap 是 Tiptap 自动添加的类名
  • 建议添加边框、内边距、最小高度等基础样式
  • 焦点样式提供更好的用户体验

步骤 4:启动项目

pnpm dev

打开浏览器访问 http://localhost:5173,你应该能看到一个可编辑的文本区域。

测试功能:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行(按 Enter)
  • ✅ 撤销/重做(Ctrl+Z / Ctrl+Shift+Z)

2.2 完整源码

💡 说明: 以下是第一个编辑器的完整代码,可以直接复制使用。

📄 src/Tiptap.tsx

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

📄 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

📄 src/App.css

/* src/App.css */
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.tiptap {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
}

.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.tiptap p {
  margin: 0.5rem 0;
}

.tiptap h1 {
  font-size: 2rem;
  margin: 1rem 0;
}

.tiptap h2 {
  font-size: 1.5rem;
  margin: 0.875rem 0;
}

.tiptap h3 {
  font-size: 1.25rem;
  margin: 0.75rem 0;
}

第二部分:核心概念

📚 本部分内容: 深入理解 useEditor Hook 的配置选项和事件回调,为后续实战打下基础。

3. useEditor Hook 详解

useEditor 是 Tiptap React 集成的核心 Hook。让我们深入了解它的配置选项。

3.1 基本配置选项

const editor = useEditor({
  // 必需:扩展配置
  extensions: [StarterKit],
  
  // 初始内容
  content: '<p>初始内容</p>',
  
  // 是否可编辑
  editable: true,
  
  // 是否自动获取焦点
  autofocus: false,
  
  // 是否立即渲染(SSR 相关)
  immediatelyRender: true,
})

配置项详解:

配置项类型默认值说明
extensionsExtension[]必需编辑器使用的扩展数组
contentstring | JSONContent''初始内容(HTML 或 JSON)
editablebooleantrue是否可编辑,可动态切换
autofocusboolean | 'start' | 'end' | 'all' | numberfalse自动获取焦点的位置
immediatelyRenderbooleantrue是否立即渲染(SSR 时设为 false)

autofocus 选项说明:

  • false: 不自动获取焦点
  • true'start': 光标在开头
  • 'end': 光标在结尾
  • 'all': 选中所有内容
  • number: 光标在指定位置

3.2 事件回调配置

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
  
  // 内容创建时触发
  onCreate: ({ editor }) => {
    console.log('编辑器已创建', editor)
  },
  
  // 内容更新时触发
  onUpdate: ({ editor }) => {
    console.log('内容已更新', editor.getHTML())
  },
  
  // 选区变化时触发
  onSelectionUpdate: ({ editor }) => {
    console.log('选区已变化', editor.state.selection)
  },
  
  // 获得焦点时触发
  onFocus: ({ editor, event }) => {
    console.log('编辑器获得焦点', event)
  },
  
  // 失去焦点时触发
  onBlur: ({ editor, event }) => {
    console.log('编辑器失去焦点', event)
  },
  
  // 销毁时触发
  onDestroy: () => {
    console.log('编辑器已销毁')
  },
})

事件回调详解:

事件触发时机用途注意事项
onCreate编辑器初始化完成初始化操作、日志记录只触发一次
onUpdate每次内容变化保存内容、同步状态频繁触发,建议防抖
onSelectionUpdate光标或选区变化更新工具栏状态频繁触发
onFocus获得焦点显示工具栏-
onBlur失去焦点隐藏工具栏、表单验证-
onDestroy组件卸载清理资源、保存数据只触发一次

第三部分:进阶功能

🚀 本部分内容: 通过三个实战案例学习进阶功能,每个案例都是独立完整的。

4. 实战案例一:自动保存编辑器

4.1 功能介绍

自动保存是编辑器的重要功能,可以防止用户意外丢失内容。我们将实现:

  • 内容变化时自动保存到 localStorage
  • 使用防抖避免频繁保存
  • 页面刷新后自动加载保存的内容
  • 显示保存状态提示

4.2 实现步骤

步骤 1:创建自动保存组件

创建 src/components/AutoSaveEditor.tsx

// src/components/AutoSaveEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增
import { useEffect, useRef } from 'react' // ✨ 新增

function AutoSaveEditor() { // ✨ 新增
  const saveTimeoutRef = useRef<NodeJS.Timeout>() // ✨ 新增
  
  const editor = useEditor({ // ✨ 新增
    extensions: [StarterKit], // ✨ 新增
    
    // ✨ 新增:从 localStorage 加载初始内容
    content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>', // ✨ 新增
    
    // ✨ 新增:内容更新时触发
    onUpdate: ({ editor }) => { // ✨ 新增
      // 清除之前的定时器
      if (saveTimeoutRef.current) { // ✨ 新增
        clearTimeout(saveTimeoutRef.current) // ✨ 新增
      } // ✨ 新增
      
      // 设置新的定时器,500ms 后保存
      saveTimeoutRef.current = setTimeout(() => { // ✨ 新增
        const html = editor.getHTML() // ✨ 新增
        localStorage.setItem('editor-content', html) // ✨ 新增
        console.log('内容已自动保存') // ✨ 新增
      }, 500) // ✨ 新增
    }, // ✨ 新增
  }) // ✨ 新增
  
  // ✨ 新增:组件卸载时清理定时器
  useEffect(() => { // ✨ 新增
    return () => { // ✨ 新增
      if (saveTimeoutRef.current) { // ✨ 新增
        clearTimeout(saveTimeoutRef.current) // ✨ 新增
      } // ✨ 新增
    } // ✨ 新增
  }, []) // ✨ 新增
  
  return ( // ✨ 新增
    <div> // ✨ 新增
      <div className="save-indicator"> // ✨ 新增
        💾 自动保存已启用 // ✨ 新增
      </div> // ✨ 新增
      <EditorContent editor={editor} /> // ✨ 新增
    </div> // ✨ 新增
  ) // ✨ 新增
} // ✨ 新增

export default AutoSaveEditor // ✨ 新增

代码详解:

  1. 使用 useRef 存储定时器

    • 避免不必要的重渲染
    • 存储定时器 ID 用于清除
  2. 防抖保存

    • 每次内容变化时清除之前的定时器
    • 设置新的定时器,500ms 后执行保存
    • 避免频繁保存,提高性能
  3. 加载和保存内容

    • 从 localStorage 加载初始内容
    • 保存 HTML 格式的内容
  4. 清理副作用

    • 组件卸载时清理定时器
    • 避免内存泄漏

步骤 2:添加样式

src/App.css 中添加:

/* ✨ 新增:保存指示器样式 */
.save-indicator {
  padding: 0.5rem 1rem;
  background-color: #f0fdf4;
  border: 1px solid #86efac;
  border-radius: 4px;
  margin-bottom: 1rem;
  color: #166534;
  font-size: 14px;
}

4.3 完整源码

💡 说明: 以下是自动保存编辑器的完整代码。

📄 src/components/AutoSaveEditor.tsx

// src/components/AutoSaveEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'

function AutoSaveEditor() {
  const saveTimeoutRef = useRef<NodeJS.Timeout>()
  
  const editor = useEditor({
    extensions: [StarterKit],
    content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>',
    
    onUpdate: ({ editor }) => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current)
      }
      
      saveTimeoutRef.current = setTimeout(() => {
        const html = editor.getHTML()
        localStorage.setItem('editor-content', html)
        console.log('内容已自动保存')
      }, 500)
    },
  })
  
  useEffect(() => {
    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current)
      }
    }
  }, [])
  
  return (
    <div>
      <div className="save-indicator">
        💾 自动保存已启用
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}

export default AutoSaveEditor

📄 src/App.css(新增部分)

/* 保存指示器样式 */
.save-indicator {
  padding: 0.5rem 1rem;
  background-color: #f0fdf4;
  border: 1px solid #86efac;
  border-radius: 4px;
  margin-bottom: 1rem;
  color: #166534;
  font-size: 14px;
}

4.4 测试功能

src/App.tsx 中使用自动保存编辑器:

import AutoSaveEditor from './components/AutoSaveEditor'

function App() {
  return (
    <div className="app">
      <h1>自动保存编辑器</h1>
      <AutoSaveEditor />
    </div>
  )
}

测试步骤:

  1. 在编辑器中输入一些文字
  2. 等待 500ms,查看控制台是否显示"内容已自动保存"
  3. 刷新页面,内容应该保留
  4. 清空浏览器 localStorage,刷新页面应显示默认内容

继续下一部分...

5. 实战案例二:EditorProvider 和完整工具栏

5.1 为什么需要 EditorProvider

当你的应用有多个组件需要访问编辑器实例时,使用 EditorProvider 和 Context 是更好的选择。

问题场景: 工具栏组件和编辑器组件都需要访问编辑器实例。

传统方案(Props 传递):

// ❌ 不推荐:需要层层传递 editor
function App() {
  const editor = useEditor({ ... })
  return (
    <div>
      <Toolbar editor={editor} />
      <EditorContent editor={editor} />
    </div>
  )
}

问题:

  • 需要手动传递 editor prop
  • 组件层级深时,需要层层传递
  • 代码冗余,不易维护

EditorProvider 方案:

// ✅ 推荐:使用 Context 共享 editor
<EditorProvider extensions={[StarterKit]}>
  <Toolbar />
  <EditorContent />
</EditorProvider>

优势:

  • 不需要手动传递 editor prop
  • 任何子组件都可以通过 useCurrentEditor 访问编辑器
  • 代码更简洁,更易维护

5.2 实现步骤

我们将创建一个完整的编辑器,包含工具栏、编辑区域和状态栏。

步骤 1:创建工具栏组件

创建 src/components/MenuBar.tsx

// src/components/MenuBar.tsx
import { useCurrentEditor } from '@tiptap/react' // ✨ 新增

function MenuBar() { // ✨ 新增
  const { editor } = useCurrentEditor() // ✨ 新增
  
  if (!editor) return null // ✨ 新增
  
  return ( // ✨ 新增
    <div className="menu-bar"> // ✨ 新增
      {/* ✨ 新增:文本格式按钮 */}
      <div className="button-group"> // ✨ 新增
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleBold().run()} // ✨ 新增
          className={editor.isActive('bold') ? 'is-active' : ''} // ✨ 新增
          title="加粗 (Ctrl+B)" // ✨ 新增
        > // ✨ 新增
          <strong>B</strong> // ✨ 新增
        </button> // ✨ 新增
        
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleItalic().run()} // ✨ 新增
          className={editor.isActive('italic') ? 'is-active' : ''} // ✨ 新增
          title="斜体 (Ctrl+I)" // ✨ 新增
        > // ✨ 新增
          <em>I</em> // ✨ 新增
        </button> // ✨ 新增
        
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleStrike().run()} // ✨ 新增
          className={editor.isActive('strike') ? 'is-active' : ''} // ✨ 新增
        > // ✨ 新增
          <s>S</s> // ✨ 新增
        </button> // ✨ 新增
      </div> // ✨ 新增
      
      {/* ✨ 新增:标题按钮 */}
      <div className="button-group"> // ✨ 新增
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} // ✨ 新增
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} // ✨ 新增
        > // ✨ 新增
          H1 // ✨ 新增
        </button> // ✨ 新增
        
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} // ✨ 新增
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} // ✨ 新增
        > // ✨ 新增
          H2 // ✨ 新增
        </button> // ✨ 新增
      </div> // ✨ 新增
      
      {/* ✨ 新增:列表按钮 */}
      <div className="button-group"> // ✨ 新增
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleBulletList().run()} // ✨ 新增
          className={editor.isActive('bulletList') ? 'is-active' : ''} // ✨ 新增
        > // ✨ 新增
          • 列表 // ✨ 新增
        </button> // ✨ 新增
        
        <button // ✨ 新增
          onClick={() => editor.chain().focus().toggleOrderedList().run()} // ✨ 新增
          className={editor.isActive('orderedList') ? 'is-active' : ''} // ✨ 新增
        > // ✨ 新增
          1. 列表 // ✨ 新增
        </button> // ✨ 新增
      </div> // ✨ 新增
      
      {/* ✨ 新增:撤销/重做 */}
      <div className="button-group"> // ✨ 新增
        <button // ✨ 新增
          onClick={() => editor.chain().focus().undo().run()} // ✨ 新增
          disabled={!editor.can().undo()} // ✨ 新增
        > // ✨ 新增
          撤销 // ✨ 新增
        </button> // ✨ 新增
        
        <button // ✨ 新增
          onClick={() => editor.chain().focus().redo().run()} // ✨ 新增
          disabled={!editor.can().redo()} // ✨ 新增
        > // ✨ 新增
          重做 // ✨ 新增
        </button> // ✨ 新增
      </div> // ✨ 新增
    </div> // ✨ 新增
  ) // ✨ 新增
} // ✨ 新增

export default MenuBar // ✨ 新增

代码详解:

  1. useCurrentEditor Hook

    • 从 Context 中获取编辑器实例
    • 返回 { editor } 对象
    • editor 可能为 null,需要空值检查
  2. Commands 链式调用

    • chain(): 开始链式调用
    • focus(): 让编辑器获得焦点
    • toggleBold(): 切换加粗状态
    • run(): 执行命令链
  3. 检查激活状态

    • isActive('bold'): 检查是否应用了加粗
    • 用于高亮工具栏按钮
  4. 检查命令可用性

    • can().undo(): 检查是否可以撤销
    • 用于禁用按钮

步骤 2:创建状态栏组件

创建 src/components/StatusBar.tsx

// src/components/StatusBar.tsx
import { useCurrentEditor } from '@tiptap/react' // ✨ 新增

function StatusBar() { // ✨ 新增
  const { editor } = useCurrentEditor() // ✨ 新增
  
  if (!editor) return null // ✨ 新增
  
  // ✨ 新增:获取字符数
  const characterCount = editor.state.doc.textContent.length // ✨ 新增
  
  // ✨ 新增:获取单词数
  const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0) // ✨ 新增
  const wordCount = words.length // ✨ 新增
  
  return ( // ✨ 新增
    <div className="status-bar"> // ✨ 新增
      <span>{characterCount} 字符</span> // ✨ 新增
      <span>{wordCount} 单词</span> // ✨ 新增
    </div> // ✨ 新增
  ) // ✨ 新增
} // ✨ 新增

export default StatusBar // ✨ 新增

步骤 3:创建完整编辑器组件

创建 src/components/FullEditor.tsx

// src/components/FullEditor.tsx
import { EditorProvider, EditorContent } from '@tiptap/react' // ✨ 新增
import StarterKit from '@tiptap/starter-kit' // ✨ 新增
import MenuBar from './MenuBar' // ✨ 新增
import StatusBar from './StatusBar' // ✨ 新增
import './FullEditor.css' // ✨ 新增

function FullEditor() { // ✨ 新增
  return ( // ✨ 新增
    <div className="full-editor"> // ✨ 新增
      <EditorProvider // ✨ 新增
        extensions={[StarterKit]} // ✨ 新增
        content="<p>开始编辑你的文档...</p>" // ✨ 新增
      > // ✨ 新增
        <MenuBar /> // ✨ 新增
        <EditorContent /> // ✨ 新增
        <StatusBar /> // ✨ 新增
      </EditorProvider> // ✨ 新增
    </div> // ✨ 新增
  ) // ✨ 新增
} // ✨ 新增

export default FullEditor // ✨ 新增

步骤 4:添加样式

创建 src/components/FullEditor.css

/* src/components/FullEditor.css */

/* ✨ 新增:编辑器容器 */
.full-editor {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
}

/* ✨ 新增:工具栏 */
.menu-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.button-group {
  display: flex;
  gap: 0.25rem;
}

.menu-bar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.menu-bar button:hover:not(:disabled) {
  background-color: #f3f4f6;
}

.menu-bar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.menu-bar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* ✨ 新增:编辑器内容 */
.full-editor .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
}

/* ✨ 新增:状态栏 */
.status-bar {
  display: flex;
  gap: 1rem;
  padding: 0.5rem 1rem;
  background-color: #f9fafb;
  border-top: 1px solid #e5e7eb;
  font-size: 12px;
  color: #6b7280;
}

5.3 完整源码

💡 说明: 以下是 EditorProvider 版本的完整代码。

📄 src/components/MenuBar.tsx

// src/components/MenuBar.tsx
import { useCurrentEditor } from '@tiptap/react'

function MenuBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  return (
    <div className="menu-bar">
      {/* 文本格式 */}
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
          title="加粗 (Ctrl+B)"
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
          title="斜体 (Ctrl+I)"
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
      </div>
      
      {/* 标题 */}
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
      </div>
      
      {/* 列表 */}
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
      </div>
      
      {/* 撤销/重做 */}
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          重做
        </button>
      </div>
    </div>
  )
}

export default MenuBar

📄 src/components/StatusBar.tsx

// src/components/StatusBar.tsx
import { useCurrentEditor } from '@tiptap/react'

function StatusBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  const characterCount = editor.state.doc.textContent.length
  const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0)
  const wordCount = words.length
  
  return (
    <div className="status-bar">
      <span>{characterCount} 字符</span>
      <span>{wordCount} 单词</span>
    </div>
  )
}

export default StatusBar

📄 src/components/FullEditor.tsx

// src/components/FullEditor.tsx
import { EditorProvider, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import MenuBar from './MenuBar'
import StatusBar from './StatusBar'
import './FullEditor.css'

function FullEditor() {
  return (
    <div className="full-editor">
      <EditorProvider
        extensions={[StarterKit]}
        content="<p>开始编辑你的文档...</p>"
      >
        <MenuBar />
        <EditorContent />
        <StatusBar />
      </EditorProvider>
    </div>
  )
}

export default FullEditor

📄 src/components/FullEditor.css

/* src/components/FullEditor.css */
.full-editor {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
}

.menu-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.button-group {
  display: flex;
  gap: 0.25rem;
}

.menu-bar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.menu-bar button:hover:not(:disabled) {
  background-color: #f3f4f6;
}

.menu-bar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.menu-bar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.full-editor .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
}

.status-bar {
  display: flex;
  gap: 1rem;
  padding: 0.5rem 1rem;
  background-color: #f9fafb;
  border-top: 1px solid #e5e7eb;
  font-size: 12px;
  color: #6b7280;
}

5.4 测试功能

src/App.tsx 中使用完整编辑器:

import FullEditor from './components/FullEditor'

function App() {
  return (
    <div className="app">
      <h1>完整编辑器</h1>
      <FullEditor />
    </div>
  )
}

测试清单:

  • ✅ 文本格式(加粗、斜体、删除线)
  • ✅ 标题(H1、H2)
  • ✅ 列表(无序、有序)
  • ✅ 撤销/重做
  • ✅ 字符数和单词数统计
  • ✅ 工具栏按钮高亮状态

继续下一部分...

6. 实战案例三:Next.js SSR 处理

6.1 问题分析

Next.js 支持服务端渲染(SSR),但 Tiptap 依赖浏览器 API,需要特殊处理。

错误示例:

// ❌ 这段代码在 Next.js 中会报错
import { useEditor, EditorContent } from '@tiptap/react'

export default function Page() {
  const editor = useEditor({ ... })
  return <EditorContent editor={editor} />
}

// 错误:ReferenceError: document is not defined

原因:

  • Next.js 在服务端执行代码时,没有 document 对象
  • Tiptap 在初始化时会访问 document
  • 导致服务端渲染失败

6.2 解决方案

使用 immediatelyRender: false 和条件渲染:

// ✅ 正确:使用 immediatelyRender
'use client' // Next.js App Router 需要添加

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'

export default function Page() {
  const [isMounted, setIsMounted] = useState(false)
  
  useEffect(() => {
    setIsMounted(true)
  }, [])
  
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World!</p>',
    immediatelyRender: false, // 关键:延迟渲染
  })
  
  if (!isMounted) {
    return <div>加载编辑器中...</div>
  }
  
  return <EditorContent editor={editor} />
}

关键点:

  1. 添加 'use client' 指令(App Router)
  2. 使用 immediatelyRender: false
  3. 使用条件渲染检测客户端
  4. 提供加载占位符

6.3 完整源码

💡 说明: 以下是 Next.js 版本的完整代码。

📄 app/editor/page.tsx

// app/editor/page.tsx
'use client'

import { EditorProvider, EditorContent, useCurrentEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'

function MenuBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  return (
    <div className="flex gap-2 p-2 border-b bg-gray-50">
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={`px-3 py-1 rounded transition-colors ${
          editor.isActive('bold') 
            ? 'bg-blue-500 text-white' 
            : 'bg-white hover:bg-gray-100'
        }`}
      >
        加粗
      </button>
      
      <button
        onClick={() => editor.chain().focus().toggleItalic().run()}
        className={`px-3 py-1 rounded transition-colors ${
          editor.isActive('italic') 
            ? 'bg-blue-500 text-white' 
            : 'bg-white hover:bg-gray-100'
        }`}
      >
        斜体
      </button>
    </div>
  )
}

function Editor() {
  const [isMounted, setIsMounted] = useState(false)
  
  useEffect(() => {
    setIsMounted(true)
  }, [])
  
  if (!isMounted) {
    return (
      <div className="p-4 text-center text-gray-500 border rounded-lg">
        加载编辑器中...
      </div>
    )
  }
  
  return (
    <EditorProvider
      extensions={[StarterKit]}
      content="<p>开始编辑...</p>"
      immediatelyRender={false}
    >
      <div className="border rounded-lg overflow-hidden">
        <MenuBar />
        <EditorContent className="prose max-w-none p-4 min-h-[300px] focus:outline-none" />
      </div>
    </EditorProvider>
  )
}

export default function EditorPage() {
  return (
    <div className="container mx-auto p-8 max-w-4xl">
      <h1 className="text-3xl font-bold mb-4">Next.js 编辑器</h1>
      <Editor />
    </div>
  )
}

6.4 测试功能

启动 Next.js 开发服务器:

npm run dev

访问 /editor 页面,测试:

  • ✅ 页面正常加载
  • ✅ 编辑器正常显示
  • ✅ 加粗、斜体功能正常
  • ✅ 刷新页面无报错

第四部分:优化和最佳实践

🎯 本部分内容: 学习性能优化技巧和最佳实践。

7. 性能优化

7.1 避免不必要的重渲染

问题: 编辑器每次更新都会触发父组件重渲染

// ❌ 不好的做法
function App() {
  const [content, setContent] = useState('')
  
  const editor = useEditor({
    extensions: [StarterKit],
    onUpdate: ({ editor }) => {
      // 每次更新都会触发 setContent,导致重渲染
      setContent(editor.getHTML())
    },
  })
  
  return (
    <div>
      <EditorContent editor={editor} />
      <div>内容长度: {content.length}</div>
    </div>
  )
}

解决方案: 使用 useRef

// ✅ 好的做法
function App() {
  const contentRef = useRef('')
  const [displayLength, setDisplayLength] = useState(0)
  
  const editor = useEditor({
    extensions: [StarterKit],
    onUpdate: ({ editor }) => {
      // 使用 ref 存储内容,不触发重渲染
      contentRef.current = editor.getHTML()
      
      // 使用防抖更新显示
      debounce(() => {
        setDisplayLength(contentRef.current.length)
      }, 500)()
    },
  })
  
  return (
    <div>
      <EditorContent editor={editor} />
      <div>内容长度: {displayLength}</div>
    </div>
  )
}

7.2 使用 React.memo 优化子组件

// ✅ 使用 memo 避免不必要的重渲染
import { memo } from 'react'

const MenuBar = memo(function MenuBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  return (
    <div className="menu-bar">
      <button onClick={() => editor.chain().focus().toggleBold().run()}>
        加粗
      </button>
      {/* 其他按钮 */}
    </div>
  )
})

export default MenuBar

第五部分:总结和练习

8. 测试功能

完整测试清单:

基础编辑:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行
  • ✅ 撤销/重做

文本格式:

  • ✅ 加粗(Ctrl+B)
  • ✅ 斜体(Ctrl+I)
  • ✅ 删除线

标题:

  • ✅ H1、H2、H3
  • ✅ 段落

列表:

  • ✅ 无序列表
  • ✅ 有序列表

自动保存:

  • ✅ 内容自动保存到 localStorage
  • ✅ 刷新页面后内容保留

EditorProvider:

  • ✅ 工具栏按钮正常工作
  • ✅ 按钮高亮状态正确
  • ✅ 字符数和单词数统计

Next.js SSR:

  • ✅ 页面正常加载
  • ✅ 无服务端渲染错误

9. 本章总结

在本章中,我们深入学习了 Tiptap 与 React 的集成:

核心知识点

安装和配置

  • 安装 @tiptap/react@tiptap/pm@tiptap/starter-kit
  • 使用 Vite 创建 React 项目

useEditor Hook

  • 创建编辑器实例
  • 配置扩展、内容、事件回调
  • 理解配置选项和事件回调

实战案例

  • 自动保存编辑器(防抖、localStorage)
  • EditorProvider 和工具栏(Context、useCurrentEditor)
  • Next.js SSR 处理(immediatelyRender、条件渲染)

性能优化

  • 避免不必要的重渲染(useRef)
  • 使用 React.memo 优化子组件

最佳实践

  1. 始终进行空值检查

    if (!editor) return null
    
  2. 使用 EditorProvider 共享状态

    <EditorProvider extensions={[StarterKit]}>
      <MenuBar />
      <EditorContent />
    </EditorProvider>
    
  3. 命令执行前获得焦点

    editor.chain().focus().toggleBold().run()
    
  4. 使用防抖处理频繁更新

    onUpdate: debounce(({ editor }) => {
      // 保存逻辑
    }, 500)
    
  5. Next.js 中使用 immediatelyRender

    const editor = useEditor({
      extensions: [StarterKit],
      immediatelyRender: false,
    })
    

关键 API

API用途示例
useEditor创建编辑器useEditor({ extensions: [StarterKit] })
EditorContent渲染编辑器<EditorContent editor={editor} />
EditorProvider提供 Context<EditorProvider extensions={[StarterKit]}>
useCurrentEditor获取编辑器const { editor } = useCurrentEditor()
chain()链式调用editor.chain().focus().toggleBold().run()
isActive()检查状态editor.isActive('bold')
can()检查可用性editor.can().undo()

下一章预告: 我们将学习 Vue、Angular、Svelte 等其他框架的集成方式。


10. 练习题

练习 1:创建字数统计编辑器

要求:

  • 使用 EditorProvider
  • 实时显示字符数和单词数
  • 超过 100 字时显示警告
💡 提示

使用 editor.state.doc.textContent 获取纯文本内容,然后计算长度。

练习 2:实现只读模式切换

要求:

  • 添加一个切换按钮
  • 点击按钮切换编辑器的可编辑状态
  • 只读模式下工具栏禁用
💡 提示

使用 editor.setEditable(false) 切换只读模式。

练习 3:实现内容导出功能

要求:

  • 添加导出按钮
  • 支持导出 HTML 和 JSON 格式
  • 使用浏览器下载 API
💡 提示
const html = editor.getHTML()
const json = editor.getJSON()

// 下载文件
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'document.html'
a.click()

11. 常见问题

Q1: 编辑器为什么是 null?

A: 首次渲染时,编辑器还未初始化。解决方案:

if (!editor) return null
// 或
editor?.chain().focus().toggleBold().run()

Q2: 如何动态更新编辑器内容?

A: 使用 setContent 方法:

useEffect(() => {
  if (editor && content) {
    editor.commands.setContent(content)
  }
}, [editor, content])

Q3: 样式为什么不生效?

A: Tiptap 是无头编辑器,需要自己添加 CSS:

.tiptap {
  padding: 1rem;
  outline: none;
}

.tiptap p {
  margin: 0.5rem 0;
}

Q4: 快捷键为什么不工作?

A: 确保编辑器有焦点:

editor.chain().focus().toggleBold().run()

Q5: 如何在 TypeScript 中使用?

A: 安装类型定义(通常已包含):

pnpm add -D @types/react

12. 扩展阅读


附录:本章所有源码文件

📦 说明: 以下是本章涉及的所有文件的完整代码,按照功能模块分类。

模块一:基础编辑器

📄 src/Tiptap.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

📄 src/App.tsx

import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

📄 src/App.css

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.tiptap {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
}

.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.tiptap p {
  margin: 0.5rem 0;
}

.tiptap h1 {
  font-size: 2rem;
  margin: 1rem 0;
}

.tiptap h2 {
  font-size: 1.5rem;
  margin: 0.875rem 0;
}

.tiptap h3 {
  font-size: 1.25rem;
  margin: 0.75rem 0;
}

模块二:自动保存编辑器

📄 src/components/AutoSaveEditor.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'

function AutoSaveEditor() {
  const saveTimeoutRef = useRef<NodeJS.Timeout>()
  
  const editor = useEditor({
    extensions: [StarterKit],
    content: localStorage.getItem('editor-content') || '<p>开始编辑...</p>',
    
    onUpdate: ({ editor }) => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current)
      }
      
      saveTimeoutRef.current = setTimeout(() => {
        const html = editor.getHTML()
        localStorage.setItem('editor-content', html)
        console.log('内容已自动保存')
      }, 500)
    },
  })
  
  useEffect(() => {
    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current)
      }
    }
  }, [])
  
  return (
    <div>
      <div className="save-indicator">
        💾 自动保存已启用
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}

export default AutoSaveEditor

📄 src/App.css(新增部分)

.save-indicator {
  padding: 0.5rem 1rem;
  background-color: #f0fdf4;
  border: 1px solid #86efac;
  border-radius: 4px;
  margin-bottom: 1rem;
  color: #166534;
  font-size: 14px;
}

模块三:EditorProvider 版本

📄 src/components/MenuBar.tsx

import { useCurrentEditor } from '@tiptap/react'

function MenuBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  return (
    <div className="menu-bar">
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
          title="加粗 (Ctrl+B)"
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
          title="斜体 (Ctrl+I)"
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
      </div>
      
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
      </div>
      
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
      </div>
      
      <div className="button-group">
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          重做
        </button>
      </div>
    </div>
  )
}

export default MenuBar

📄 src/components/StatusBar.tsx

import { useCurrentEditor } from '@tiptap/react'

function StatusBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  const characterCount = editor.state.doc.textContent.length
  const words = editor.state.doc.textContent.split(/\s+/).filter(word => word.length > 0)
  const wordCount = words.length
  
  return (
    <div className="status-bar">
      <span>{characterCount} 字符</span>
      <span>{wordCount} 单词</span>
    </div>
  )
}

export default StatusBar

📄 src/components/FullEditor.tsx

import { EditorProvider, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import MenuBar from './MenuBar'
import StatusBar from './StatusBar'
import './FullEditor.css'

function FullEditor() {
  return (
    <div className="full-editor">
      <EditorProvider
        extensions={[StarterKit]}
        content="<p>开始编辑你的文档...</p>"
      >
        <MenuBar />
        <EditorContent />
        <StatusBar />
      </EditorProvider>
    </div>
  )
}

export default FullEditor

📄 src/components/FullEditor.css

.full-editor {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
}

.menu-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.button-group {
  display: flex;
  gap: 0.25rem;
}

.menu-bar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.menu-bar button:hover:not(:disabled) {
  background-color: #f3f4f6;
}

.menu-bar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.menu-bar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.full-editor .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
}

.status-bar {
  display: flex;
  gap: 1rem;
  padding: 0.5rem 1rem;
  background-color: #f9fafb;
  border-top: 1px solid #e5e7eb;
  font-size: 12px;
  color: #6b7280;
}

模块四:Next.js 版本

📄 app/editor/page.tsx

'use client'

import { EditorProvider, EditorContent, useCurrentEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useState } from 'react'

function MenuBar() {
  const { editor } = useCurrentEditor()
  
  if (!editor) return null
  
  return (
    <div className="flex gap-2 p-2 border-b bg-gray-50">
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={`px-3 py-1 rounded transition-colors ${
          editor.isActive('bold') 
            ? 'bg-blue-500 text-white' 
            : 'bg-white hover:bg-gray-100'
        }`}
      >
        加粗
      </button>
      
      <button
        onClick={() => editor.chain().focus().toggleItalic().run()}
        className={`px-3 py-1 rounded transition-colors ${
          editor.isActive('italic') 
            ? 'bg-blue-500 text-white' 
            : 'bg-white hover:bg-gray-100'
        }`}
      >
        斜体
      </button>
    </div>
  )
}

function Editor() {
  const [isMounted, setIsMounted] = useState(false)
  
  useEffect(() => {
    setIsMounted(true)
  }, [])
  
  if (!isMounted) {
    return (
      <div className="p-4 text-center text-gray-500 border rounded-lg">
        加载编辑器中...
      </div>
    )
  }
  
  return (
    <EditorProvider
      extensions={[StarterKit]}
      content="<p>开始编辑...</p>"
      immediatelyRender={false}
    >
      <div className="border rounded-lg overflow-hidden">
        <MenuBar />
        <EditorContent className="prose max-w-none p-4 min-h-[300px] focus:outline-none" />
      </div>
    </EditorProvider>
  )
}

export default function EditorPage() {
  return (
    <div className="container mx-auto p-8 max-w-4xl">
      <h1 className="text-3xl font-bold mb-4">Next.js 编辑器</h1>
      <Editor />
    </div>
  )
}