React 组件封装方法论 —— 以 Todo App 为例

本文档结合当前项目的 Todo App 实现,系统讲解 React 组件封装的通用方法论。 所有代码位置均以项目实际文件为准,每一节都配有实战案例与反例对比。


目录

  1. 整体架构
  2. 三层封装模型
  3. [Provider 层详解](#Provider 层详解 "#3-provider-%E5%B1%82%E8%AF%A6%E8%A7%A3")
  4. 子组件层详解
  5. 主容器层详解
  6. 通用拆分流程
  7. 常见陷阱与规避
  8. 目录结构规范
  9. 演进路线
  10. 实战:复杂业务组件拆分全过程
  11. [进阶:Context 拆分与性能优化](#进阶:Context 拆分与性能优化 "#11-%E8%BF%9B%E9%98%B6context-%E6%8B%86%E5%88%86%E4%B8%8E%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96")
  12. 进阶:状态机管理复杂状态
  13. [进阶:组合式 Hook 设计模式](#进阶:组合式 Hook 设计模式 "#13-%E8%BF%9B%E9%98%B6%E7%BB%84%E5%90%88%E5%BC%8F-hook-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F")
  14. 附录:核心原理速查
  15. [进阶:复合组件模式 Compound Components](#进阶:复合组件模式 Compound Components "#15-%E8%BF%9B%E9%98%B6%E5%A4%8D%E5%90%88%E7%BB%84%E4%BB%B6%E6%A8%A1%E5%BC%8F-compound-components")
  16. [进阶:受控 vs 非受控](#进阶:受控 vs 非受控 "#16-%E8%BF%9B%E9%98%B6%E5%8F%97%E6%8E%A7-vs-%E9%9D%9E%E5%8F%97%E6%8E%A7")
  17. 进阶:性能优化全景
  18. 进阶:调试技巧
  19. 进阶:设计系统与组件库构建
  20. [进阶:Headless Component 模式](#进阶:Headless Component 模式 "#20-%E8%BF%9B%E9%98%B6headless-component-%E6%A8%A1%E5%BC%8F")
  21. [进阶:Server Components 与 Suspense](#进阶:Server Components 与 Suspense "#21-%E8%BF%9B%E9%98%B6server-components-%E4%B8%8E-suspense")

1. 整体架构

scss 复制代码
 ┌─────────────────────────────────────────────────┐
 │              src/App.tsx                        │
 │                  应用入口                        │
 └───────────────────────┬─────────────────────────┘
                         │
                         ▼
 ┌─────────────────────────────────────────────────┐
 │         src/components/todo/TodoApp.tsx          │
 │     主容器:只用 Provider 包裹 + 组装子组件      │
 └───────────────────────┬─────────────────────────┘
                         │
                         ▼
 ┌─────────────────────────────────────────────────┐
 │  src/components/todo/context/TodoContext.tsx    │
 │   Provider 层:集中管理 state + effect + API    │
 └───────────────────────┬─────────────────────────┘
                         │
         ┌───────────────┼───────────────┬───────────────┐
         ▼               ▼               ▼               ▼
  Header.tsx      TodoInput.tsx    TodoList.tsx    TodoFooter.tsx
  (components/)     子组件层:通过 useTodos() 按需获取数据与方法

架构分层核心思想

层级 职责关键词 典型文件
入口层 路由、全局 Provider App.tsx, main.tsx
容器层 组装、布局 TodoApp.tsx
逻辑层 状态、副作用、API TodoContext.tsx
展示层 JSX、样式 Header.tsx, TodoInput.tsx
工具层 纯函数、计算 useTodoStats.ts, styles.ts

2. 三层封装模型

React 组件封装通常分为三层,每层职责单一、互不越界:

第 1 层:Provider 层(context/TodoContext.tsx

职责:集中管理所有状态、副作用和操作方法,通过 Context 对外暴露统一 API。

包含内容

  • useState 状态
  • useEffect 副作用(持久化、全局事件、自动聚焦)
  • useCallback 操作方法
  • 组合其他 Hook(如 hooks/useTodoStats.ts
  • 统一的 value 对象

不做什么:不写 JSX、不关心 UI 长什么样。

第 2 层:子组件层(components/*.tsx

职责 :纯展示,通过 useTodos() 从 Context 按需自取数据。

包含内容

  • 仅 JSX + 样式
  • 调用 useTodos() 取自己需要的 state / method
  • 使用 React.memo 避免无意义重渲染

不做什么:不持有业务状态、不写 useEffect、不做数据计算。

第 3 层:主容器层(TodoApp.tsx

职责:只做两件事 ------ 用 Provider 包裹、组装子组件。

包含内容

  • 一对 <TodoProvider>...</TodoProvider>
  • 子组件的组合顺序

不做什么:不写业务逻辑、不写 props 透传、不写样式。

三层封装的好处

scss 复制代码
❌ 反例:大泥球组件(500+ 行)
┌─────────────────────────────────────────────┐
│ TodoApp.tsx                                  │
│  ├── useState (20 个)                        │
│  ├── useEffect (5 个)                        │
│  ├── 方法 (15 个)                            │
│  ├── JSX (300 行)                            │
│  └── 样式 (100 行)                           │
│  问题:改一个按钮要翻 500 行代码              │
└─────────────────────────────────────────────┘

✅ 正例:三层封装
┌─────────────────────────────────────────────┐
│ TodoApp.tsx (50 行)                          │
│  └── 只做 Provider 包裹 + 子组件组装         │
├─────────────────────────────────────────────┤
│ TodoContext.tsx (250 行)                      │
│  └── 只做状态管理 + 方法封装                 │
├─────────────────────────────────────────────┤
│ components/ (每个 20-50 行)                  │
│  └── 只做展示                                │
└─────────────────────────────────────────────┘

3. Provider 层详解

文件位置:context/TodoContext.tsx

3.1 定义 Context 值的类型

ts 复制代码
export interface TodoContextValue {
  // 状态(只读给消费者)
  todos: Todo[]
  filter: Filter
  input: string
  editingId: number | null
  editingText: string
  stats: TodoStats
  filteredTodos: Todo[]
  maxTextLen: number
  loading: boolean
  error: string | null
  // 方法(消费者调用)
  setFilter: (f: Filter) => void
  setInput: (v: string) => void
  setEditingText: (v: string) => void
  addTodo: () => Promise<void>
  toggleDone: (id: number) => Promise<void>
  deleteTodo: (id: number) => Promise<void>
  startEdit: (todo: Todo) => void
  commitEdit: () => Promise<void>
  cancelEdit: () => void
  clearCompleted: () => Promise<void>
  clearAll: () => Promise<void>
  handleInputKey: (e: React.KeyboardEvent<HTMLInputElement>) => void
  handleEditKey: (e: React.KeyboardEvent<HTMLInputElement>) => void
  refetch: () => Promise<void>
  clearError: () => void
  editInputRef: React.RefObject<HTMLInputElement | null>
}

要点

  • 类型是"契约",所有组件按此契约消费数据
  • 把 state + method 都列出来,一目了然
  • 单独抽出接口类型,便于测试与维护
  • 状态分两类 :"原始状态"(todos, filter)和"派生状态"(stats, filteredTodos

3.2 创建 Context

ts 复制代码
const TodoContext = createContext<TodoContextValue | null>(null)

要点

  • 使用 null 作为默认值(不是 undefined
  • 在 Consumer Hook 里做空值检查并抛错,避免忘记套 Provider
  • nullundefined 更明确:undefined 容易被当成"合法值"

3.3 Provider 组件的三种形态

形态 A:简单版(所有 state 在一个 Provider 里)

tsx 复制代码
export function TodoProvider({ children }: { children: ReactNode }) {
  const [todos, setTodos] = useState<Todo[]>([])
  // ... 所有 state
  const value: TodoContextValue = { /* ... */ }
  return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>
}

形态 B:支持 props 配置(当前项目使用)

tsx 复制代码
interface TodoProviderProps {
  children: ReactNode
  maxTextLen?: number          // 可配置的最大输入长度
  useApi?: boolean             // 是否启用 API 模式
  initialFilter?: Filter        // 初始筛选条件
}

export function TodoProvider({
  children,
  maxTextLen = 80,
  useApi = true,
  initialFilter = 'all',
}: TodoProviderProps) {
  const [filter, setFilter] = useState<Filter>(initialFilter)
  // ... 其余 state
}

适用场景:需要在不同地方复用 Provider,且配置不同。

形态 C:工厂函数(高级)

tsx 复制代码
function createTodoProvider(config: TodoConfig) {
  return function TodoProvider({ children }: { children: ReactNode }) {
    const [todos, setTodos] = useState<Todo[]>(config.initialTodos)
    // ...
  }
}

export const AdminTodoProvider = createTodoProvider({ maxTextLen: 200 })
export const UserTodoProvider = createTodoProvider({ maxTextLen: 80 })

3.4 Consumer Hook

ts 复制代码
export function useTodos(): TodoContextValue {
  const ctx = useContext(TodoContext)
  if (!ctx) {
    throw new Error('useTodos must be used within a <TodoProvider>')
  }
  return ctx
}

要点

  • 对外暴露统一入口,调用者不直接接触 Context
  • 错误信息明确,便于调试("忘记套 Provider"是最常见错误)
  • 可以在这里做额外的封装(如默认值、日志、DevTools 集成)

3.5 Provider 内的副作用管理

Provider 内的 useEffect 主要处理 3 类副作用:

类型 示例 清理逻辑 依赖项
数据获取 todoApi.list() 取消请求(AbortController useApi
持久化 localStorage.setItem 不需要清理 todos
DOM 操作 inputRef.current.focus() 由 React 生命周期自动处理 editingId
全局事件 window.addEventListener('keydown', ...) 必须返回 removeEventListener editingId

代码示例:初始化加载 + 取消请求

tsx 复制代码
useEffect(() => {
  if (!useApi) return
  let cancelled = false

  const controller = new AbortController()

  const load = async () => {
    setLoading(true)
    try {
      const dtos = await todoApi.list()
      if (!cancelled) {
        setTodos(dtoListToTodos(dtos))
        localStorage.removeItem(STORAGE_KEY)
      }
    } catch (err) {
      if (!cancelled) handleError(err)
    } finally {
      if (!cancelled) setLoading(false)
    }
  }

  load()
  return () => {
    cancelled = true
    controller.abort()
  }
}, [useApi, handleError])

代码示例:全局 Esc 监听

tsx 复制代码
useEffect(() => {
  const handleKey = (e: KeyboardEvent) => {
    if (e.key === 'Escape' && editingId !== null) {
      setEditingId(null)
      setEditingText('')
    }
  }
  window.addEventListener('keydown', handleKey)
  return () => window.removeEventListener('keydown', handleKey)
}, [editingId])

3.6 乐观更新模式(Optimistic Update)

这是当前项目的一大亮点:先更新 UI,再发请求,失败则回滚

tsx 复制代码
const toggleDone = useCallback(
  async (id: number) => {
    const todo = todos.find((t) => t.id === id)
    const prevDone = todo?.done ?? false

    // ① 乐观更新:立即修改 UI
    setTodos((list) =>
      list.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    )

    // ② 发请求
    if (!useApi) return
    try {
      const dto = await todoApi.toggle(id)
      // 成功:用服务器数据覆盖
      setTodos((list) => list.map((t) => (t.id === id ? dtoToTodo(dto) : t)))
    } catch (err) {
      // ③ 失败回滚:恢复到之前的状态
      setTodos((list) =>
        list.map((t) => (t.id === id ? { ...t, done: prevDone } : t))
      )
      handleError(err)
    }
  },
  [todos, useApi, handleError]
)

乐观更新的三步骤

  1. 先改:立即更新 state,让用户看到即时反馈
  2. 再请求:调用 API
  3. 成功则覆盖,失败则回滚:保持数据一致性

4. 子组件层详解

文件位置:components/

4.1 标准结构

每个子组件遵循统一结构:

tsx 复制代码
import { memo } from 'react'
import { useTodos } from '../context/TodoContext'
import { styles } from '../styles'

function TodoInputImpl() {
  const { input, setInput, addTodo, handleInputKey, maxTextLen } = useTodos()

  return (
    <div style={styles.inputRow}>
      <input
        value={input}
        maxLength={maxTextLen}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={handleInputKey}
        placeholder="添加新待办..."
      />
      <button onClick={addTodo}>添加</button>
    </div>
  )
}

export const TodoInput = memo(TodoInputImpl)

4.2 按需取数(最小依赖原则)

组件只取自己需要的字段,不要贪多

tsx 复制代码
// TodoInput 只关心输入和添加逻辑
const { input, setInput, addTodo, handleInputKey, maxTextLen } = useTodos()

// TodoProgress 只关心统计数据
const { stats } = useTodos()

// TodoFilter 只关心筛选
const { filter, setFilter } = useTodos()

// TodoFooter 只关心列表和批量操作
const { todos, stats, clearCompleted, clearAll } = useTodos()

好处:每个组件的依赖都显式可见,修改时容易定位影响范围。

4.3 React.memo 深度解析

tsx 复制代码
export const TodoInput = memo(TodoInputImpl)

memo 的工作原理

  • React 会对组件的 props 做浅比较
  • 如果 props 没变,就跳过重新渲染
  • 注意:Context 的值变化仍然会触发重新渲染(memo 管不到 Context)

什么时候该加 memo

场景 是否使用 原因
列表项(如 TodoItem ✅ 是 列表重渲时避免所有项跟着重渲
父组件频繁重渲染,子组件 props 少 ✅ 是 减少不必要的计算
组件非常简单(< 10 行) ❌ 否 memo 本身有比较开销
props 每次都在变 ❌ 否 memo 不会有任何效果
组件使用了 Context ⚠️ 谨慎 memo 对 Context 值变化无效

memo 失效的常见原因

tsx 复制代码
// ❌ 每次渲染创建新对象,memo 失效
const styles = { color: 'red' }
<TodoItem style={styles} />

// ✅ 用 useMemo 缓存
const styles = useMemo(() => ({ color: 'red' }), [])
<TodoItem style={styles} />

// ❌ 每次渲染创建新函数,memo 失效
<TodoItem onClick={() => doSomething(id)} />

// ✅ 用 useCallback 缓存
const handleClick = useCallback(() => doSomething(id), [id])
<TodoItem onClick={handleClick} />

4.4 可组合 Hook

文件位置:hooks/useTodoStats.ts

ts 复制代码
export function useTodoStats(todos: Todo[]): TodoStats {
  return useMemo(() => {
    const total = todos.length
    const completed = todos.filter((t) => t.done).length
    const percent = total === 0 ? 0 : Math.round((completed / total) * 100)
    return { total, completed, active: total - completed, percent }
  }, [todos])
}

设计原则

  • 单一职责:只做统计
  • 无副作用:不读 localStorage、不订阅事件
  • 纯函数:输入相同,输出相同
  • 可单独测试:不需要 React 环境也能验证逻辑

更多可组合 Hook 示例

ts 复制代码
// 输入框字符计数
export function useCharCount(text: string, max: number) {
  return useMemo(() => ({
    length: text.length,
    remaining: max - text.length,
    overflow: text.length > max,
  }), [text, max])
}

// 键盘快捷键监听
export function useKeyboardShortcut(
  key: string,
  handler: (e: KeyboardEvent) => void
) {
  useEffect(() => {
    const listener = (e: KeyboardEvent) => {
      if (e.key === key) handler(e)
    }
    window.addEventListener('keydown', listener)
    return () => window.removeEventListener('keydown', listener)
  }, [key, handler])
}

// 本地存储 Hook
export function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const raw = localStorage.getItem(key)
      return raw ? (JSON.parse(raw) as T) : initial
    } catch {
      return initial
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch {
      // ignore
    }
  }, [key, value])

  return [value, setValue] as const
}

5. 主容器层详解

文件位置:TodoApp.tsx

5.1 标准结构

tsx 复制代码
const MAX_TEXT_LEN = 80

function TodoLayout() {
  return (
    <div style={styles.container}>
      <Header title="📋 待办清单 Pro" />
      <TodoProgress />
      <TodoInput />
      <TodoFilter />
      <TodoList />
      <TodoFooter />
    </div>
  )
}

export default function TodoApp() {
  return (
    <TodoProvider maxTextLen={MAX_TEXT_LEN}>
      <TodoLayout />
    </TodoProvider>
  )
}

5.2 为什么分成两个组件

tsx 复制代码
// ❌ 反例:写在一个组件里
function TodoApp() {
  // 问题:无法把 Layout 放在 Provider 内部
  // 所有子组件都拿不到 Context
  return (
    <TodoProvider>
      <div>
        <Header />
        <TodoInput />  {/* ❌ useTodos() 会抛错 */}
      </div>
    </TodoProvider>
  )
}

// ✅ 正例:分成两个组件
function TodoLayout() {
  return (
    <div>
      <Header />
      <TodoInput />  {/* ✅ 可以正常 useTodos() */}
    </div>
  )
}

function TodoApp() {
  return (
    <TodoProvider>
      <TodoLayout />
    </TodoProvider>
  )
}

5.3 布局组合的灵活性

tsx 复制代码
// 移动端布局
function TodoMobileLayout() {
  return (
    <MobileView>
      <Header />
      <TodoInput />
      <TodoFilter />
      <TodoList />
      <TodoFooter />
    </MobileView>
  )
}

// 桌面端布局
function TodoDesktopLayout() {
  return (
    <DesktopView>
      <Sidebar>
        <Header />
        <TodoFilter />
        <TodoStats />
      </Sidebar>
      <MainContent>
        <TodoInput />
        <TodoList />
      </MainContent>
      <Footer>
        <TodoFooter />
      </Footer>
    </DesktopView>
  )
}

// 根据设备选择布局
function TodoApp() {
  return (
    <TodoProvider>
      {isMobile ? <TodoMobileLayout /> : <TodoDesktopLayout />}
    </TodoProvider>
  )
}

6. 通用拆分流程

当你接手一个 500+ 行的大组件时,按以下步骤拆分:

步骤 1:枚举所有 UI 块

在组件代码里用注释标记出视觉上独立的块:

tsx 复制代码
function BigComponent() {
  return (
    <div>
      {/* 输入区 */}
      <div>...</div>

      {/* 进度条 */}
      <div>...</div>

      {/* 筛选栏 */}
      <div>...</div>

      {/* 列表 */}
      <div>...</div>

      {/* 底部操作栏 */}
      <div>...</div>
    </div>
  )
}

步骤 2:抽出类型定义

把所有 interface / type 抽到 types.ts

ts 复制代码
// types.ts
export interface Todo { /* ... */ }
export type Filter = 'all' | 'active' | 'completed'
export interface TodoStats { /* ... */ }

步骤 3:抽出共享样式

把所有样式对象抽到 styles.ts(如果用 CSS Modules / Tailwind 则跳过)。

ts 复制代码
// styles.ts
export const styles = {
  container: { /* ... */ },
  inputRow: { /* ... */ },
  // ...
}

步骤 4:抽出可组合 Hook

识别纯计算的逻辑,抽成独立 Hook。

ts 复制代码
// hooks/useTodoStats.ts
export function useTodoStats(todos: Todo[]) { /* ... */ }

// hooks/useKeyboardShortcut.ts
export function useKeyboardShortcut(key: string, handler: Function) { /* ... */ }

步骤 5:建立 Provider

把 state / effect / method 集中到 context/TodoContext.tsx

tsx 复制代码
// context/TodoContext.tsx
const TodoContext = createContext<TodoContextValue | null>(null)

export function TodoProvider({ children }: { children: ReactNode }) {
  // 所有 state / effect / method 都在这里
  return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>
}

export function useTodos() { /* ... */ }

步骤 6:逐个抽离子组件

按 UI 块拆分,每个组件通过 useTodos() 自取数据。

tsx 复制代码
// components/TodoInput.tsx
export const TodoInput = memo(function TodoInput() {
  const { input, setInput, addTodo } = useTodos()
  return <div>...</div>
})

步骤 7:简化主容器

删除所有 props 透传,只保留 Provider 包裹和子组件组装。

tsx 复制代码
// TodoApp.tsx
export default function TodoApp() {
  return (
    <TodoProvider>
      <TodoLayout />
    </TodoProvider>
  )
}

步骤 8:更新 barrel

index.ts 里用 export { ... } 统一对外暴露。

ts 复制代码
// index.ts
export { TodoApp } from './TodoApp'
export { TodoProvider, useTodos } from './context/TodoContext'
export type { Todo, Filter, TodoStats } from './types'

拆分流程图

markdown 复制代码
1. 标记 UI 块          →  2. 抽 types.ts       →  3. 抽 styles.ts
   ↓                                                       ↓
6. 逐个抽子组件  ←  5. 建立 Provider  ←  4. 抽 Hook
   ↓
7. 简化主容器          →  8. 更新 barrel

7. 常见陷阱与规避

陷阱 1:Context 导致不必要的重渲染

问题:Provider 内任何 state 变化会触发所有 Consumer 重渲染。

规避

  • useCallback 缓存所有方法
  • useMemo 缓存派生数据
  • 子组件用 React.memo 包裹
  • 终极方案:拆分多个 Context(见第 11 章)

陷阱 2:把所有东西都塞进 Context

问题:Context 越来越大,任何变化都触发全局重渲染。

规避

  • 按功能模块拆分多个 Context(如 TodoContextThemeContextUserContext
  • 只放"真的跨组件共享"的状态
  • 组件私有 state 保持在组件内部
  • 临时 UI 状态(如 hover、focus)不要放 Context

陷阱 3:忘记清理副作用

问题:全局事件、定时器未清理,导致内存泄漏。

tsx 复制代码
// ❌ 错误:忘记清理
useEffect(() => {
  window.addEventListener('keydown', handler)
}, [])

// ✅ 正确:返回清理函数
useEffect(() => {
  window.addEventListener('keydown', handler)
  return () => window.removeEventListener('keydown', handler)
}, [])

陷阱 4:闭包陷阱

问题:事件处理器捕获了旧的 state 值。

tsx 复制代码
// ❌ 错误:todos 可能是旧值
const addTodo = () => setTodos([...todos, newTodo])

// ✅ 正确:函数式更新
const addTodo = () => setTodos((prev) => [...prev, newTodo])

闭包陷阱的典型场景

tsx 复制代码
// ❌ 定时器里的 state 是旧值
useEffect(() => {
  const timer = setInterval(() => {
    console.log(todos.length)  // 永远输出初始值
  }, 1000)
  return () => clearInterval(timer)
}, [])

// ✅ 用 ref 保持最新值
const todosRef = useRef(todos)
todosRef.current = todos

useEffect(() => {
  const timer = setInterval(() => {
    console.log(todosRef.current.length)  // 总是最新值
  }, 1000)
  return () => clearInterval(timer)
}, [])

陷阱 5:列表 key 用 index

问题:列表重排时 DOM 会错乱。

tsx 复制代码
// ❌ 错误
items.map((item, index) => <div key={index}>{item.name}</div>)

// ✅ 正确:用稳定的 id
items.map((item) => <div key={item.id}>{item.name}</div>)

什么时候可以用 index 作为 key

  • 列表是静态的(不会增删)
  • 列表不会重排
  • 没有其它稳定的 id

陷阱 6:把派生状态存进 state

问题stats 是从 todos 派生出来的,却存成独立 state。

tsx 复制代码
// ❌ 错误:派生状态存成 state
const [todos, setTodos] = useState<Todo[]>([])
const [stats, setStats] = useState<TodoStats>({ total: 0, /*... */ })

// 每次 todos 变化都要手动同步 stats
useEffect(() => {
  setStats(calcStats(todos))
}, [todos])

// ✅ 正确:用 useMemo 计算
const stats = useMemo(() => calcStats(todos), [todos])

陷阱 7:在 useEffect 里做不该做的事

tsx 复制代码
// ❌ 错误:useEffect 里做纯计算
const sortedTodos = useEffect(() => {
  return todos.sort((a, b) => a.text.localeCompare(b.text))
}, [todos])  // 这是 useMemo 的工作

// ✅ 正确:用 useMemo
const sortedTodos = useMemo(() => {
  return [...todos].sort((a, b) => a.text.localeCompare(b.text))
}, [todos])

陷阱 8:Props 层层透传(Prop Drilling)

问题:A → B → C → D,D 需要 A 的数据,中间 B、C 被迫透传。

tsx 复制代码
// ❌ 错误:props 层层传递
function App() {
  return <Page todos={todos} />
}
function Page({ todos }) {
  return <List todos={todos} />
}
function List({ todos }) {
  return <Item todos={todos} />  // 只有 Item 需要 todos
}

// ✅ 正确:用 Context 直接获取
function App() {
  return <TodoProvider><Page /></TodoProvider>
}
function Page() {
  return <List />
}
function List() {
  return <Item />
}
function Item() {
  const { todos } = useTodos()  // 直接获取
}

8. 目录结构规范

css 复制代码
src/components/
└── feature/                    ← 功能模块(如 todo / auth / dashboard)
    ├── index.ts                ← Barrel 导出
    ├── types.ts                ← 类型定义(根)
    ├── styles.ts               ← 共享样式(根)
    ├── context/                ← Provider 层
    │   └── FeatureContext.tsx
    ├── hooks/                  ← 可组合 Hook
    │   └── useFeatureStats.ts
    ├── utils/                  ← 纯函数工具
    │   └── format.ts
    └── components/             ← 展示子组件
        ├── Header.tsx
        ├── Item.tsx
        ├── List.tsx
        ├── Footer.tsx
        └── EmptyState.tsx

设计原则

  • 根目录 :保留 index.ts(barrel)、types.tsstyles.ts
  • context/:存放 Provider + Consumer Hook
  • hooks/:存放可组合的自定义 Hook(纯逻辑、无副作用)
  • utils/:存放纯函数工具(格式化、校验等)
  • components/:存放所有展示子组件

命名约定

类型 命名规范 示例
组件文件 PascalCase.tsx TodoInput.tsx
Hook 文件 camelCase.ts(use 开头) useTodoStats.ts
工具函数 camelCase.ts format.ts
类型文件 types.ts types.ts
样式文件 styles.ts styles.ts
索引文件 index.ts index.ts

每个文件职责单一

复制代码
❌ 反例:一个文件 500 行
├── TodoInput.tsx    ← 包含输入 + 筛选 + 进度 + ... 所有逻辑

✅ 正例:每个文件职责单一
├── components/
│   ├── TodoInput.tsx      ← 只做输入
│   ├── TodoFilter.tsx     ← 只做筛选
│   ├── TodoProgress.tsx   ← 只做进度
│   └── TodoList.tsx       ← 只做列表

公共 API 从 index.ts 统一导出

ts 复制代码
// index.ts
export { TodoApp } from './TodoApp'
export { TodoProvider, useTodos } from './context/TodoContext'
export { TodoInput } from './components/TodoInput'
export { TodoList } from './components/TodoList'
export type { Todo, Filter, TodoStats } from './types'
export { styles } from './styles'

9. 演进路线

当前实现是"教学版",适合入门学习。生产环境中可以逐步升级:

阶段 技术 适用场景
入门 useState + useEffect 小型应用、演示项目
进阶 Context + useReducer 中等复杂度、多组件共享
成熟 Zustand / Redux Toolkit 大型应用、团队协作
专家 React Query / SWR 服务端状态管理、缓存
极致 RTL + Vitest 单元测试 需要高可靠性的生产环境

升级到 Zustand 示例

ts 复制代码
import { create } from 'zustand'

interface TodoStore {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  deleteTodo: (id: number) => void
}

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  addTodo: (text) => set((s) => ({
    todos: [...s.todos, { id: Date.now(), text, done: false, createdAt: Date.now() }]
  })),
  toggleTodo: (id) => set((s) => ({
    todos: s.todos.map((t) => t.id === id ? { ...t, done: !t.done } : t)
  })),
  deleteTodo: (id) => set((s) => ({
    todos: s.todos.filter((t) => t.id !== id)
  })),
}))

Zustand 相比 Context 的优势

  • 无需 Provider 包裹
  • 订阅粒度更细(组件只订阅需要的字段)
  • 内置持久化中间件
  • API 更简洁

升级到 React Query 示例

tsx 复制代码
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

function TodoList() {
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: () => todoApi.list().then(dtoListToTodos),
  })

  const queryClient = useQueryClient()
  const addMutation = useMutation({
    mutationFn: (text: string) =>
      todoApi.create(buildCreatePayload(text)).then(dtoToTodo),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  if (isLoading) return <Loading />
  return <List items={todos} onAdd={addMutation.mutate} />
}

React Query 带来的好处

  • 自动缓存、去重、后台同步
  • 乐观更新 + 失败回滚(内置)
  • 分页、无限滚动支持
  • DevTools 可视化

10. 实战:复杂业务组件拆分全过程

场景:订单管理页面

假设你接手一个 800 行的 OrderPage.tsx,功能包括:

  • 订单列表(分页、筛选、排序)
  • 订单详情(Tab 切换:商品、物流、操作日志)
  • 订单操作(创建、编辑、取消、发货)
  • 实时状态轮询

Step 1:标记 UI 块

tsx 复制代码
function OrderPage() {
  return (
    <div>
      {/* 顶部:筛选栏 */}
      <div>...</div>  {/* 200 行 */}

      {/* 中部:订单列表 */}
      <div>...</div>  {/* 300 行 */}

      {/* 右侧:订单详情抽屉 */}
      <Drawer>
        <Tab>
          {/* Tab 1:商品 */}
          <div>...</div>  {/* 100 行 */}
          {/* Tab 2:物流 */}
          <div>...</div>  {/* 80 行 */}
          {/* Tab 3:操作日志 */}
          <div>...</div>  {/* 80 行 */}
        </Tab>
      </Drawer>

      {/* 底部:批量操作栏 */}
      <div>...</div>
    </div>
  )
}

Step 2:按职责拆分

css 复制代码
src/components/order/
├── index.ts
├── types.ts                    ← Order, OrderItem, OrderStatus
├── styles.ts
├── context/
│   └── OrderContext.tsx        ← 订单状态、操作方法、API 调用
├── hooks/
│   ├── useOrderStats.ts        ← 订单统计
│   ├── useOrderFilter.ts       ← 筛选逻辑
│   └── useOrderPolling.ts      ← 实时轮询
├── components/
│   ├── OrderFilter.tsx         ← 筛选栏
│   ├── OrderTable.tsx          ← 订单列表
│   ├── OrderTableRow.tsx       ← 单行(memo)
│   ├── OrderDetailDrawer.tsx   ← 详情抽屉
│   ├── OrderItemsTab.tsx       ← 商品 Tab
│   ├── OrderShippingTab.tsx    ← 物流 Tab
│   ├── OrderLogsTab.tsx        ← 日志 Tab
│   ├── OrderBatchActions.tsx   ← 批量操作栏
│   └── OrderEmptyState.tsx     ← 空状态
└── OrderPage.tsx               ← 主容器(< 50 行)

Step 3:主容器(OrderPage.tsx)

tsx 复制代码
function OrderLayout() {
  return (
    <div>
      <OrderFilter />
      <OrderTable />
      <OrderDetailDrawer />
      <OrderBatchActions />
    </div>
  )
}

export default function OrderPage() {
  return (
    <OrderProvider>
      <OrderLayout />
    </OrderProvider>
  )
}

Step 4:订单 Context(OrderContext.tsx)

tsx 复制代码
export interface OrderContextValue {
  orders: Order[]
  selectedIds: number[]
  filter: OrderFilter
  loading: boolean
  error: string | null
  setFilter: (f: OrderFilter) => void
  selectOrder: (id: number) => void
  selectAll: () => void
  createOrder: (data: CreateOrderDto) => Promise<void>
  cancelOrder: (id: number) => Promise<void>
  shipOrder: (id: number, trackingNo: string) => Promise<void>
  bulkCancel: () => Promise<void>
  refetch: () => Promise<void>
}

const OrderContext = createContext<OrderContextValue | null>(null)

export function OrderProvider({ children }: { children: ReactNode }) {
  const [orders, setOrders] = useState<Order[]>([])
  const [selectedIds, setSelectedIds] = useState<number[]>([])
  const [filter, setFilter] = useState<OrderFilter>({ status: 'all' })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  // 初始化加载
  useEffect(() => { refetch() }, [])

  // 实时轮询(每 30 秒)
  useOrderPolling(() => refetch(), 30_000)

  const value: OrderContextValue = { /* ... 所有方法 */ }
  return <OrderContext.Provider value={value}>{children}</OrderContext.Provider>
}

11. 进阶:Context 拆分与性能优化

问题:单个 Context 的性能瓶颈

当 Context 内状态较多时,任何一个状态变化都会触发所有 Consumer 重渲染。

解法:按功能拆分成多个 Context

tsx 复制代码
// context/OrderContext.tsx
export function OrderProvider({ children }: { children: ReactNode }) {
  // A. 订单列表状态
  const [orders, setOrders] = useState<Order[]>([])
  const ordersValue = useMemo(() => ({ orders, setOrders }), [orders])

  // B. 筛选状态
  const [filter, setFilter] = useState<OrderFilter>({ status: 'all' })
  const filterValue = useMemo(() => ({ filter, setFilter }), [filter])

  // C. 选中状态
  const [selectedIds, setSelectedIds] = useState<number[]>([])
  const selectValue = useMemo(() => ({ selectedIds, setSelectedIds }), [selectedIds])

  // D. 操作方法
  const actions = useMemo(() => ({
    createOrder, cancelOrder, shipOrder,
  }), [/* deps */])

  return (
    <OrderListContext.Provider value={ordersValue}>
      <OrderFilterContext.Provider value={filterValue}>
        <OrderSelectContext.Provider value={selectValue}>
          <OrderActionsContext.Provider value={actions}>
            {children}
          </OrderActionsContext.Provider>
        </OrderSelectContext.Provider>
      </OrderFilterContext.Provider>
    </OrderListContext.Provider>
  )
}

配合自定义 Hook 使用

tsx 复制代码
// hooks/useOrderList.ts
export function useOrderList() {
  const ctx = useContext(OrderListContext)
  if (!ctx) throw new Error('useOrderList must be used within OrderListProvider')
  return ctx
}

// hooks/useOrderFilter.ts
export function useOrderFilter() { /* ... */ }

// 组件按需订阅
function OrderTable() {
  const { orders } = useOrderList()       // 只订阅订单列表
  return <Table data={orders} />
}

function OrderFilter() {
  const { filter, setFilter } = useOrderFilter()  // 只订阅筛选
  return <FilterBar value={filter} onChange={setFilter} />
}

function OrderBatchActions() {
  const { selectedIds } = useOrderSelect()  // 只订阅选中
  const { bulkCancel } = useOrderActions()  // 只订阅操作
  return <ActionsBar selectedCount={selectedIds.length} onBulkCancel={bulkCancel} />
}

性能对比

css 复制代码
拆分前(单个 Context):
┌─────────────────────────────────────────┐
│ 任何 state 变化 → 所有 Consumer 重渲染   │
│ 订单变化:Filter 也重渲染(不必要)     │
│ 筛选变化:Table 也重渲染(不必要)       │
└─────────────────────────────────────────┘

拆分后(多个 Context):
┌─────────────────────────────────────────┐
│ 订单变化:只有订阅 orders 的组件重渲染   │
│ 筛选变化:只有订阅 filter 的组件重渲染   │
│ 互不干扰,性能最优                      │
└─────────────────────────────────────────┘

12. 进阶:状态机管理复杂状态

问题:复杂状态转换容易出错

比如订单状态:pending → paid → shipped → completed,每个转换有不同的前置条件和副作用。

解法:用 useReducer 实现状态机

tsx 复制代码
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'completed' | 'cancelled'

interface OrderState {
  status: OrderStatus
  data: Order | null
  error: string | null
  loading: boolean
}

type OrderAction =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: Order }
  | { type: 'FETCH_ERROR'; error: string }
  | { type: 'PAY' }
  | { type: 'SHIP'; trackingNo: string }
  | { type: 'COMPLETE' }
  | { type: 'CANCEL' }

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null }
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload, status: action.payload.status }
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.error }
    case 'PAY':
      if (state.status !== 'pending') return state  // 状态守卫
      return { ...state, status: 'paid' }
    case 'SHIP':
      if (state.status !== 'paid') return state
      return { ...state, status: 'shipped', data: { ...state.data!, trackingNo: action.trackingNo } }
    case 'COMPLETE':
      if (state.status !== 'shipped') return state
      return { ...state, status: 'completed' }
    case 'CANCEL':
      if (state.status === 'completed' || state.status === 'shipped') return state
      return { ...state, status: 'cancelled' }
    default:
      return state
  }
}

