本文档结合当前项目的 Todo App 实现,系统讲解 React 组件封装的通用方法论。 所有代码位置均以项目实际文件为准,每一节都配有实战案例与反例对比。
目录
- 整体架构
- 三层封装模型
- [Provider 层详解](#Provider 层详解 "#3-provider-%E5%B1%82%E8%AF%A6%E8%A7%A3")
- 子组件层详解
- 主容器层详解
- 通用拆分流程
- 常见陷阱与规避
- 目录结构规范
- 演进路线
- 实战:复杂业务组件拆分全过程
- [进阶: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")
- 进阶:状态机管理复杂状态
- [进阶:组合式 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")
- 附录:核心原理速查
- [进阶:复合组件模式 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")
- [进阶:受控 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")
- 进阶:性能优化全景
- 进阶:调试技巧
- 进阶:设计系统与组件库构建
- [进阶:Headless Component 模式](#进阶:Headless Component 模式 "#20-%E8%BF%9B%E9%98%B6headless-component-%E6%A8%A1%E5%BC%8F")
- [进阶: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 层详解
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
null比undefined更明确: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]
)
乐观更新的三步骤:
- 先改:立即更新 state,让用户看到即时反馈
- 再请求:调用 API
- 成功则覆盖,失败则回滚:保持数据一致性
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
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(如
TodoContext、ThemeContext、UserContext) - 只放"真的跨组件共享"的状态
- 组件私有 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.ts、styles.ts context/:存放 Provider + Consumer Hookhooks/:存放可组合的自定义 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>
使用者不传 active、onChange,一切由复合组件内部协调。
实现思路
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.List、Tabs.Indicator、Tabs.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-window 或 react-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 常见调试流程
- 现象收集:复现步骤、截图、控制台错误
- 定位范围:通过 DevTools 看是哪个组件的 state 出了问题
- 追踪数据:在 Provider 的 value 处加日志,验证数据流向
- 最小复现:抽出最小代码片段放到 CodeSandbox
- 对比版本:git bisect 找出引入 bug 的 commit
- 自动化回归:补测试用例,防止再犯
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
总结:组件封装的心法
- 分层:Provider 层只管状态、子组件层只管展示、主容器层只管组装
- 契约:Context Value 是"接口",类型先行
- 纯净:Hook 尽量纯函数,便于组合和测试
- 按需:子组件按最小依赖原则取数
- 演进:useState → Context → Zustand/Redux → React Query,按复杂度升级
- 调试:善用 DevTools、Profiler、Why Did You Render
- 系统:从业务组件走向通用组件、设计系统、Headless
- 未来:关注 RSC、Suspense、Server First 的新范式