编辑器菜单栏组件设计文档

本教程介绍如何设计一个可切换编辑引擎的编辑器组件。通过适配器模式,我们将具体编辑器的 API 抽象为统一接口,使 UI 组件与编辑器实现解耦。


目录


1. 问题引出

不同编辑器的 API 差异

不同编辑器的 API 风格差异很大:

编辑器 命令执行方式 示例
Tiptap 链式调用 editor.chain().focus().toggleBold().run()
CodeMirror 直接命令 editor.toggleBold()
Penn 简单函数 editor.markToggle('bold')

如果 MenuBar 直接依赖某一种编辑器的 API,换到其他编辑器时需要大量修改。

解决方案

依赖抽象而非具体实现:MenuBar 只知道 editor.commands.toggleMark('bold'),不知道也不关心这个命令内部是如何实现的。


2. 适配器模式设计

核心架构

┌─────────────────────────────────────────────────────────────┐
│                        架构图                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌──────────────┐         ┌────────────────────┐           │
│   │   Tiptap     │         │   CodeMirror       │           │
│   │  Adapter     │         │   Adapter          │           │
│   │  (适配层)     │         │   (适配层)          │           │
│   └──────┬───────┘         └─────────┬──────────┘           │
│          │                            │                     │
│          └──────────┬─────────────────┘                     │
│                       │                                     │
│                       ▼                                     │
│              ┌────────────────┐                             │
│              │  EditorInstance │ ← 抽象接口层                │
│              │  + EditorCommands │                           │
│              └────────┬────────┘                             │
│                       │                                     │
│          ┌────────────┴────────────┐                        │
│          │                         │                        │
│          ▼                         ▼                        │
│   ┌──────────────┐         ┌──────────────┐                 │
│   │   MenuBar    │         │  ToolBar     │                 │
│   │  组件        │         │   组件        │                 │
│   └──────────────┘         └──────────────┘                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

核心思想

  • MenuBar 只关心”做什么”,不关心”怎么做”
  • 每个编辑器有自己的适配器,MenuBar 零修改即可切换

3. useEditorState 详解

什么是 useEditorState?

useEditorState 是 Tiptap 提供的 React Hook,用于高效地订阅编辑器状态变化

// components/MenuBar.tsx
'use client'

import type { Editor } from '@tiptap/core'
import { useEditorState } from '@tiptap/react'

import { toolbarStateSelector } from './toolbarStateSelector'

export const MenuBar = ({ editor }: { editor: Editor | null }) => {
  /**
   * useEditorState 会订阅 editor 的状态变化
   * 当编辑器状态改变时,返回的 editorState 会更新
   * 组件会自动重新渲染
   */
  const editorState = useEditorState({
    editor: editor!,
    selector: toolbarStateSelector,
  })

  // ... 组件内容
}

核心概念

参数 类型 说明
editor Editor Tiptap 编辑器实例
selector Function 状态选择器,接收状态快照,返回简化状态对象

工作原理

┌──────────────┐     状态变化      ┌──────────────────┐
│   Editor     │ ───────────────→ │  useEditorState  │
│   实例       │                  │  (订阅者)         │
└──────────────┘                  └────────┬─────────┘
                                           │
                                           ▼
                                  ┌──────────────────┐
                                  │  返回新 state     │
                                  │  触发组件重渲染   │
                                  └──────────────────┘

为什么不用 useEffect + useState?

// ❌ 不推荐:每次都创建新引用,导致不必要的重渲染
const [state, setState] = useState({ isBold: false })

useEffect(() => {
  const updateState = () => {
    setState({
      isBold: editor.isActive('bold'),
      // ... 其他状态
    })
  }

  // 每次状态变化都创建新对象,即使值没变也会触发重渲染
  editor.on('update', updateState)
  return () => editor.off('update', updateState)
}, [editor])

// ✅ 推荐:useEditorState 内部做了优化
const editorState = useEditorState({
  editor,
  selector: toolbarStateSelector,
})
// selector 返回新对象时才会触发重渲染

4. 状态选择器模式