使用状态机的好处

  • 状态守卫 :非法转换自动阻止(如 paid 状态不能 SHIP
  • 逻辑集中:所有状态转换在 reducer 里一目了然
  • 可测试reducer(state, action) 是纯函数,单元测试非常简单
  • DevTools 友好:时间旅行调试

现实案例:表单状态机

tsx 复制代码
type FormState = 'idle' | 'editing' | 'submitting' | 'success' | 'error'

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action) {
    case 'EDIT':
      return state === 'idle' || state === 'error' ? 'editing' : state
    case 'SUBMIT':
      return state === 'editing' ? 'submitting' : state
    case 'SUCCESS':
      return state === 'submitting' ? 'success' : state
    case 'ERROR':
      return state === 'submitting' ? 'error' : state
    case 'RESET':
      return 'idle'
    default:
      return state
  }
}

13. 进阶:组合式 Hook 设计模式

模式 1:useXxx 接受参数,返回状态 + 方法

ts 复制代码
export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  const increment = useCallback(() => setCount((c) => c + 1), [])
  const decrement = useCallback(() => setCount((c) => c - 1), [])
  const reset = useCallback(() => setCount(initial), [initial])
  return { count, increment, decrement, reset }
}

// 使用
const { count, increment } = useCounter(10)

模式 2:useXxx 接受回调,返回受控值

