本教程介绍如何设计一个可切换编辑引擎的编辑器组件。通过适配器模式,我们将具体编辑器的 API 抽象为统一接口,使 UI 组件与编辑器实现解耦。
目录
- 1. 问题引出
- 2. 适配器模式设计
- 3. useEditorState 详解
- 4. 状态选择器模式
- 5. 实现 Tiptap 适配器
- 6. 实现 CodeMirror 适配器
- 7. Toolbar 组件:只依赖接口
- 8. React Hook 指南
- 9. 最佳实践
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 模式的优势
- 性能优化:只订阅关心的状态,减少重渲染
- 可测试性:selector 是纯函数,易于单元测试
- 类型统一: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 性能优化优先级
- 先解决真实的性能问题,不要过早优化
- 使用 React DevTools Profiler 定位瓶颈
- 优先考虑:
React.memo包裹纯展示组件useCallback/useMemo减少不必要的重渲染- 虚拟列表处理长列表
- 最后才考虑
useMemo优化计算