toolbarStateSelector 的作用

状态选择器从庞大的编辑器状态中提取 MenuBar 需要的最小状态集,避免订阅整个编辑器状态。

// toolbarStateSelector.ts
import type { Editor } from '@tiptap/react'
import type { EditorStateSnapshot } from '@tiptap/react'
import type { ToolbarState } from '../types'

/**
 * MenuBar 组件的状态选择器
 *
 * 作用:
 * 1. 从编辑器状态中提取 MenuBar 需要的字段
 * 2. 减少不必要的重渲染(只关注相关状态)
 * 3. 提供默认空状态,防止 editor 为 null 时出错
 *
 * @param ctx - 编辑器状态快照,包含 editor 实例和当前选择状态
 * @returns 符合 ToolbarState 接口的状态对象
 */
export function toolbarStateSelector(ctx: EditorStateSnapshot<Editor>): ToolbarState {
  // 当 editor 尚未初始化时,返回安全的默认状态
  if (!ctx.editor) {
    return {
      isBold: false,
      canBold: false,
      isItalic: false,
      canItalic: false,
      isStrike: false,
      canStrike: false,
      isUnderline: false,
      canUnderline: false,
      isCode: false,
      canCode: false,
      canClearMarks: false,
      isParagraph: false,
      isHeading1: false,
      isHeading2: false,
      isHeading3: false,
      isHeading4: false,
      isHeading5: false,
      isHeading6: false,
      isBulletList: false,
      isOrderedList: false,
      isCodeBlock: false,
      isBlockquote: false,
      canUndo: false,
      canRedo: false,
    }
  }

  // 当 editor 就绪时,返回实际状态
  return {
    // Mark 格式化状态(加粗、斜体、删除线、行内代码)
    isBold: ctx.editor.isActive('bold') ?? false,
    canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
    isItalic: ctx.editor.isActive('italic') ?? false,
    canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
    isStrike: ctx.editor.isActive('strike') ?? false,
    canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
    isUnderline: ctx.editor.isActive('underline') ?? false,
    canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false,
    isCode: ctx.editor.isActive('code') ?? false,
    canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
    canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,

    // 块级元素状态(段落、标题、列表、代码块、引用)
    isParagraph: ctx.editor.isActive('paragraph') ?? false,
    isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
    isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
    isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
    isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
    isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
    isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,

    // 列表状态
    isBulletList: ctx.editor.isActive('bulletList') ?? false,
    isOrderedList: ctx.editor.isActive('orderedList') ?? false,

    // 块级元素
    isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
    isBlockquote: ctx.editor.isActive('blockquote') ?? false,

    // 历史操作状态
    canUndo: ctx.editor.can().chain().undo().run() ?? false,
    canRedo: ctx.editor.can().chain().redo().run() ?? false,
  }
}

Selector 模式的优势

  1. 性能优化:只订阅关心的状态,减少重渲染
  2. 可测试性:selector 是纯函数,易于单元测试
  3. 类型统一:ToolbarState 类型统一定义在 editor/types.ts

5. 实现 Tiptap 适配器

5.1 定义编辑器抽象接口

// editor/types.ts

/**
 * 编辑器命令接口
 *
 * 定义编辑器支持的操作命令
 * 每个方法都是一个独立命令,职责单一
 * 不暴露链式调用等实现细节
 *
 * MenuBar 通过此接口与具体编辑器解耦
 */
export interface EditorCommands {
  // 文本格式化命令
  toggleMark: (mark: string) => void      // 切换文本标记(加粗、斜体等)
  unsetAllMarks: () => void               // 清除所有文本标记
  clearNodes: () => void                  // 清除节点(转为段落)

  // 块级元素命令
  setParagraph: () => void                // 设置为段落
  setHorizontalRule: () => void           // 插入水平线

  // 标题命令(直接传 level,不嵌套对象)
  toggleHeading: (level: number) => void

  // 列表命令
  toggleBulletList: () => void            // 切换无序列表
  toggleOrderedList: () => void           // 切换有序列表