ts 复制代码
export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])
  return debounced
}

// 使用:搜索框防抖
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, 300)
useEffect(() => { /* 触发搜索 */ }, [debouncedKeyword])

模式 3:useXxx 返回元组(类似 useState)

ts 复制代码
export function useToggle(initial = false): [boolean, () => void, (v: boolean) => void] {
  const [value, setValue] = useState(initial)
  const toggle = useCallback(() => setValue((v) => !v), [])
  return [value, toggle, setValue] as const
}

// 使用
const [isOpen, toggleOpen, setOpen] = useToggle()

模式 4:useXxx 组合其它 Hook

ts 复制代码
export function useFetch<T>(url: string, options?: RequestInit) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  // 组合 useDebounce 做防抖
  const debouncedUrl = useDebounce(url, 300)

  useEffect(() => {
    let cancelled = false
    const fetchData = async () => {
      setLoading(true)
      try {
        const res = await fetch(debouncedUrl, options)
        const json = await res.json() as T
        if (!cancelled) setData(json)
      } catch (err) {
        if (!cancelled) setError(err as Error)
      } finally {
        if (!cancelled) setLoading(false)
      }
    }
    fetchData()
    return () => { cancelled = true }
  }, [debouncedUrl, options])

  return { data, loading, error }
}

模式 5:useXxx 支持命令式调用(Imperative Handle)

