cal.diy 前端实战:用 SWR 在 React 客户端数据获取中实现自动请求去重
导读
本篇文章聚焦于 cal.diy 仓库内 vercel-react-best-practices 技能体系中 client-swr-dedup 这条客户端数据获取规范:当多个 React 组件实例需要读取同一份远程数据时,如何用 SWR 的 key 式缓存让它们共享同一次网络请求,同时获得缓存与自动重新校验能力。读完本文你将掌握 useSWR、useImmutableSWR、useSWRMutation 三个 API 的选型与用法、SWR 自动去重背后的机制原理,以及如何用极少的代码消除重复请求带来的性能浪费。
一、为什么客户端数据获取需要"去重"
1.1 规则在技能体系中的定位
在 SKILL.md 的分类表中,客户端数据获取(Client-Side Data Fetching)被列为 MEDIUM-HIGH 优先级类别,其中包含两条规则:
| 规则文件 | 主题 |
|---|---|
client-swr-dedup |
使用 SWR 实现自动请求去重 |
client-event-listeners |
全局事件监听器去重 |
规则文件 client-swr-dedup.md 自身的元数据也标注了 impact: MEDIUM-HIGH,impactDescription: automatic deduplication,关键词为 client, swr, deduplication, data-fetching。这说明它面向的是所有"需要从客户端直接发起数据请求"的场景,属于影响面较广的通用型规范。
1.2 问题本质:多个实例各自发请求
在 React 应用中,同一份数据常常被渲染在多个组件实例里——例如一个页面中有导航栏的"当前用户"徽标、侧边栏的用户列表、以及页头统计信息,它们都需要 /api/users 的数据。如果用"每个组件各自 fetch"的写法,三个组件就会在同一时刻发出三份内容完全相同的 HTTP 请求,产生三重浪费:
- 网络流量浪费:相同响应被重复传输;
- 服务器压力上升:相同查询被重复处理、重复打库;
- 加载体验恶化:每个组件各自经历 loading 状态,晚挂载的组件还要重复等待。
规则文档给出的反面示例正是这种模式(client-swr-dedup.md):
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
}
这段代码除了没有去重外,还存在两个隐患:其一,setUsers 没有在 effect 里做取消保护,组件卸载或 key 变化后仍可能触发 setState(React 严格模式下 effect 还会执行两次,进而发出两次请求);其二,useState([]) 无法表达"加载中 / 出错"状态,调用方需要自行拼装。SWR 正是为同时解决这些问题而设计的。
二、SWR 的核心:key 驱动的缓存与自动去重
SWR 是 Vercel 团队开源的 React Hooks 数据请求库,名字取自 HTTP 缓存策略 stale-while-revalidate(先返回缓存、再后台重新验证)。规则文档明确指出:"SWR enables request deduplication, caching, and revalidation across component instances",即它跨组件实例提供了三层能力:
- 去重(deduplication):同一时间窗口内、相同 key 的并发请求只发一次;
- 缓存(caching):响应结果按 key 存入全局共享缓存,供任意组件复用;
- 重新校验(revalidation):窗口切换回前台、重新聚焦、间隔轮询等时机下自动刷新数据。
2.1 key 是去重的唯一依据
SWR 把"请求地址 + 请求参数"序列化成一个 cache key(通常是 URL 字符串或由多个参数组成的数组)。任何组件只要传入相同的 key,就会命中同一份缓存条目。去重判断发生在发送请求之前:当多个 useSWR 组件在很短的时间内先后挂载并注册了相同 key 时,SWR 会复用"第一个注册者发起的、仍在进行中的那次请求",其余组件直接共享其结果。
从源码结构看,这一机制的实现依赖 SWR 内部全局共享的 cache store(默认是一个基于 Map 的内存缓存)与订阅发布系统:组件挂载时向 store 注册 key 并订阅更新,请求完成后写入缓存并广播,所有订阅该 key 的组件同时收到新数据并触发渲染。因此"组件 A 先发请求、组件 B 后挂载"也能直接命中 A 已经写入的缓存,实现跨组件复用。
2.2 正确写法:多实例共享同一次请求
规则文档给出的正确示例只有一个 Hook 调用:
import useSWR from 'swr'
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
}
这里的 fetcher 是返回 Promise 的函数,接收 key 作为参数,例如:
const fetcher = (url: string) => fetch(url).then(r => r.json())
// 需要在包裹层提供 fetcher,或在每个 Hook 处显式传入:
// <SWRConfig value={{ fetcher }}> ... </SWRConfig>
使用 useSWR 后,同一页面中任意多个 <UserList />(或其他同样请求 /api/users 的组件)会在去重时间窗口内只触发一次网络请求,所有实例共享 data;当数据因重新校验更新时,各实例会同步拿到新值并各自重渲染,业务代码无需编写任何缓存或同步逻辑。同时返回值还包含 isLoading、error、mutate 等字段,可以替代手写的 loading/error 状态管理。
三、按数据形态选择 API:可变 / 不可变 / 变更操作
3.1 不可变数据用 useImmutableSWR
对"一旦加载基本不会变化"的数据(如站点配置、静态字典),继续使用默认配置会引入不必要的后台重新校验。规则文档推荐使用库内封装好的 useImmutableSWR:
import { useImmutableSWR } from '@/lib/swr'
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
}
useImmutableSWR 本质是全局 SWRConfig 与 useSWR 的组合:它通过覆盖默认配置把自动重新校验全部关闭(典型做法是设置 revalidateOnFocus: false、revalidateOnReconnect: false、refreshInterval: 0),让不可变数据只请求一次并长期驻留缓存。注意示例中的 @/lib/swr 说明团队通常会在 lib 层封装统一的 SWR 出口(集中提供 fetcher、全局配置与这些便捷封装),而不是在每个组件里重复拼装配置。
如果你的代码库没有现成的 useImmutableSWR 封装,可以借助 <SWRConfig value={{ revalidateIfStale: false, revalidateOnFocus: false, revalidateOnReconnect: false }}> 在子树级别达到同样效果。
3.2 写操作必须走 useSWRMutation:只触发、不同步缓存
useSWR 本质是"读"语义——它的数据会参与全局缓存与广播;如果拿它来做 POST/PUT 等写操作,请求成功后返回的新数据会被当成该 key 的"读取结果"写回缓存,污染其他读取方。规则文档为此给出了专门的变更 Hook:
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button>
}
useSWRMutation 与 useSWR 的关键区别在于:
- 它不会在组件挂载时自动发起请求,只在调用返回的
trigger()时才执行,天然适配按钮点击、表单提交等用户驱动型操作; updateUser接收(key, { arg }),trigger(payload)传入的arg会作为第二参数交给变更函数;- 变更期间它提供独立的
isMutating状态(而非isLoading),便于按钮禁用与 loading 反馈; - 变更成功后可通过
mutate主动更新或失效对应读取 key,实现"写后重取"的一致性闭环。
3.3 三种 API 的选型速查
| 场景 | API | 触发时机 | 参与全局缓存 |
|---|---|---|---|
| 高频共享、需要实时更新的读数据 | useSWR |
挂载即请求 + 自动重新校验 | 是(去重、广播) |
| 极少变化的读数据 | useImmutableSWR |
仅首次 | 是(但关闭重校验) |
| 写 / 变更操作 | useSWRMutation |
仅调用 trigger() 时 |
否(按需 mutate 读取 key) |
四、控制去重与重新校验行为的常用配置
去重与缓存是 SWR 默认开箱即用的能力,但在真实项目中往往还需要微调。以下是决定去重窗口与缓存新鲜度的关键配置项(通过 useSWR(key, fetcher, options) 的第三参数或全局 <SWRConfig value={{...}}> 设置):
| 配置项 | 作用 | 典型值 |
|---|---|---|
dedupingInterval |
同一 key 的去重时间窗口(毫秒),窗口内重复请求被合并 | 默认 2000;希望每次都发新请求可设为 0 |
revalidateOnFocus |
窗口重新获得焦点时是否自动重新校验 | 默认开启;不可变数据设为 false |
revalidateOnReconnect |
网络恢复连接时是否自动重新校验 | 默认开启 |
refreshInterval |
轮询刷新间隔(毫秒) | 0 表示不轮询 |
revalidateIfStale |
缓存已过期(stale)时挂载是否立即重校验 | 默认开启 |
fallbackData |
首次加载前展示的占位数据 | 可传缓存快照提升首屏体验 |
keepPreviousData |
新请求进行中是否保留上一次数据 | 对分页/列表筛选很有用 |
fetcher |
统一的取数函数,通常在 SWRConfig 全局提供 |
(url) => fetch(url).then(r => r.json()) |
从原理上理解这些参数的取舍:去重解决的是"同一时刻的重复请求",而重新校验解决的是"数据过期后的更新"。前者靠 dedupingInterval 窗口内的请求合并实现;后者由 revalidateOnFocus、revalidateOnReconnect、refreshInterval 等策略触发,两者叠加便构成 stale-while-revalidate 的完整语义。当某个页面数据变动频繁时,可适当缩短 dedupingInterval 并用轮询/手动 mutate 配合;当数据几乎不可变时,则应像 useImmutableSWR 那样关停全部自动重校验,把网络请求数压到最低。
此外,useSWRConfig() 可以在任意组件内读取并更新全局配置与 cache 实例,例如执行 cache.delete(key) 主动驱逐缓存条目;在"登出后清理所有用户数据"这类场景中非常实用。
五、自动去重带来的连锁收益
将 useEffect + fetch 重构为 SWR 后,收益不止是"少发几个请求":
- 加载状态收敛:同一 key 的所有消费者共享一次请求与同一份
data,页面不会出现"头部已加载、列表还在转圈"的参差状态; - 竞态条件消失:手写 effect 中"后发出的响应先返回并覆盖新数据"的经典竞态由 SWR 的内部请求管理消除;
- 体验一致与即时跳变:切回前台或断网重连时数据自动刷新,且先渲染 stale 缓存再静默更新,感知延迟大幅下降;
- 代码量下降:loading/error/data 三态、重试、轮询等样板代码全部由 Hook 承担。
值得一提的是,从当前仓库结构看,本 monorepo 的客户端主链路大量使用了 packages/trpc(基于 react-query 的 tRPC 客户端)来承载类型安全的数据请求,其 query key 机制同样提供了查询级去重与缓存;而 client-swr-dedup 这条规则主要适用于需要直接对 REST 端点或第三方接口发起客户端请求的模块与组件。判断标准始终一致:凡是多组件共享的读请求,都必须通过一个统一的 key 式数据层收敛,而不是各写各的 fetch。若使用 tRPC 的 react-query 客户端,useQuery 的 key 收敛与 SWR 的 key 去重遵循的是同一套"同 key 共享请求"思想,可互相印证。
六、落地检查清单
在 cal.diy 的代码评审或重构中,可用以下清单快速判断是否违反了 client-swr-dedup 规则:
- [ ] 代码中是否存在
useEffect内直接fetch且结果写入useState的组件?如有,改为useSWR; - [ ] 多个组件是否请求了同一 URL/资源?若是,它们是否共享同一 SWR key 并自动去重;
- [ ] 数据是否几乎不变?是则改用
useImmutableSWR(或等价的全局配置关闭重校验); - [ ] 该请求是写操作吗?是则改用
useSWRMutation,避免污染读缓存; - [ ] 是否在 lib 层统一封装了
fetcher与 SWR 全局配置,而非在各组件零散重复定义; - [ ] 是否需要针对焦点、重连、轮询调整
dedupingInterval/revalidateOnFocus等参数,使去重窗口与数据新鲜度要求匹配。
总结
client-swr-dedup 规则给出了一套完整的客户端取数范式:读数据用 key 式 useSWR 实现去重、缓存与自动重校验;不可变数据用 useImmutableSWR 关停重校验;写操作用 useSWRMutation 按需触发且不污染读缓存。它的本质是用"同 key 共享请求"取代"每实例独立 fetch",既消除了网络与服务端的重复开销,又统一了加载状态、消灭了竞态。重构你的组件时,优先寻找 useEffect + fetch + useState 的写法并将其替换为上述三种 API 之一,即是对本条规则最直接的落地。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00