  // 代码和引用命令
  toggleCodeBlock: () => void             // 切换代码块
  toggleBlockquote: () => void            // 切换引用块

  // 历史命令
  undo: () => void                        // 撤销
  redo: () => void                        // 重做
}

/**
 * 编辑器实例接口
 *
 * 只暴露最核心的方法,不绑定具体 API 风格
 */
export interface EditorInstance {
  /**
   * 检查当前选择是否处于激活状态
   * @param name - 节点或标记名称
   * @param attrs - 可选的节点属性(如标题级别 { level: 1 })
   */
  isActive: (name: string, attrs?: Record<string, unknown>) => boolean

  /**
   * 编辑器命令集合
   *
   * 所有编辑操作都通过这个对象执行
   * 内部自动处理焦点等逻辑
   */
  commands: EditorCommands
}

/**
 * Toolbar 组件所需的完整状态
 *
 * 使用类型别名,方便各处引用
 */
export interface ToolbarState {
  // 文本格式化状态
  isBold: boolean; canBold: boolean
  isItalic: boolean; canItalic: boolean
  isStrike: boolean; canStrike: boolean
  isUnderline: boolean; canUnderline: boolean
  isCode: boolean; canCode: boolean
  canClearMarks: boolean

  // 块级元素状态
  isParagraph: boolean
  isHeading1: boolean; isHeading2: boolean; isHeading3: boolean
  isHeading4: boolean; isHeading5: boolean; isHeading6: boolean

  // 列表和块状态
  isBulletList: boolean; isOrderedList: boolean
  isCodeBlock: boolean; isBlockquote: boolean

  // 历史状态
  canUndo: boolean; canRedo: boolean
}

5.2 Tiptap 适配器 Hook

// adapters/tiptap-adapter.ts
import { useEditorState } from '@tiptap/react'
import type { Editor } from '@tiptap/core'

import { toolbarStateSelector } from '../toolbarStateSelector'
import type { EditorInstance, EditorCommands, ToolbarState } from '../editor/types'

/**
 * Tiptap 编辑器适配器 Hook
 *
 * 职责:
 * 1. 订阅 Tiptap 编辑器状态
 * 2. 将 Tiptap 链式 API 适配为简单命令接口
 * 3. 返回适配后的数据和编辑器实例
 *
 * 适配层把 Tiptap 的 chain().focus().toggleBold().run()
 * 转换为简单的 editor.commands.toggleBold()
 */
export function useTiptapAdapter(editor: Editor | null) {
  // 使用 useEditorState 订阅编辑器状态
  const editorState = useEditorState({
    editor: editor!,
    selector: toolbarStateSelector,
  })

  /**
   * 将 Tiptap 的链式调用适配为简单命令接口
   *
   * 每个命令内部处理:
   * - 自动获取焦点(focus)
   * - 执行具体操作
   * - 调用 run()
   *
   * 这样 MenuBar 就不需要知道 Tiptap 的链式 API
   */
  const commands: EditorCommands = {
    toggleMark: (mark: string) => {
      editor!.chain().focus().toggleMark(mark).run()
    },
    unsetAllMarks: () => {
      editor!.chain().focus().unsetAllMarks().run()
    },
    clearNodes: () => {
      editor!.chain().focus().clearNodes().run()
    },
    setParagraph: () => {
      editor!.chain().focus().setParagraph().run()
    },
    setHorizontalRule: () => {
      editor!.chain().focus().setHorizontalRule().run()
    },
    toggleHeading: (level: number) => {
      editor!.chain().focus().toggleHeading({ level }).run()
    },
    toggleBulletList: () => {
      editor!.chain().focus().toggleBulletList().run()
    },
    toggleOrderedList: () => {
      editor!.chain().focus().toggleOrderedList().run()
    },
    toggleCodeBlock: () => {
      editor!.chain().focus().toggleCodeBlock().run()
    },
    toggleBlockquote: () => {
      editor!.chain().focus().toggleBlockquote().run()
    },
    undo: () => {
      editor!.chain().focus().undo().run()
    },
    redo: () => {
      editor!.chain().focus().redo().run()
    },
  }

  /**
   * 构造抽象的编辑器实例
   *
   * 只暴露 isActive 和 commands,不暴露 Tiptap 的 chain/focus 等方法
   */
  const abstractEditor: EditorInstance = {
    isActive: (name, attrs) => editor!.isActive(name, attrs),
    commands,
  }

  return {
    editor: abstractEditor,
    state: editorState as ToolbarState,
  }
}

