Lesson 12:进阶数据交互 — 乐观更新、防抖搜索与无限滚动
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:75~120 分钟(首次学习)
- 先修要求:完成 Lesson 11,Mock API 正在运行,Board 已使用 Query 读取任务
- 学习产出:实现可回滚的任务状态更新、搜索,并试用独立分页示例
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:解决网络延迟带来的 UX 割裂感,掌握乐观更新、搜索防抖、请求竞态取消和无限滚动等数据交互模式。
📦 本节产出:通过乐观更新实现任务状态瞬间切换,带防抖功能的搜索栏,以及滑动加载的无限列表。
一、网络延迟带来的糟糕 UX
假设用户在一个有着几十个任务的列表(如 Board.tsx)中,想要勾选完成一个名为「上线 v1.0」的任务:
传统的后端渲染或普通 AJAX:
- 用户点击 checkbox。
- 按钮变成
Loading (Spinner 菊花图)。 - 经过 800ms(或者弱网 3 秒),收到后端 { status: 200 }。
- checkbox 终于打上了勾
✅。
在极高频的操作(比如点赞、勾选 Todo、切换开关)中,这种体验极度拖沓。
解决方案:乐观更新 (Optimistic Updates)
乐观更新先按预期结果显示 UI,再与服务器确认;失败时由应用编写回滚和提示逻辑。它减少的是等待反馈的时间,不会加快网络。
二、实战:为任务状态更新添加乐观反馈
我们回到 Board.tsx,准备改变单个任务 (t-1) 的状态。
// 追加到 src/api/taskRequests.ts;保留上一课的 Task 类型、fetchTasks 和 apiRequest 导入
export const updateTaskStatus = ({ taskId, status }: { taskId: string; status: Task['status'] }) =>
apiRequest<Task>(`/tasks/${encodeURIComponent(taskId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})在组件里使用 useMutation 对象来实现这套回滚逻辑。这里涉及到了通过 queryClient 更新和恢复指定缓存。
// src/components/TaskItem.tsx
import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateTaskStatus, type Task } from '../api/taskRequests'
export default function TaskItem({ task }: { task: Task }) {
const queryClient = useQueryClient()
const projectId = task.projectId
const mutationKey = ['task-status', projectId]
const isUpdating = useIsMutating({ mutationKey }) > 0
const mutation = useMutation({
mutationKey,
mutationFn: updateTaskStatus,
// 💥 重点 1: onMutate 在发送网络请求前立即执行!
onMutate: async (variables) => {
// 1. 先取消任何正在进行的查询(防止我们正在乐观更新的时候,
// 旧的真实数据回来了把我们覆盖掉)
await queryClient.cancelQueries({ queryKey: ['tasks', projectId] })
// 2. 将旧的缓存数据 "快照" 下来(为了万一失败了能够回滚)
const previousTask = queryClient.getQueryData<Task[]>(['tasks', projectId])
?.find(item => item.id === variables.taskId)
// 3. 乐观地、强行修改缓存并让 UI 立刻重绘!
queryClient.setQueryData<Task[]>(['tasks', projectId], (old) =>
// 🛡️ 注意加可选链 ?.,防止缓存还没建立好时就点触发报错崩溃!
old?.map(t => t.id === variables.taskId
? { ...t, status: variables.status }
: t
)
)
// 4. 将旧的快照传递到错误处理函数(context 的机制)
return { previousTask }
},
// 💣 重点 2: 如果网络请求崩了怎么办? (如 500 后端错误,或者是断网)
onError: (_err, _variables, context) => {
// 5. 使用刚才保存的快照,强行把缓存倒带回滚!
if (context?.previousTask) {
const previousTask = context.previousTask
queryClient.setQueryData<Task[]>(['tasks', projectId], old =>
old?.map(item => item.id === previousTask.id ? { ...item, status: previousTask.status } : item),
)
}
},
// 🏁 重点 3: 终于不论是成功还是失败(settled = ended)
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks', projectId] })
})
// 用户点击事件
const handleToggle = () => {
// 同项目一次只提交一个状态更新,避免连续点击产生快照覆盖。
if (queryClient.isMutating({ mutationKey }) > 0) return
mutation.mutate({
taskId: task.id,
status: task.status === 'done' ? 'todo' : 'done'
})
}
return (
<button
type="button"
disabled={isUpdating}
aria-pressed={task.status === 'done'}
onClick={handleToggle}
className={`w-full text-left flex items-center gap-3 p-4 border rounded-xl disabled:cursor-wait transition-all
${task.status === 'done' ? 'bg-green-50 border-green-200' : 'bg-white hover:bg-gray-50'}
${mutation.isPending ? 'opacity-60' : 'opacity-100'}
`}
>
<span className="text-xl">{task.status === 'done' ? '✅' : '⬜️'}</span>
<span className={task.status === 'done' ? 'line-through text-gray-400' : ''}>
{task.title}
</span>
{mutation.isError && (
<span className="ml-auto text-xs text-red-500 animate-pulse">⚠️ 更新失败,已回滚</span>
)}
</button>
)
}在 Board.tsx 顶部导入 TaskItem,把任务 <li> 的内容替换为 <TaskItem task={task} />。本组件乐观修改的是精确的 Task[] 缓存 ['tasks', id];本课后面的分页示例使用不同 key,不能把这个数组更新器直接套到无限查询的 { pages, pageParams } 上。
体验一下:
- 第一次点击:因为
delay=800,你的感觉却是——瞬间打勾!因为onMutate在请求发出的那一刻就改了缓存。 - 关闭假服务器(
Ctrl+C停掉json-server),然后再点击——勾先打上了,请求失败后由onError回滚 成未打勾状态,并出现错误提示。
回滚只恢复本次任务,避免覆盖别的任务。按钮禁用持续到重新获取结束;多客户端同时编辑仍需要服务端版本校验,缓存失效也不能保证离线时刷新成功。
CAUTION
乐观更新需要明确的冲突与失败策略。本例限制同一项目的状态更新并发;需要允许并发时,必须设计逐项回滚和重新获取的时机。参见 官方乐观更新指南。
三、搜索防抖 (Debounce)
假设我们要给任务列表加一个实时搜索框。用户每按一个键,就触发一次接口查询。 如果每次输入事件都请求,就可能产生多次中间结果查询;实际次数还受中文输入法组合事件等影响。
3.1 什么是防抖?
核心思想: 只有当用户停止输入一段时间(比如 300ms)后,才真正执行搜索请求。中间的中间按键全部忽略!
3.2 实现 useDebounce 自定义 Hook
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react'
/**
* 将快速变化的值延迟更新。
* @param value 原始值(可能每毫秒都在变)
* @param delay 防抖延迟(建议 300-500ms)
* @returns 稳定后的值
*/
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
// 每次 value 改变时,设一个计时器
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 如果 value 在 delay 毫秒内又变了,清掉上一个计时器
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}3.3 搭配 TanStack Query 使用
// src/components/TaskSearch.tsx
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useDebounce } from '../hooks/useDebounce'
import { apiRequest } from '../api/http'
import type { Task } from '../api/taskRequests'
export default function TaskSearch({ projectId }: { projectId: string }) {
const [searchText, setSearchText] = useState('')
// 🔑 核心!把原始值"减速"
const debouncedSearch = useDebounce(searchText, 300)
// queryKey 中使用防抖后的值!
// 这样只有在用户停止输入 300ms 后,这个 queryKey 才会变,才触发请求
const { data: results, isLoading, isError, error } = useQuery({
queryKey: ['tasks', projectId, 'search', debouncedSearch],
queryFn: ({ signal }) => {
const params = new URLSearchParams({ projectId, q: debouncedSearch.trim() })
return apiRequest<Task[]>(`/tasks?${params}`, { signal })
},
enabled: Boolean(projectId) && debouncedSearch.trim().length > 0, // 空字符串时不发请求
})
return (
<div>
<input
aria-label="搜索当前项目任务"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="搜索任务..."
className="w-full border rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
/>
{/* 视觉反馈:用户在打字时给一点提示 */}
{searchText && searchText !== debouncedSearch && (
<p className="text-xs text-gray-400 mt-1 animate-pulse">正在等你停下来...</p>
)}
{isLoading && (
<p className="text-sm text-gray-500 mt-2">搜索中...</p>
)}
{isError && <p role="alert">{error.message}</p>}
{debouncedSearch.trim() && results?.map(task => (
<div key={task.id} className="p-3 border-b">{task.title}</div>
))}
</div>
)
}在 Board.tsx 导入 TaskSearch,将 <TaskSearch projectId={id} /> 放在标题下方。此处 q 是 json-server 0.17.4 的全文搜索参数,可能匹配标题之外的字段。
四、🧠 深度专题:请求竞态与取消 (Race Conditions)
4.1 什么是请求竞态?
假设用户快速切换分类筛选:先点"图书",再马上点"电子"。
这就是竞态条件 (Race Condition):后发的请求先到,先发的请求后到,导致 UI 显示错了!
4.2 TanStack Query 的自动保护
好消息:TanStack Query 默认就解决了这个问题!
当 queryKey 从 ['tasks', 'book'] 变为 ['tasks', 'electronics'] 时:
- 当前观察者改为订阅新的 key;旧查询有自己的缓存,并不因为切换 key 就必然变为 stale。
- 即使它的网络响应后来到了,TanStack Query 也不会用它更新当前 UI——因为当前 active 的 key 已经变了。
但如果你自己用原生 fetch + useState 做搜索,就必须手动处理竞态了。
4.3 用 AbortController 手动取消请求
如果你不使用 TanStack Query,而是自己用 useEffect + fetch,需要用 AbortController:
// ⚠️ 教学目的:展示为什么 TanStack Query 帮你做了多少脏活累活
useEffect(() => {
const controller = new AbortController() // 创建一个 "取消开关"
fetch(`/api/tasks?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`请求失败:${res.status}`)
return res.json()
})
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') return // 被主动取消的,正常情况,别报错
console.error(err)
})
// 每次 query 变化时,先取消上一次的请求!
return () => controller.abort()
}, [query])以上 /api 片段用于说明原理,接口、state 和 effect 放在自己的组件中。TanStack Query 会为 queryFn 提供 signal;只有把它传给 fetch,底层请求才能响应取消。取消请求不保证服务端停止已开始的写入。
useQuery({
queryKey: ['tasks', query],
// queryFn 的唯一参数是上下文对象,其中包含 signal
queryFn: ({ signal }) =>
fetch(`/api/tasks?q=${encodeURIComponent(query)}`, { signal }).then(r => {
if (!r.ok) throw new Error(`请求失败:${r.status}`)
return r.json()
}),
})五、分页 (Pagination) 与无限加载 (Infinite Queries)
列表足够大时,分页可以降低传输量和渲染开销;是否需要分页应结合数据大小和交互需求决定。
json-server 的分页支持
我们的 Mock 也可以通过修改 URL 分页:http://localhost:3001/tasks?projectId=proj-1&_page=1&_limit=10
5.1 经典上一页/下一页
其实这就是个 useState 管理 page 变量的正常 useQuery!
分页与无限列表是独立的只读展示示例,可在 Board 中分别试用,不同时替换主线缓存。先在 taskRequests.ts 追加请求函数:
export const fetchTaskPage = (projectId: string, page: number, size: number, signal?: AbortSignal) => {
const params = new URLSearchParams({ projectId, _page: String(page), _limit: String(size) })
return apiRequest<Task[]>(`/tasks?${params}`, { signal })
}// src/components/PagedTaskList.tsx
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { fetchTaskPage } from '../api/taskRequests'
export default function PagedTaskList({ projectId }: { projectId: string }) {
const [page, setPage] = useState(1)
const { data, isPending, isError, error, isPlaceholderData } = useQuery({
queryKey: ['tasks', projectId, 'page', page],
queryFn: ({ signal }) => fetchTaskPage(projectId, page, 10, signal),
placeholderData: keepPreviousData,
})
return (
<section>
{isPending && <p>加载中...</p>}
{isError && <p role="alert">{error.message}</p>}
<ul className={isPlaceholderData ? 'opacity-50' : ''}>
{data?.map(task => <li key={task.id}>{task.title} · {task.status}</li>)}
</ul>
<div className="mt-4 flex gap-3">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>上一页</button>
<span>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)}
disabled={isPending || isError || isPlaceholderData || !data || data.length < 10}>
下一页
</button>
</div>
</section>
)
}在 Board 中用 <PagedTaskList key={id} projectId={id} /> 挂载,切换项目时重置页码,避免占位数据串到另一个项目。这个示例按页长度判断下一页;最后一页恰好满页时,会多请求一个空页。若需精确页数,可读取 json-server 的 X-Total-Count 响应头。
5.2 瀑布流下拉加载 useInfiniteQuery
在社交媒体和现代后台的无限表格中,非常常见滚动到底部加载更多。 TanStack 专门提供了一个 Hook useInfiniteQuery 来接管这种数组合并。
import { useInfiniteQuery } from '@tanstack/react-query'
import { useRef, useEffect, useCallback } from 'react'
import { fetchTaskPage } from '../api/taskRequests'
const PAGE_SIZE = 5
export default function InfiniteTaskList({ projectId }: { projectId: string }) {
const {
data, // 返回的是一个包着多页数据的 { pages: [[A,B], [C,D]] } 结构
fetchNextPage, // 去加载下一页的触发器函数
hasNextPage, // 根据 getNextPageParam 算出来的有没有下一页
isFetchingNextPage,// 正在加载下一页状态中
isPending, // 首次加载
isFetching,
isError,
error,
refetch,
} = useInfiniteQuery({
queryKey: ['tasks', projectId, 'infinite'],
queryFn: ({ pageParam, signal }) => fetchTaskPage(projectId, pageParam, PAGE_SIZE, signal),
initialPageParam: 1,
// 这个回调告诉库:根据现在这一页的返回值,下一页应该请求第几页?
getNextPageParam: (lastPage, allPages) => {
// 如果最近一页返回的条目数等于 PAGE_SIZE,说明可能还有更多
return lastPage.length === PAGE_SIZE ? allPages.length + 1 : undefined
},
})
// ====== IntersectionObserver 自动触底加载 ======
const bottomRef = useRef<HTMLDivElement>(null)
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry?.isIntersecting && hasNextPage && !isFetching && !isError) {
fetchNextPage()
}
}, [hasNextPage, isFetching, isError, fetchNextPage])
useEffect(() => {
const el = bottomRef.current
if (!el) return
const observer = new IntersectionObserver(handleObserver, {
// 当底部哨兵元素进入视口时触发
threshold: 0,
})
observer.observe(el)
return () => observer.disconnect()
}, [handleObserver, isPending])
if (isPending) return <div className="animate-pulse p-8 text-gray-400">加载中...</div>
return (
<div className="space-y-2">
{/* 因为数据被分面包裹,需要双重循环展平 */}
{data?.pages.flatMap(page => page).map(task => (
<div key={task.id}>{task.title} · {task.status}</div>
))}
{isError && <p role="alert">{error.message}</p>}
{isError && !data && <button disabled={isFetching} onClick={() => void refetch()}>重试加载列表</button>}
{hasNextPage && <button disabled={isFetching} onClick={() => void fetchNextPage()}>加载更多 / 重试</button>}
{/* 底部哨兵元素 —— IntersectionObserver 的监视目标 */}
<div ref={bottomRef} className="h-4" />
{/* 状态文案 */}
{isFetchingNextPage && (
<div className="text-center py-4 text-gray-400 animate-pulse">
加载更多中...
</div>
)}
{!hasNextPage && (data?.pages.length ?? 0) > 0 && (
<div className="text-center py-4 text-gray-300 text-sm">
— 到底啦,没有更多了 —
</div>
)}
</div>
)
}将上例保存为 src/components/InfiniteTaskList.tsx,在 Board 中用 <InfiniteTaskList key={id} projectId={id} /> 试用。普通 Query 的数组缓存和 Infinite Query 的 { pages, pageParams } 不能使用相同 key。观察器 effect 依赖 isPending,首次数据返回、哨兵挂载后才会重新观察;自动加载失败后由按钮重试,避免失败循环。
5.3 IntersectionObserver 原理图解
当用户滚动到列表底部时,底部哨兵 div 进入视口 → IntersectionObserver 触发回调 → fetchNextPage() → 新数据渲染后哨兵被推到更下面 → 用户继续滚动 → 循环。
六、练习
- 使用 Lesson 11 已放在根 Provider 内的
ReactQueryDevtools,不要重复挂载。去点击修改某个任务的值,看着可视化的 DevTools 里那个对应的缓存项是如何由 "fresh" → "stale" → 重抓最新值的。 - 修改
useDebounce的延迟时间(改成 1000ms 和 100ms),感受不同的体验差异,寻找最佳平衡点。 - 尝试在不使用 TanStack Query 的情况下,纯手写
useEffect+fetch+AbortController来实现搜索功能,感受 TanStack Query 帮你省掉了多少代码。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 了解了修改服务端资源导致 UI 同步延迟的问题 | 服务端响应速度导致的 UX 打折 |
编写了 onMutate 与缓存操作结合的回滚逻辑 | 什么是乐观更新 (Optimistic Updates) |
实现了 useDebounce 自定义 Hook | 防抖原理、300ms 延迟窗口设计 |
| 了解了快速切换导致的请求竞态 | TanStack Query 自动竞态保护 + AbortController |
用 placeholderData 改善换页体验 | 防止重新进入 Loading 态 |
引入 useInfiniteQuery 接管分页追加逻辑 | 多维数组结构展平 + IntersectionObserver 自动触底 |