Skip to content

Lesson 12:进阶数据交互 — 乐观更新、防抖搜索与无限滚动 ​

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

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:75~120 分钟(首次学习)
  • 先修要求:完成 Lesson 11,Mock API 正在运行,Board 已使用 Query 读取任务
  • 学习产出:实现可回滚的任务状态更新、搜索,并试用独立分页示例
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

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

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

🎯 本节目标:解决网络延迟带来的 UX 割裂感,掌握乐观更新、搜索防抖、请求竞态取消和无限滚动等数据交互模式。

📦 本节产出:通过乐观更新实现任务状态瞬间切换,带防抖功能的搜索栏,以及滑动加载的无限列表。

一、网络延迟带来的糟糕 UX ​

假设用户在一个有着几十个任务的列表(如 Board.tsx)中,想要勾选完成一个名为「上线 v1.0」的任务:

传统的后端渲染或普通 AJAX:

  1. 用户点击 checkbox。
  2. 按钮变成 Loading (Spinner 菊花图)。
  3. 经过 800ms(或者弱网 3 秒),收到后端 { status: 200 }。
  4. checkbox 终于打上了勾 ✅。

在极高频的操作(比如点赞、勾选 Todo、切换开关)中,这种体验极度拖沓。

解决方案:乐观更新 (Optimistic Updates) ​

乐观更新先按预期结果显示 UI,再与服务器确认;失败时由应用编写回滚和提示逻辑。它减少的是等待反馈的时间,不会加快网络。


二、实战:为任务状态更新添加乐观反馈 ​

我们回到 Board.tsx,准备改变单个任务 (t-1) 的状态。

ts
// 追加到 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 更新和恢复指定缓存。

tsx
// 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 } 上。

体验一下:

  1. 第一次点击:因为 delay=800,你的感觉却是——瞬间打勾!因为 onMutate 在请求发出的那一刻就改了缓存。
  2. 关闭假服务器(Ctrl+C 停掉 json-server),然后再点击——勾先打上了,请求失败后由 onError 回滚 成未打勾状态,并出现错误提示。

回滚只恢复本次任务,避免覆盖别的任务。按钮禁用持续到重新获取结束;多客户端同时编辑仍需要服务端版本校验,缓存失效也不能保证离线时刷新成功。

CAUTION

乐观更新需要明确的冲突与失败策略。本例限制同一项目的状态更新并发;需要允许并发时,必须设计逐项回滚和重新获取的时机。参见 官方乐观更新指南。


三、搜索防抖 (Debounce) ​

假设我们要给任务列表加一个实时搜索框。用户每按一个键,就触发一次接口查询。 如果每次输入事件都请求,就可能产生多次中间结果查询;实际次数还受中文输入法组合事件等影响。

3.1 什么是防抖? ​

核心思想: 只有当用户停止输入一段时间(比如 300ms)后,才真正执行搜索请求。中间的中间按键全部忽略!

3.2 实现 useDebounce 自定义 Hook ​

tsx
// 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 使用 ​

tsx
// 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'] 时:

  1. 当前观察者改为订阅新的 key;旧查询有自己的缓存,并不因为切换 key 就必然变为 stale。
  2. 即使它的网络响应后来到了,TanStack Query 也不会用它更新当前 UI——因为当前 active 的 key 已经变了。

但如果你自己用原生 fetch + useState 做搜索,就必须手动处理竞态了。

4.3 用 AbortController 手动取消请求 ​

如果你不使用 TanStack Query,而是自己用 useEffect + fetch,需要用 AbortController:

tsx
// ⚠️ 教学目的:展示为什么 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,底层请求才能响应取消。取消请求不保证服务端停止已开始的写入。

tsx
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 追加请求函数:

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 })
}
tsx
// 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 来接管这种数组合并。

tsx
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() → 新数据渲染后哨兵被推到更下面 → 用户继续滚动 → 循环。


六、练习 ​

  1. 使用 Lesson 11 已放在根 Provider 内的 ReactQueryDevtools,不要重复挂载。去点击修改某个任务的值,看着可视化的 DevTools 里那个对应的缓存项是如何由 "fresh" → "stale" → 重抓最新值的。
  2. 修改 useDebounce 的延迟时间(改成 1000ms 和 100ms),感受不同的体验差异,寻找最佳平衡点。
  3. 尝试在不使用 TanStack Query 的情况下,纯手写 useEffect + fetch + AbortController 来实现搜索功能,感受 TanStack Query 帮你省掉了多少代码。

📌 本节小结 ​

你做了什么你学到了什么
了解了修改服务端资源导致 UI 同步延迟的问题服务端响应速度导致的 UX 打折
编写了 onMutate 与缓存操作结合的回滚逻辑什么是乐观更新 (Optimistic Updates)
实现了 useDebounce 自定义 Hook防抖原理、300ms 延迟窗口设计
了解了快速切换导致的请求竞态TanStack Query 自动竞态保护 + AbortController
用 placeholderData 改善换页体验防止重新进入 Loading 态
引入 useInfiniteQuery 接管分页追加逻辑多维数组结构展平 + IntersectionObserver 自动触底

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