6. 实现 CodeMirror 适配器

6.1 CodeMirror API 风格

CodeMirror 的 API 风格与 Tiptap 完全不同:

  • 不使用链式调用
  • 使用 dispatch 事件系统
  • Markdown 编辑器直接操作 token

6.2 CodeMirror 适配器 Hook

// adapters/codemirror-adapter.ts
import { useState, useEffect, useCallback } from 'react'
import type { EditorView } from '@codemirror/view'
import type { EditorState } from '@codemirror/state'
import type { EditorInstance, EditorCommands, ToolbarState } from '../editor/types'

/**
 * CodeMirror 编辑器适配器 Hook
 *
 * CodeMirror 的 API 风格与 Tiptap 完全不同:
 * - 不使用链式调用
 * - 使用 dispatch 事件系统
 * - Markdown 编辑器直接操作 token
 *
 * 但这些细节都被适配层屏蔽了
 */
export function useCodeMirrorAdapter(view: EditorView | null) {
  // 状态管理:订阅 editor 的状态变化
  const [toolbarState, setToolbarState] = useState<ToolbarState>(getDefaultState())

  // 订阅 CodeMirror 状态变化
  useEffect(() => {
    if (!view) return

    const updateHandler = () => {
      setToolbarState(computeToolbarState(view))
    }

    view.dom.addEventListener('input', updateHandler)
    return () => view.dom.removeEventListener('input', updateHandler)
  }, [view])

  /**
   * 将 CodeMirror 的 API 适配为简单命令接口
   *
   * CodeMirror 使用 dispatch 事件系统:
   * - 每个操作创建一个 transaction
   * - 通过 view.dispatch() 应用更改
   */
  const commands: EditorCommands = {
    toggleMark: (mark: string) => {
      if (!view) return
      // CodeMirror 操作 markdown token
      const { from, to } = view.state.selection.main
      const selectedText = view.state.sliceDoc(from, to)

      const marker = getMarkerForMark(mark)
      const wrapper = marker + marker

      view.dispatch({
        changes: { from, to, insert: wrapper + selectedText + wrapper },
        selection: { anchor: from + marker.length }
      })
      view.focus()
    },

    unsetAllMarks: () => {
      // 清除 Markdown 格式化 - 遍历 token 移除标记
      if (!view) return
      // 实现略
    },

    clearNodes: () => {
      // 转为段落 - 移除所有块级标记
      if (!view) return
      // 实现略
    },

    setParagraph: () => {
      // 设置段落
      if (!view) return
      // 实现略
    },

    setHorizontalRule: () => {
      if (!view) return
      const { from } = view.state.selection.main
      view.dispatch({
        changes: { from, insert: '\n---\n' }
      })
    },

    toggleHeading: (level: number) => {
      if (!view) return
      const { from } = view.state.selection.main
      const line = view.state.doc.lineAt(from)
      const marker = '#'.repeat(level) + ' '

      // 检查是否是标题:移除或添加 # 前缀
      const match = line.text.match(/^#{1,6}\s/)
      if (match) {
        view.dispatch({
          changes: { from: line.from, to: line.from + match[0].length, insert: marker }
        })
      } else {
        view.dispatch({
          changes: { from: line.from, insert: marker }
        })
      }
      view.focus()
    },

    toggleBulletList: () => {
      if (!view) return
      const { from } = view.state.selection.main
      const line = view.state.doc.lineAt(from)

      const match = line.text.match(/^(\s*)[-*]\s/)
      if (match) {
        view.dispatch({
          changes: { from: line.from, to: line.from + match[0].length, insert: '' }
        })
      } else {
        view.dispatch({
          changes: { from: line.from, insert: '- ' }
        })
      }
      view.focus()
    },

    toggleOrderedList: () => {
      if (!view) return
      const { from } = view.state.selection.main
      const line = view.state.doc.lineAt(from)

      const match = line.text.match(/^(\d+)\.\s/)
      if (match) {
        view.dispatch({
          changes: { from: line.from, to: line.from + match[0].length, insert: '' }
        })
      } else {
        view.dispatch({
          changes: { from: line.from, insert: '1. ' }
        })
      }
      view.focus()
    },

    toggleCodeBlock: () => {
      if (!view) return
      const { from, to } = view.state.selection.main
      const selectedText = view.state.sliceDoc(from, to)

      view.dispatch({
        changes: { from, to, insert: '```\n' + selectedText + '\n```' }
      })
      view.focus()
    },

    toggleBlockquote: () => {
      if (!view) return
      const { from } = view.state.selection.main
      const line = view.state.doc.lineAt(from)

      const match = line.text.match(/^>\s/)
      if (match) {
        view.dispatch({
          changes: { from: line.from, to: line.from + match[0].length, insert: '' }
        })
      } else {
        view.dispatch({
          changes: { from: line.from, insert: '> ' }
        })
      }
      view.focus()
    },

    undo: () => {
      if (!view) return
      view.dispatch({ undo: true })
    },

    redo: () => {
      if (!view) return
      view.dispatch({ redo: true })
    },
  }

  /**
   * 构造抽象的编辑器实例
   */
  const abstractEditor: EditorInstance = {
    isActive: (name, attrs) => {
      // 检查 CodeMirror 的选择状态
      if (!view) return false

      const { from } = view.state.selection.main
      const line = view.state.doc.lineAt(from)
      const lineText = line.text

      switch (name) {
        case 'bold': return lineText.includes('**')
        case 'italic': return lineText.includes('*') && !lineText.includes('**')
        case 'code': return lineText.includes('`')
        case 'heading': return lineText.match(/^#+\s/) !== null
        case 'bulletList': return lineText.match(/^[-*]\s/) !== null
        case 'orderedList': return lineText.match(/^\d+\.\s/) !== null
        case 'codeBlock': return lineText.includes('```')
        case 'blockquote': return lineText.startsWith('> ')
        default: return false
      }
    },
    commands,
  }

  return {
    editor: abstractEditor,
    state: toolbarState,
  }
}

/**
 * 获取默认状态
 */
function getDefaultState(): ToolbarState {
  return {
    isBold: false, canBold: false,
    isItalic: false, canItalic: false,
    isStrike: false, canStrike: false,
    isUnderline: false, canUnderline: false,
    isCode: false, canCode: false,
    canClearMarks: false,
    isParagraph: true,
    isHeading1: false, isHeading2: false, isHeading3: false,
    isHeading4: false, isHeading5: false, isHeading6: false,
    isBulletList: false, isOrderedList: false,
    isCodeBlock: false, isBlockquote: false,
    canUndo: false, canRedo: false,
  }
}

/**
 * 根据 mark 类型获取对应的 Markdown 标记
 */
function getMarkerForMark(mark: string): string {
  switch (mark) {
    case 'bold': return '**'
    case 'italic': return '*'
    case 'code': return '`'
    case 'strike': return '~~'
    case 'underline': return '<u></u>'
    default: return ''
  }
}

/**
 * 从 CodeMirror 状态计算 ToolbarState
 */
function computeToolbarState(view: EditorView): ToolbarState {
  // 实际实现中需要解析 CodeMirror 的 token 来计算状态
  return getDefaultState()
}

7. Toolbar 组件:只依赖接口

7.1 Toolbar 实现

// components/Toolbar.tsx
'use client'

import type { ToolbarState, EditorInstance } from '../editor/types'

interface ToolbarProps {
  editor: EditorInstance | null
  state: ToolbarState
  onSave: () => void
}

/**
 * Toolbar 组件
 *
 * 完全通过抽象接口与编辑器交互,不直接依赖 Tiptap 或 CodeMirror
 * 可以与任何实现了 EditorInstance 接口的编辑器配合使用
 *
 * 核心思想:Toolbar 只关心"做什么",不关心"怎么做"
 */
export const Toolbar = ({
  editor,
  state,
  onSave,
}: ToolbarProps) => {
  // 当没有编辑器实例时,显示加载状态
  if (!editor) {
    return <div>Loading...</div>
  }

  // 点击按钮时切换加粗状态
  // 只需调用命令,无需关心链式调用等实现细节
  const toggleMark = (mark: string) => {
    editor.commands.toggleMark(mark)
  }

  return (
    <div className="toolbar">
      {/* 保存按钮 */}
      <button onClick={onSave}>Save</button>

      {/* 历史操作 */}
      <button
        onClick={() => editor.commands.undo()}
        disabled={!state.canUndo}
      >
        Undo
      </button>
      <button
        onClick={() => editor.commands.redo()}
        disabled={!state.canRedo}
      >
        Redo
      </button>

      <span className="separator">|</span>

      {/* 文本格式化 */}
      <button
        onClick={() => toggleMark('bold')}
        disabled={!state.canBold}
        className={state.isBold ? 'active' : ''}
      >
        Bold
      </button>
      <button
        onClick={() => toggleMark('italic')}
        disabled={!state.canItalic}
        className={state.isItalic ? 'active' : ''}
      >
        Italic
      </button>
      <button
        onClick={() => toggleMark('underline')}
        disabled={!state.canUnderline}
        className={state.isUnderline ? 'active' : ''}
      >
        Underline
      </button>
      <button
        onClick={() => toggleMark('strike')}
        disabled={!state.canStrike}
        className={state.isStrike ? 'active' : ''}
      >
        Strike
      </button>
      <button
        onClick={() => toggleMark('code')}
        disabled={!state.canCode}
        className={state.isCode ? 'active' : ''}
      >
        Code
      </button>

      <span className="separator">|</span>

      {/* 块级元素 */}
      <button onClick={() => editor.commands.toggleHeading(1)}>H1</button>
      <button onClick={() => editor.commands.toggleHeading(2)}>H2</button>
      <button onClick={() => editor.commands.toggleHeading(3)}>H3</button>

      <span className="separator">|</span>

      {/* 列表和引用 */}
      <button
        onClick={() => editor.commands.toggleBulletList()}
        className={state.isBulletList ? 'active' : ''}
      >
        Bullet List
      </button>
      <button
        onClick={() => editor.commands.toggleOrderedList()}
        className={state.isOrderedList ? 'active' : ''}
      >
        Ordered List
      </button>
      <button
        onClick={() => editor.commands.toggleCodeBlock()}
        className={state.isCodeBlock ? 'active' : ''}
      >
        Code Block
      </button>
      <button
        onClick={() => editor.commands.toggleBlockquote()}
        className={state.isBlockquote ? 'active' : ''}
      >
        Quote
      </button>
    </div>
  )
}

7.2 使用方式

Toolbar 和编辑器是平级组合关系:

// App.tsx
function App() {
  const editor = useEditor({ extensions: [StarterKit] })
  const { editor: abstractEditor, state } = useTiptapAdapter(editor)

  return (
    <div className="editor">
      <Toolbar editor={abstractEditor} state={state} onSave={handleSave} />
      <EditorContent editor={editor} />
    </div>
  )
}

7.3 切换编辑器的流程

┌─────────────────────────────────────────────────────────────┐
│  架构:Toolbar 和 EditorContent 是平级                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │  Editor (创建 editor)                                │   │
│   │                                                     │   │
│   │   ┌──────────────┐      ┌──────────────┐             │   │
│   │   │   Toolbar    │      │EditorContent │             │   │
│   │   │   (平级)      │      │   (平级)     │             │   │
│   │   └──────────────┘      └──────────────┘             │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                             │
│   切换编辑器时:替换 TiptapEditor 为 CodeMirrorEditor      │
│   Toolbar 零修改                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7.4 设计原则总结

┌─────────────────────────────────────────────────────────────┐
│                      核心原则                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 接口最小化                                              │
│     只暴露 Toolbar 真正需要的方法                           │
│     不暴露编辑器特有的一切 API                               │
│                                                             │
│  2. 命令自包含                                              │
│     每个命令内部处理焦点、运行等逻辑                         │
│     Toolbar 调用时只需 editor.commands.toggleMark('bold')   │
│                                                             │
│  3. 状态外部计算                                            │
│     状态订阅发生在 Adapter 层                               │
│     Toolbar 只是被动接收 props                               │
│                                                             │
│  4. 适配层隔离                                              │
│     每个编辑器有自己的 Adapter                              │
│     切换时 Toolbar 零修改                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

8. React Hook 指南

8.1 什么是 Hook?

Hook 是 React 16.8 引入的特性,允许在函数组件中使用 state 和生命周期。本质上就是一个接收参数并返回数据的函数

8.2 常见内置 Hooks

useState - 状态管理

const [count, setCount] = useState(0)  // [当前值, 更新函数]

// 直接更新
setCount(count + 1)

// 函数式更新(推荐,避免依赖旧值)
setCount(prev => prev + 1)

// 更新对象(需要展开或使用函数式更新)
setUser(prev => ({ ...prev, name: 'Alice' }))

useEffect - 副作用处理

/**
 * useEffect 的三个变体:
 */

// 1. 不依赖任何值 - 只在挂载时执行一次(类似 componentDidMount)
useEffect(() => {
  const subscription = subscribeToData()
  return () => subscription.unsubscribe()  // 清理函数
}, [])

// 2. 依赖特定值 - 值变化时重新执行
useEffect(() => {
  document.title = `Count: ${count}`
}, [count])

// 3. 无依赖数组 - 每次渲染都执行(不推荐,可能导致性能问题)
useEffect(() => {
  doSomething()
})

useRef - 持久化引用

/**
 * useRef 的两个用途:
 */

// 1. 存储会在渲染间保持的值(不触发重渲染)
const timerRef = useRef<number>(null)

useEffect(() => {
  timerRef.current = setInterval(() => tick(), 1000)
  return () => clearInterval(timerRef.current!)
}, [])

// 2. 访问 DOM 元素
const inputRef = useRef<HTMLInputElement>(null)

const focusInput = () => {
  inputRef.current?.focus()
}

useCallback - 缓存函数

/**
 * useCallback 缓存函数引用
 *
 * 作用:避免子组件因为函数引用变化而不必要的重渲染
 * @param fn - 要缓存的函数
 * @param deps - 依赖数组
 */
const handleClick = useCallback((id: string) => {
  doSomething(id)
}, [id])  // id 不变时,返回相同函数引用

// 当 id 变化时,才会返回新的函数

useMemo - 缓存计算结果

/**
 * useMemo 缓存昂贵计算的结果
 *
 * @param factory - 计算函数
 * @param deps - 依赖数组
 */
const expensiveValue = useMemo(() => {
  return items.reduce((sum, item) => sum + item.price, 0)
}, [items])  // items 不变时,返回缓存值

useContext - 跨组件传值

// 1. 创建 Context
const ThemeContext = createContext<'light' | 'dark'>('light')

// 2. Provider 提供值
<ThemeContext.Provider value="dark">
  {children}
</ThemeContext.Provider>

// 3. 消费 Context(无需层层 prop drilling)
const theme = useContext(ThemeContext)

8.3 自定义 Hook

将可复用逻辑提取为自定义 Hook:

// hooks/useLocalStorage.ts

/**
 * 将 state 同步到 localStorage 的 Hook
 *
 * @param key - localStorage 的键名
 * @param initialValue - 初始值
 * @returns [值, 设置值] 元组
 */
function useLocalStorage<T>(key: string, initialValue: T) {
  // 初始化时从 localStorage 读取(SSR 安全)
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue
    const stored = localStorage.getItem(key)
    return stored ? JSON.parse(stored) : initialValue
  })

  // 值变化时同步到 localStorage
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue] as const
}