ts 复制代码
export function useImperativeRef<T>(initial: T) {
  const ref = useRef<T>(initial)
  const setValue = useCallback((value: T) => { ref.current = value }, [])
  const getValue = useCallback(() => ref.current, [])
  return [ref, setValue, getValue] as const
}

// 使用
const [inputRef, setInputValue, getInputValue] = useImperativeRef<HTMLInputElement | null>(null)
// 父组件可以通过 ref 命令式子组件

14. 附录:核心原理速查

React 渲染机制

markdown 复制代码
1. 触发更新(setState / props 变化 / Context 变化)
   ↓
2.  reconciliation(协调)
   - 对比新旧虚拟 DOM
   - 生成最小变更
   ↓
3. commit(提交)
   - 将变更应用到真实 DOM
   - 执行副作用(useEffect / useLayoutEffect)

Hook 调用规则

规则 说明 原因
只能在组件顶层调用 不能放在 if / for / 嵌套函数里 React 依赖调用顺序定位 Hook
只能在 React 函数内调用 组件、自定义 Hook 普通函数没有 Fiber 上下文
自定义 Hook 以 use 开头 命名约定 ESLint 插件会检查

useEffect vs useLayoutEffect

特性 useEffect useLayoutEffect
执行时机 DOM 绘制后 DOM 绘制前(同步)
适用场景 数据获取、订阅、定时器 DOM 测量、同步修改
用户感知 可能有闪烁 无闪烁

