Skip to content

Lesson 15:重构业务逻辑 — 自定义 Hooks、useOptimistic 与组合模式 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:90~120 分钟(首次学习)
  • 先修要求:完成 Lesson 14,理解请求层、Query mutation 与表单提交
  • 学习产出:提取任务 mutation Hook,比较缓存乐观更新与局部useOptimistic,并实现组合组件
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:将越来越臃肿的组件拆解,提取可复用的业务逻辑,并掌握 React 19 新增的 useOptimistic Hook 和高级组件组合模式。

📦 本节产出:通过编写自定义 Hook(useTaskStatusMutation,复用已有 useDebounce)让 UI 组件重新变得清爽;比较 useOptimistic 的局部乐观 UI 与共享缓存更新;理解 Compound Components 组合模式。

一、组件正在变得臃肿 ​

在前面的课程中,我们在 Board.tsx 和 TaskItem.tsx 里塞入了大量的逻辑:

  • TanStack Query 的 useQuery 和 useMutation
  • 乐观更新中复杂的缓存快照(onMutate、onError 等)
  • 各种按钮的点击事件处理
  • UI 的渲染(JSX)
tsx
// ❌ 典型的"胖组件" (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 函数,需遵守以下规则:

  1. 名字必须以 use 开头(比如 useTasks、useWindowSize)。
  2. 在这个函数内部,可以调用其他的 Hook(比如 useState、useQuery)。
  3. 在函数组件或自定义 Hook 的顶层调用,且每次渲染保持调用顺序;不能放进事件、条件或循环中。React 的 use API 是允许条件读取的特例,不代表自定义 Hook 都能这么用。
  4. 复用 Hook 复用的是逻辑,不会自动让多个调用共享本地 state;本课共享数据来自外部 Query 缓存。

2.1 实战:提取请求逻辑 Hook ​

把之前 Lesson 12 里面那一长串乐观更新逻辑,抽取成一个专门的 useTaskStatusMutation:

ts
// 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:

tsx
// 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;只等待请求而不更新数据,乐观状态结束后就会退回旧值。

tsx
// 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 缓存,使所有订阅者收到正式结果:

tsx
// 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 组件是这么用的:

tsx
<Dialog>
  <DialogTrigger>打开</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>标题</DialogTitle>
    </DialogHeader>
  </DialogContent>
</Dialog>

这种"一个父组件包含多个语义化子组件"的模式叫做 Compound Components(组合组件)。

4.1 为什么要这种模式? ​

tsx
// ❌ 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(手风琴折叠面板)作为教学示例:

tsx
// 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>
  )
}

使用方式:

tsx
<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 自定义暴露的方法:

tsx
// 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>
  )
}
tsx
// 独立演示组件,不替换主线 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/;数据模型和缓存语义改变时,上层也需要调整。

七、练习 ​

  1. 编写一个 useProjectsQuery 自定义 Hook,封装获取所有项目列表的 useQuery,使得业务层只需要写 const { data } = useProjectsQuery() 即可。
  2. 新增“项目收藏”练习:先在项目数据中定义收藏字段和 PATCH 请求,再用 useOptimistic 显示等待期间的星标;成功更新基础值,失败显示提示。主线此前尚未实现收藏。
  3. (思考题)哪些逻辑适合普通函数,哪些必须保留在组件或自定义 Hook 中?(提示:想一想 Hook 内部调用了什么?如果你把 useState 放在普通函数里会怎样?)

📌 本节小结 ​

你做了什么你学到了什么
了解胖组件带来的维护负担关注点分离(UI 仅处理视图展现逻辑)
编写并抽象了带有深层副作用的 Hook自定义 Hook 的核心规则与参数封装
使用了 React 19 的 useOptimistic局部临时 UI 与共享缓存更新的不同职责
手写了一个 Compound Component组合模式:Context 驱动的父子组件隐式通信
梳理了项目的分层目录结构现代前端架构 (UI → Hooks → Store/API)

项目驱动 · 边写边学 · React 19