// 使用
const [name, setName] = useLocalStorage('name', 'Alice')
// hooks/useWindowSize.ts

/**
 * 监听窗口尺寸变化的 Hook
 *
 * @returns 包含宽度和高度的尺寸对象
 */
function useWindowSize() {
  const [size, setSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  })

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }

    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  }, [])

  return size
}

9. 最佳实践

9.1 Hooks 调用规则

React 依赖调用顺序来追踪 state,必须遵守以下规则

// ❌ 错误:在条件语句中调用 Hook
function Component({ showCount }) {
  if (showCount) {
    const [count, setCount] = useState(0)  // 可能不执行,破坏 state 顺序
  }
}

// ❌ 错误:在循环中调用 Hook
function Component({ items }) {
  items.forEach(item => {
    const [state, setState] = useState(item.id)  // 每次渲染执行次数不同
  })
}

// ✅ 正确:始终在组件顶层调用 Hook
function Component({ showCount }) {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('')

  if (showCount) {
    // 可以有条件语句,但不能改变 Hook 调用顺序
  }
}

9.2 依赖数组要完整

// ❌ 错误:遗漏依赖导致 stale closure
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1)  // count 是旧值,永远是 0
  }, 1000)
}, [])  // 没有依赖,timer 只创建一次

// ✅ 正确:包含所有依赖
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1)  // 使用函数式更新
  }, 1000)
  return () => clearInterval(timer)
}, [])  // 如果确实不需要依赖,明确使用空数组