状态管理选择指南

bash 复制代码
状态规模:
  小(组件内)     → useState / useReducer
  中(跨 2-3 组件) → Context + useReducer
  大(全局)        → Zustand / Redux Toolkit
  服务端状态        → React Query / SWR

状态类型:
  临时 UI 状态(hover/focus)   → useState(组件内)
  表单数据                      → useState / useForm
  全局用户信息                  → Context / Zustand
  服务器数据(带缓存/重试)      → React Query

文件清单

文件 职责 代码行数
TodoApp.tsx 主容器(薄) ~47
context/TodoContext.tsx Provider + Consumer Hook ~220
hooks/useTodoStats.ts 可组合统计 Hook ~18
components/Header.tsx 顶部标题 ~26
components/TodoProgress.tsx 进度条 ~22
components/TodoInput.tsx 输入区 ~44
components/TodoFilter.tsx 筛选栏 ~38
components/TodoList.tsx 列表容器 ~54
components/TodoItem.tsx 单条待办 ~87
components/TodoFooter.tsx 底部操作栏 ~34
components/EmptyState.tsx 空状态 ~25
types.ts 类型定义 ~15
styles.ts 共享样式 ~118
index.ts Barrel 导出 ~7

总计:14 个文件,约 750 行代码。

最终目录结构

