Lesson 15:重构业务逻辑 — 自定义 Hooks、useOptimistic 与组合模式
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:90~120 分钟(首次学习)
- 先修要求:完成 Lesson 14,理解请求层、Query mutation 与表单提交
- 学习产出:提取任务 mutation Hook,比较缓存乐观更新与局部useOptimistic,并实现组合组件
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:将越来越臃肿的组件拆解,提取可复用的业务逻辑,并掌握 React 19 新增的
useOptimisticHook 和高级组件组合模式。📦 本节产出:通过编写自定义 Hook(
useTaskStatusMutation,复用已有useDebounce)让 UI 组件重新变得清爽;比较useOptimistic的局部乐观 UI 与共享缓存更新;理解 Compound Components 组合模式。
一、组件正在变得臃肿
在前面的课程中,我们在 Board.tsx 和 TaskItem.tsx 里塞入了大量的逻辑:
- TanStack Query 的
useQuery和useMutation - 乐观更新中复杂的缓存快照(
onMutate、onError等) - 各种按钮的点击事件处理
- UI 的渲染(JSX)
// ❌ 典型的"胖组件" (Fat Component)
function TaskItem({ task }) {
const queryClient = useQueryClient()
// !!! 这里有 40 行长长的 useMutation 乐观更新逻辑 !!!
const mutation = useMutation({ ... })
// !!! 这里有 10 行根据搜索防抖计算得出结果的逻辑 !!!
const debouncedSearch = useDebounce(...)
// !!! 终于到了 UI 渲染 !!!
return <div>...</div>
}可以把重复、独立的请求逻辑提取到 Hook,让视图更容易阅读和测试。小组件直接调用 Query 也合理,分层应解决实际重复,而不是要求所有组件只含 JSX。
二、自定义 Hook 的核心规则
在 React 中,Hook 本质上就是普通的 JavaScript 函数。 自定义 Hook 是使用 React Hook 的 JavaScript 函数,需遵守以下规则:
- 名字必须以
use开头(比如useTasks、useWindowSize)。 - 在这个函数内部,可以调用其他的 Hook(比如
useState、useQuery)。 - 在函数组件或自定义 Hook 的顶层调用,且每次渲染保持调用顺序;不能放进事件、条件或循环中。React 的
useAPI 是允许条件读取的特例,不代表自定义 Hook 都能这么用。 - 复用 Hook 复用的是逻辑,不会自动让多个调用共享本地 state;本课共享数据来自外部 Query 缓存。
2.1 实战:提取请求逻辑 Hook
把之前 Lesson 12 里面那一长串乐观更新逻辑,抽取成一个专门的 useTaskStatusMutation:
// src/hooks/useTaskMutations.ts
import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateTaskStatus, type Task } from '@/api/taskRequests'
export function useTaskStatusMutation(projectId: string) {
const queryClient = useQueryClient()
const queryKey = ['tasks', projectId]
const mutationKey = ['task-status', projectId]
const isUpdating = useIsMutating({ mutationKey }) > 0
const mutation = useMutation({
mutationKey,
mutationFn: updateTaskStatus,
onMutate: async variables => {
await queryClient.cancelQueries({ queryKey })
const previousTask = queryClient.getQueryData<Task[]>(queryKey)
?.find(task => task.id === variables.taskId)
queryClient.setQueryData<Task[]>(queryKey, old =>
old?.map(task => task.id === variables.taskId ? { ...task, status: variables.status } : task),
)
return { previousTask }
},
onError: (_error, _variables, context) => {
if (!context?.previousTask) return
const previousTask = context.previousTask
queryClient.setQueryData<Task[]>(queryKey, old =>
old?.map(task => task.id === previousTask.id ? { ...task, status: previousTask.status } : task),
)
},
onSettled: () => queryClient.invalidateQueries({ queryKey }),
})
return {
mutate: (variables: Parameters<typeof updateTaskStatus>[0]) => {
if (queryClient.isMutating({ mutationKey }) === 0) mutation.mutate(variables)
},
isPending: isUpdating,
isError: mutation.isError,
error: mutation.error,
}
}这里保留 Lesson 12 的同项目状态更新互斥、逐字段回滚和等待缓存刷新;它仍只乐观修改普通任务列表,分页缓存通过失效重新获取。
2.2 改造后的 UI 组件
TaskItem 现在调用提取后的 Hook:
// src/components/TaskItem.tsx
import { useTaskStatusMutation } from '@/hooks/useTaskMutations'
import type { Task } from '@/api/taskRequests'
export default function TaskItem({ task }: { task: Task }) {
// 复用状态更新与错误处理逻辑
const statusMutation = useTaskStatusMutation(task.projectId)
const handleToggle = () => {
statusMutation.mutate({
taskId: task.id,
status: task.status === 'done' ? 'todo' : 'done'
})
}
return (
<button
type="button"
disabled={statusMutation.isPending}
aria-pressed={task.status === 'done'}
onClick={handleToggle}
className={`p-4 border rounded-xl cursor-pointer transition-all
${statusMutation.isPending ? 'opacity-50' : ''}
`}
>
{task.status === 'done' ? '✅' : '⬜️'} {task.title}
{statusMutation.isError && <span className="text-red-500 ml-2">更新失败!</span>}
</button>
)
}三、React 19:useOptimistic
在 Lesson 12 我们手动写了 onMutate + setQueryData + onError 回滚这一长串复杂的乐观更新。 React 19 的 useOptimistic 可以显示组件局部的乐观值。它不管理网络缓存,不能直接替代 Query 的跨组件同步。
3.1 原理对比
3.2 实战:用 useOptimistic 重写任务切换
下面是可选替代视图,不与前面的缓存乐观更新同时套在同一次操作上。调用方必须在请求成功后更新传入的基础 task;只等待请求而不更新数据,乐观状态结束后就会退回旧值。
// src/components/TaskItemOptimistic.tsx
import { useOptimistic, useRef, useState, useTransition } from 'react'
import type { Task } from '@/api/taskRequests'
export default function TaskItemOptimistic({ task, saveStatus }: {
task: Task
saveStatus: (taskId: string, status: Task['status']) => Promise<void>
}) {
const [optimisticTask, setOptimisticTask] = useOptimistic(
task,
(currentTask, status: Task['status']) => ({ ...currentTask, status }),
)
const [isPending, startTransition] = useTransition()
const [error, setError] = useState('')
const inFlight = useRef(false)
const handleToggle = () => {
if (inFlight.current) return
inFlight.current = true
setError('')
const nextStatus = optimisticTask.status === 'done' ? 'todo' : 'done'
startTransition(async () => {
setOptimisticTask(nextStatus)
try {
await saveStatus(task.id, nextStatus)
} catch {
setError('更新失败,请重试。')
} finally {
inFlight.current = false
}
})
}
return (
<div>
<button type="button" disabled={isPending} onClick={handleToggle}
aria-pressed={optimisticTask.status === 'done'} className="rounded border p-4">
{optimisticTask.status === 'done' ? '✅' : '⬜️'} {optimisticTask.title}
</button>
{error && <p role="alert">{error}</p>}
</div>
)
}在 Board 中提供保存回调。例如把服务器返回的任务写入已有的 Query 缓存,使所有订阅者收到正式结果:
// Board.tsx 中追加的导入:
import { useQueryClient } from '@tanstack/react-query'
import { updateTaskStatus, type Task } from '@/api/taskRequests'
import TaskItemOptimistic from '@/components/TaskItemOptimistic'
// 在 Board 顶层、任何提前 return 之前调用:
const queryClient = useQueryClient()
const saveStatus = async (taskId: string, status: Task['status']) => {
const saved = await updateTaskStatus({ taskId, status })
queryClient.setQueryData<Task[]>(['tasks', id], old =>
old?.map(task => task.id === saved.id ? saved : task),
)
await queryClient.invalidateQueries({ queryKey: ['tasks', id] })
}
// 在原 tasksQuery.data.map 的 li 内替换为:
<TaskItemOptimistic task={task} saveStatus={saveStatus} />setOptimisticTask 必须在 Action(如 Transition 回调或表单 action)中调用。React 管理临时 UI 的生命周期,应用仍负责请求、错误提示和基础值更新。这里的互斥仅针对同一个组件实例;跨视图并发编辑仍需要协调。useOptimistic 官方说明。
四、Compound Components 组合组件模式
在 Lesson 13 我们看到了 shadcn/ui 的 Dialog 组件是这么用的:
<Dialog>
<DialogTrigger>打开</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>标题</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>这种"一个父组件包含多个语义化子组件"的模式叫做 Compound Components(组合组件)。
4.1 为什么要这种模式?
// ❌ Props 地狱:一个组件要传 20 个 props
<Dialog
title="标题"
description="描述"
triggerText="打开"
confirmText="确认"
cancelText="取消"
onConfirm={...}
onCancel={...}
showFooter={true}
icon="warning"
// ... 更多 props
/>
// ✅ Compound Components:结构清晰、灵活可定制
<Dialog>
<DialogTrigger>打开</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>标题</DialogTitle>
<DialogDescription>描述</DialogDescription>
</DialogHeader>
{/* 你可以在这里放任何自定义内容! */}
<MyCustomChart />
<DialogFooter>
<Button variant="outline">取消</Button>
<Button>确认</Button>
</DialogFooter>
</DialogContent>
</Dialog>4.2 实战:自己写一个 Compound Component
让我们做一个简单的 Accordion(手风琴折叠面板)作为教学示例:
// src/components/Accordion.tsx
import { createContext, useContext, useId, useState, type ReactNode } from 'react'
// 1. 用 Context 做父子组件间的隐式通信
interface AccordionContextType {
openItem: string | null
toggle: (id: string) => void
}
const AccordionContext = createContext<AccordionContextType | null>(null)
// 2. 父组件:管理状态
export function Accordion({ children }: { children: ReactNode }) {
const [openItem, setOpenItem] = useState<string | null>(null)
const toggle = (id: string) => setOpenItem(prev => prev === id ? null : id)
return (
<AccordionContext.Provider value={{ openItem, toggle }}>
<div className="divide-y border rounded-xl overflow-hidden">
{children}
</div>
</AccordionContext.Provider>
)
}
// 3. 子组件:通过 Context 自动感知状态
export function AccordionItem({ id, title, children }: {
id: string; title: string; children: ReactNode
}) {
const ctx = useContext(AccordionContext)
const panelId = useId()
const triggerId = useId()
if (!ctx) throw new Error('AccordionItem 必须在 Accordion 内使用')
const isOpen = ctx.openItem === id
return (
<div>
<h3><button
type="button"
id={triggerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => ctx.toggle(id)}
className="w-full text-left px-4 py-3 font-medium hover:bg-gray-50 flex justify-between"
>
{title}
<span className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}>▼</span>
</button></h3>
<div id={panelId} role="region" aria-labelledby={triggerId} hidden={!isOpen}
className="px-4 py-3 bg-gray-50 text-sm text-gray-600">
{children}
</div>
</div>
)
}使用方式:
<Accordion>
<AccordionItem id="1" title="什么是 React?">
React 是一个用于构建用户界面的 JavaScript 库。
</AccordionItem>
<AccordionItem id="2" title="什么是 JSX?">
JSX 是 JavaScript 的语法扩展,可以用类似标签的写法描述界面。
</AccordionItem>
</Accordion>核心要点:父组件 Accordion 通过 Context 注入状态,子组件 AccordionItem 通过 useContext 消费状态。用户不需要传任何 prop 来管理开关——组合在一起时它们自动协作!
五、useImperativeHandle — 让父组件调用子组件的方法
有时候你需要从父组件控制子组件的行为。比如:
- 父组件点击按钮 → 让子组件的 Dialog 弹出
- 父组件点击重置 → 让子组件的表单清空
- 父组件切换 Tab → 让子组件的视频暂停
useImperativeHandle 让你可以给 ref 自定义暴露的方法:
// src/components/ConfirmDialog.tsx
import { useImperativeHandle, useState, type Ref } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
// 定义暴露给父组件的 API
export interface ConfirmDialogHandle {
open: (message: string) => void
close: () => void
}
export default function ConfirmDialog({
ref,
onConfirm
}: {
ref?: Ref<ConfirmDialogHandle>
onConfirm: () => void
}) {
const [isOpen, setIsOpen] = useState(false)
const [message, setMessage] = useState('')
// 向父组件暴露 open 和 close 方法
useImperativeHandle(ref, () => ({
open: (msg: string) => {
setMessage(msg)
setIsOpen(true)
},
close: () => setIsOpen(false),
}))
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认操作</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<div className="flex gap-3 justify-end">
<button onClick={() => setIsOpen(false)}
className="px-4 py-2 rounded-xl border hover:bg-gray-50">取消</button>
<button onClick={() => { onConfirm(); setIsOpen(false) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white hover:bg-red-700">确认</button>
</div>
</DialogContent>
</Dialog>
)
}// 独立演示组件,不替换主线 Board.tsx — 父组件使用
import { useRef, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogHandle } from '@/components/ConfirmDialog'
export default function ConfirmExample() {
const [confirmed, setConfirmed] = useState(false)
// ref 类型是我们自定义的 Handle,不是 DOM 元素!
const dialogRef = useRef<ConfirmDialogHandle>(null)
const handleDelete = () => {
// 命令式调用子组件的方法
dialogRef.current?.open('确认完成本次演示操作吗?')
}
return (
<div>
<button onClick={handleDelete}>打开确认框</button>
{confirmed && <p>已确认</p>}
<ConfirmDialog
ref={dialogRef}
onConfirm={() => setConfirmed(true)}
/>
</div>
)
}NOTE
注意这里用的是 React 19 的写法(ref 直接作为 prop),不需要 forwardRef!如果你还在用 React 18,需要用 forwardRef 包裹子组件(参见 L14 深度专题)。
何时用 useImperativeHandle?
- 需要命令式(imperative)的操作(
open()、play()、scrollTo()) - 声明式 props 无法自然表达的场景
- 注意:大多数场景应该优先用声明式的 props/state,只在真正需要命令式 API 时才用
六、架构分层思想
推荐的项目目录结构:
src/
├── components/ ← 纯 UI 组件(只关心展示)
│ ├── ui/ ← shadcn/ui 基础组件
│ ├── TaskItem.tsx
│ └── TaskSearch.tsx
├── hooks/ ← 自定义 Hooks(业务逻辑桥梁)
│ ├── useTaskMutations.ts
│ ├── useProjectsQuery.ts
│ └── useDebounce.ts
├── api/ ← API 请求函数(最底层,只管 fetch)
│ ├── projectRequests.ts
│ └── taskRequests.ts
├── store/ ← 全局状态管理(Zustand)
│ └── useThemeStore.ts
├── lib/ ← 通用工具函数
│ ├── utils.ts
│ └── validations.ts
└── pages/ ← 页面级组件(组装一切)为什么要这么分层? 假设你们团队换掉了后端的接口(从 RESTful fetch 换成了 GraphQL):
- 面条代码:你要去所有的
组件.tsx里找 fetch。 - 分层架构:
组件 (UI)完全不碰网络;Hooks也不关心底层传输;如果请求函数保持相同输入、输出与错误约定,改动可主要集中在api/;数据模型和缓存语义改变时,上层也需要调整。
七、练习
- 编写一个
useProjectsQuery自定义 Hook,封装获取所有项目列表的useQuery,使得业务层只需要写const { data } = useProjectsQuery()即可。 - 新增“项目收藏”练习:先在项目数据中定义收藏字段和 PATCH 请求,再用
useOptimistic显示等待期间的星标;成功更新基础值,失败显示提示。主线此前尚未实现收藏。 - (思考题)哪些逻辑适合普通函数,哪些必须保留在组件或自定义 Hook 中?(提示:想一想 Hook 内部调用了什么?如果你把
useState放在普通函数里会怎样?)
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 了解胖组件带来的维护负担 | 关注点分离(UI 仅处理视图展现逻辑) |
| 编写并抽象了带有深层副作用的 Hook | 自定义 Hook 的核心规则与参数封装 |
使用了 React 19 的 useOptimistic | 局部临时 UI 与共享缓存更新的不同职责 |
| 手写了一个 Compound Component | 组合模式:Context 驱动的父子组件隐式通信 |
| 梳理了项目的分层目录结构 | 现代前端架构 (UI → Hooks → Store/API) |