// ✅ 正确:或者使用 useRef 保存函数
const increment = useCallback(() => setCount(prev => prev + 1), [])
useEffect(() => {
  const timer = setInterval(increment, 1000)
  return () => clearInterval(timer)
}, [increment])

9.3 useEffect 中的 async 函数

// ❌ 错误:useEffect 不能直接返回 async 函数
useEffect(async () => {
  const data = await fetch('/api/user')
  setUser(data)
}, [])

// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  async function fetchUser() {
    const data = await fetch('/api/user')
    setUser(data)
  }
  fetchUser()
}, [])

// ✅ 或者使用 IIFE
useEffect(() => {
  ;(async () => {
    const data = await fetch('/api/user')
    setUser(data)
  })()
}, [])

9.4 useCallback 使用场景

// ❌ 过度使用:没有传递给子组件或用于依赖数组时不需要
const handleClick = useCallback(() => {
  console.log('clicked')
}, [])

// ✅ 必要场景:传递给子组件时
const Parent = () => {
  const [count, setCount] = useState(0)

  // 每次 count 变化都创建新函数
  // 没有 useCallback,Child 会不必要地重渲染
  const handleClick = useCallback(() => {
    setCount(prev => prev + 1)
  }, [])

  return <Child onClick={handleClick} />
}

const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  return <button onClick={onClick}>Click</button>
})

9.5 use 前缀约定

// ✅ 必须:以 use 开头,React 才能识别为 Hook
function useAuth() {}
function useWindowSize() {}
function useQuery(endpoint: string) {}

// ❌ 不要这样命名:ESLint 会报警告
function getUserData() {}
function calculateTotal(items: Item[]) {}

9.6 状态提升 vs Collocation

// ✅ 好的实践:将状态放在需要它的最近层级
function Parent() {
  const [count, setCount] = useState(0)
  return <Child count={count} onIncrement={() => setCount(c => c + 1)} />
}

// ❌ 过度提升:状态放在太高层级
function GrandParent() {
  const [count, setCount] = useState(0)
  return (
    <Parent>
      <Child count={count} onIncrement={() => setCount(c => c + 1)} />
    </Parent>
  )
}

9.7 性能优化优先级

  1. 先解决真实的性能问题,不要过早优化
  2. 使用 React DevTools Profiler 定位瓶颈
  3. 优先考虑:
    • React.memo 包裹纯展示组件
    • useCallback / useMemo 减少不必要的重渲染
    • 虚拟列表处理长列表
  4. 最后才考虑 useMemo 优化计算

参考资料