css 复制代码
src/components/todo/
├── index.ts                 ← Barrel(根)
├── types.ts                 ← 类型契约(根)
├── styles.ts                ← 共享样式(根)
├── TodoApp.tsx              ← 主容器(根)
├── context/                 ← Provider 层
│   └── TodoContext.tsx
├── hooks/                   ← 可组合 Hook
│   └── useTodoStats.ts
└── components/              ← 展示子组件
    ├── Header.tsx
    ├── TodoProgress.tsx
    ├── TodoInput.tsx
    ├── TodoFilter.tsx
    ├── TodoList.tsx
    ├── TodoItem.tsx
    ├── TodoFooter.tsx
    └── EmptyState.tsx

15. 进阶:复合组件模式 Compound Components

什么是复合组件

参考 Radix、Headless UI、Ant Design 的 Tabs、Accordion、Menu:一组"父子"组件通过隐式 Context 共享状态,使用者只需声明结构,不必操心状态传递。

tsx 复制代码
// 使用示例
<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Trigger value="account">账号</Tabs.Trigger>
    <Tabs.Trigger value="password">密码</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account">账号设置</Tabs.Content>
  <Tabs.Content value="password">密码修改</Tabs.Content>
</Tabs>

使用者不传 activeonChange,一切由复合组件内部协调。

实现思路

tsx 复制代码
// context/TabsContext.tsx
const TabsContext = createContext<TabsValue | null>(null)

export function Tabs({
  defaultValue,
  value: controlledValue,
  onValueChange,
  children,
}: TabsProps) {
  const isControlled = controlledValue !== undefined
  const [internal, setInternal] = useState(defaultValue)
  const value = isControlled ? controlledValue! : internal

  const setValue = useCallback(
    (next: string) => {
      if (!isControlled) setInternal(next)
      onValueChange?.(next)
    },
    [isControlled, onValueChange]
  )

  return (
    <TabsContext.Provider value={{ value, setValue }}>
      {children}
    </TabsContext.Provider>
  )
}

Tabs.List = function TabsList({ children }) {
  return <div role="tablist">{children}</div>
}

Tabs.Trigger = function TabsTrigger({ value, children }) {
  const ctx = useContext(TabsContext)!
  const active = ctx.value === value
  return (
    <button
      role="tab"
      aria-selected={active}
      onClick={() => ctx.setValue(value)}
      style={{ fontWeight: active ? 700 : 400 }}
    >
      {children}
    </button>
  )
}

Tabs.Content = function TabsContent({ value, children }) {
  const ctx = useContext(TabsContext)!
  if (ctx.value !== value) return null
  return <div role="tabpanel">{children}</div>
}

复合组件的优势

  • API 友好:使用者不必关心内部 props,代码语义化
  • 灵活组合:使用者可自由排列子组件顺序、数量
  • 可扩展 :后续加 Tabs.ListTabs.IndicatorTabs.Scroll 等,对外 API 保持稳定

常见场景

  • Tabs、Accordion、Collapse
  • Menu / Dropdown / Select
  • Form / Field / Label / Error
  • Steps / Stepper
  • Table / Row / Cell

16. 进阶:受控 vs 非受控

两种模式对比

维度 受控组件 非受控组件
数据来源 父组件 props 组件内部 state / ref
更新方式 onChange 回调 内部逻辑
典型场景 表单、状态同步外部 一次性操作(如文件选择)
灵活性

受控组件示例(当前 TodoInput)

tsx 复制代码
function TodoInput() {
  const { input, setInput, addTodo } = useTodos()
  return (
    <input
      value={input}
      onChange={(e) => setInput(e.target.value)}
      onKeyDown={addTodo}
    />
  )
}

非受控组件示例(性能优化版 TodoInput)

tsx 复制代码
function TodoInputFast() {
  const ref = useRef<HTMLInputElement>(null)
  const { addTodo } = useTodos()

  return (
    <input
      ref={ref}
      onKeyDown={(e) => {
        if (e.key === 'Enter') {
          addTodo(ref.current?.value ?? '')
          if (ref.current) ref.current.value = ''
        }
      }}
    />
  )
}

同时支持两种模式(推荐)

tsx 复制代码
interface SearchInputProps {
  value?: string
  defaultValue?: string
  onChange?: (value: string) => void
  onSubmit?: (value: string) => void
}

function SearchInput({ value, defaultValue, onChange, onSubmit }: SearchInputProps) {
  const isControlled = value !== undefined
  const [inner, setInner] = useState(defaultValue ?? '')
  const current = isControlled ? value! : inner

  const update = (next: string) => {
    if (!isControlled) setInner(next)
    onChange?.(next)
  }

  return (
    <input
      value={current}
      onChange={(e) => update(e.target.value)}
      onKeyDown={(e) => e.key === 'Enter' && onSubmit?.(current)}
    />
  )
}

判定规则value !== undefined 视为受控,否则视为非受控。这是 Radix、React Hook Form 的通用做法。

选择建议

  • 表单类、需要校验:优先受控
  • 高频输入、性能敏感:非受控 + 提交时一次性读取
  • 组件库通用组件:同时支持两种模式

17. 进阶:性能优化全景

7 种常见优化手段

17.1 React.memo + useMemo + useCallback 三件套

tsx 复制代码
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }: Props) {
  return (
    <li>
      <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>删除</button>
    </li>
  )
})

// 父组件
const onToggle = useCallback((id: number) => toggle(id), [toggle])
const onDelete = useCallback((id: number) => remove(id), [remove])

正确姿势:先写好代码,通过 React DevTools Profiler 找性能瓶颈,再针对性加。

17.2 虚拟长列表

列表超过 100 条时,用 react-windowreact-virtualized

tsx 复制代码
import { FixedSizeList } from 'react-window'

function VirtualList({ items }: { items: Todo[] }) {
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={50}
      itemData={items}
    >
      {({ index, style, data }) => (
        <div style={style}>
          <TodoItem todo={data[index]} />
        </div>
      )}
    </FixedSizeList>
  )
}

17.3 路由懒加载

tsx 复制代码
import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))

<Suspense fallback={<Loading />}>
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/settings" element={<Settings />} />
  </Routes>
</Suspense>

17.4 图片懒加载

tsx 复制代码
<img loading="lazy" src={url} alt="" />

17.5 CSS 优化

css 复制代码
.card {
  contain: layout paint;        /* 隔离渲染 */
  content-visibility: auto;     /* 离屏跳过渲染 */
}

17.6 避免不必要的 Context 更新

tsx 复制代码
const value = useMemo(
  () => ({ todos, setTodos, addTodo }),
  [todos, addTodo]
)

17.7 预加载与预获取

tsx 复制代码
// 鼠标 hover 时预加载组件
<button onMouseEnter={() => Dashboard.preload()}>首页</button>

优化检查清单

  • 用 React DevTools Profiler 定位具体瓶颈
  • 确认渲染次数是否符合预期
  • 长列表是否虚拟滚动
  • 路由是否懒加载
  • Context 的 value 是否 useMemo 化
  • 是否存在不必要的 props 重新创建

18. 进阶:调试技巧

18.1 React DevTools 调试

  • Components 面板:查看每个组件的 props、state、hooks
  • ⚡ 图标:查看组件渲染次数
  • Profiler:录制一段操作,查看火焰图,定位渲染热点

18.2 自定义 Hook 调试

tsx 复制代码
function useTodos() {
  const ctx = useContext(TodoContext)
  if (!ctx) throw new Error('...')

  if (import.meta.env.DEV) {
    console.debug('[useTodos]', {
      todos: ctx.todos.length,
      filter: ctx.filter,
      loading: ctx.loading,
    })
  }
  return ctx
}

18.3 Why Did You Render

安装 @welldone-software/why-did-you-render,在入口配置:

tsx 复制代码
import whyDidYouRender from '@welldone-software/why-did-you-render'
whyDidYouRender(React, { include: [/^TodoItem$/] })

当组件不必要重渲时,控制台会打印:

yaml 复制代码
TodoItem is preventing re-renders: props.todos changed
  prev: [{id: 1, text: 'a'}, {id: 2, text: 'b'}]
  next: [{id: 1, text: 'a'}, {id: 2, text: 'b'}]

18.4 错误边界

tsx 复制代码
class ErrorBoundary extends React.Component {
  state = { error: null }

  static getDerivedStateFromError(error) {
    return { error }
  }

  componentDidCatch(error, info) {
    console.error('[ErrorBoundary]', error, info)
  }

  render() {
    if (this.state.error) {
      return <Fallback onRetry={() => this.setState({ error: null })} />
    }
    return this.props.children
  }
}

18.5 单元测试辅助调试

tsx 复制代码
import { render, screen, fireEvent } from '@testing-library/react'
import { TodoProvider } from './context/TodoContext'

test('点击添加按钮后列表应新增一项', () => {
  render(
    <TodoProvider>
      <TodoApp />
    </TodoProvider>
  )
  fireEvent.change(screen.getByRole('textbox'), { target: { value: '学习 React' } })
  fireEvent.click(screen.getByRole('button', { name: '添加' }))
  expect(screen.getByText('学习 React')).toBeInTheDocument()
})

18.6 常见调试流程

  1. 现象收集:复现步骤、截图、控制台错误
  2. 定位范围:通过 DevTools 看是哪个组件的 state 出了问题
  3. 追踪数据:在 Provider 的 value 处加日志,验证数据流向
  4. 最小复现:抽出最小代码片段放到 CodeSandbox
  5. 对比版本:git bisect 找出引入 bug 的 commit
  6. 自动化回归:补测试用例,防止再犯

19. 进阶:设计系统与组件库构建

从业务组件到通用组件

css 复制代码
业务组件(TodoInput)→ 通用组件(Input)→ 设计系统(Button、Input、Modal)

抽取通用 Input

tsx 复制代码
// src/components/ui/Input.tsx
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string
  error?: string
  helperText?: string
  leftIcon?: React.ReactNode
  rightIcon?: React.ReactNode
  size?: 'sm' | 'md' | 'lg'
}

export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
  { label, error, helperText, leftIcon, rightIcon, size = 'md', className, ...rest },
  ref
) {
  const sizeClass = size === 'sm' ? 'h-8 text-sm' : size === 'lg' ? 'h-12 text-base' : 'h-10 text-base'
  return (
    <label className="flex flex-col gap-1">
      {label && <span className="text-gray-700 font-medium">{label}</span>}
      <span className={`relative inline-flex items-center ${sizeClass}`}>
        {leftIcon && <span className="absolute left-2">{leftIcon}</span>}
        <input
          ref={ref}
          className={`w-full px-3 ${leftIcon ? 'pl-8' : ''} ${rightIcon ? 'pr-8' : ''}
            border rounded-md focus:ring-2 focus:ring-blue-400
            ${error ? 'border-red-500' : 'border-gray-300'}
            ${className ?? ''}`}
          {...rest}
        />
        {rightIcon && <span className="absolute right-2">{rightIcon}</span>}
      </span>
      {error ? (
        <span className="text-red-500 text-xs">{error}</span>
      ) : helperText ? (
        <span className="text-gray-500 text-xs">{helperText}</span>
      ) : null}
    </label>
  )
})

设计系统的三层结构

less 复制代码
┌─────────────────────────────────────────┐
│  1. Design Token(CSS 变量)            │
│     --color-primary: #1677ff            │
│     --spacing-md: 16px                  │
├─────────────────────────────────────────┤
│  2. Primitive(基础组件)               │
│     Button, Input, Modal, Tooltip      │
├─────────────────────────────────────────┤
│  3. Pattern(业务组件)                  │
│     SearchBar, UserCard, OrderForm     │
└─────────────────────────────────────────┘

Design Token 示例

css 复制代码
:root {
  --color-primary: #1677ff;
  --color-primary-hover: #4096ff;
  --color-danger: #ff4d4f;
  --color-success: #52c41a;
  --font-size-xs: 12px;
  --font-size-sm: 14px;
  --font-size-md: 16px;
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --radius-sm: 4px;
  --radius-md: 8px;
  --shadow-sm: 0 1px 2px rgba(0,0,0,.08);
}

国际化与主题

tsx 复制代码
interface Theme {
  primary: string
  borderRadius: string
  mode: 'light' | 'dark'
}

const ThemeContext = createContext<Theme>({ primary: '#1677ff', borderRadius: '8px', mode: 'light' })

export function ThemeProvider({ theme, children }: PropsWithChildren<{ theme: Partial<Theme> }>) {
  const merged = useMemo(() => ({ ...defaultTheme, ...theme }), [theme])
  return <ThemeContext.Provider value={merged}>{children}</ThemeContext.Provider>
}

20. 进阶:Headless Component 模式

什么是 Headless

"无样式组件":只负责逻辑与状态,不提供 UI 样式,交给使用者自由发挥。代表库:Radix、Headless UI、Reach UI。

实现:useToggle Hook + Headless 组件

tsx 复制代码
// hooks/useToggle.ts
export function useToggle(defaultOn = false) {
  const [on, setOn] = useState(defaultOn)
  const toggle = useCallback(() => setOn((v) => !v), [])
  return { on, setOn, toggle }
}

// headless/Toggle.tsx
export function Toggle({ onCheckedChange, defaultOn = false }: ToggleProps) {
  const { on, toggle } = useToggle(defaultOn)
  const triggerProps = {
    role: 'switch' as const,
    'aria-checked': on,
    onClick: () => {
      toggle()
      onCheckedChange?.(!on)
    },
  }
  return { triggerProps, on }
}

使用:自由绑定到任何 UI

tsx 复制代码
// 风格 A:原生按钮
const toggle = Toggle({ defaultOn: true })
<button {...toggle.triggerProps}>{toggle.on ? '开启' : '关闭'}</button>

// 风格 B:自定义 UI
<div {...toggle.triggerProps} className={`switch ${toggle.on ? 'on' : ''}`}>
  <span className="thumb" />
</div>

对比

维度 UI 组件 Headless 组件
样式 内置 无,使用者自定
逻辑 内置 内置
灵活性
维护成本

什么时候用 Headless

  • 多个品牌/主题复用同一逻辑
  • 需要与 Tailwind、CSS-in-JS 深度集成
  • 要做跨平台(Web/React Native)复用

21. 进阶:Server Components 与 Suspense

React 18+ 的新能力

React Server Components(RSC)让组件可以在服务端运行,直接 async/await 取数据,无需 useEffect

RSC 示例(概念)

tsx 复制代码
// TodoList.server.tsx(服务端组件)
async function TodoList() {
  const todos = await db.todo.findMany()
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  )
}

Suspense 边界 + 流式渲染

tsx 复制代码
// 客户端
<Suspense fallback={<SkeletonList />}>
  <TodoList />
</Suspense>

与当前架构的结合点

场景 当前做法 RSC 做法
首屏数据 Provider useEffect 拉取 Server Component 直接 await
代码分割 React.lazy RSC 自动按边界分割
SEO 客户端 Hydration 服务端直接返回 HTML
副作用 必须客户端组件 Server Component 禁止 useEffect

当前项目可落地的改进

即便不用 RSC,也可以用 Suspense + 客户端实现:

tsx 复制代码
function TodoModule() {
  return (
    <TodoProvider>
      <Suspense fallback={<TodoSkeleton />}>
        <TodoLayout />
      </Suspense>
    </TodoProvider>
  )
}

关键原则

  • 展示优先:先展示骨架屏,再逐步流式填充
  • 边界清晰:Loading / Error 边界集中管理
  • 渐进采纳:先掌握 Suspense 基础,再进阶 RSC

总结:组件封装的心法

  1. 分层:Provider 层只管状态、子组件层只管展示、主容器层只管组装
  2. 契约:Context Value 是"接口",类型先行
  3. 纯净:Hook 尽量纯函数,便于组合和测试
  4. 按需:子组件按最小依赖原则取数
  5. 演进:useState → Context → Zustand/Redux → React Query,按复杂度升级
  6. 调试:善用 DevTools、Profiler、Why Did You Render
  7. 系统:从业务组件走向通用组件、设计系统、Headless
  8. 未来:关注 RSC、Suspense、Server First 的新范式

附带代码路径

gitee

相关推荐
任沫2 小时前
Agent之Function Call
javascript·人工智能·go
默_笙3 小时前
🛬 我让 AI 帮我写了一个打飞机游戏,结果 Canvas 把我整不会了
前端·javascript
梯度不陡3 小时前
AI 到底能不能从零写软件?ProgramBench 和 RepoZero 给出了两种答案
前端·javascript·面试
胡萝卜术5 小时前
滑动窗口最大值:从暴力到单调队列,层层优化全解析
前端·javascript·面试
kyriewen6 小时前
2026 年了,这 6 个 npm 包可以卸载了——浏览器原生 API 已经能替代
前端·javascript·npm
猩猩程序员7 小时前
零基础学习 React 19
react.js
铁皮饭盒7 小时前
bun直接tsx,优雅!
javascript·后端
_柳青杨9 小时前
一文吃透 Node.js 事件循环:从原理到 Node 20+ 重大变更
javascript·后端
spmcor9 小时前
React 进阶指南:状态管理进化——从 Context 到 Redux Toolkit(第五篇)
react.js