背景
在现代前端开发中,客户端缓存已成为提升页面响应速度、优化用户体验的重要手段。本文关注的核心是请求级别的数据缓存,即如何在前端框架中管理接口请求的缓存生命周期与一致性问题,常用于 React Query、SWR、ahooks/useRequest 等数据获取工具中。
本文所探讨的是请求缓存策略,不涉及浏览器层面的 HTTP 缓存(如 Cache-Control、ETag)或离线缓存机制(如 Service Worker、AppCache)。
本文将围绕缓存时间控制模型、一致性维护方式等方面展开分析,提供一套面向落地的前端请求缓存设计思路。
缓存机制
缓存一致性指的是缓存数据与真实实时数据的一致性问题,也就是缓存什么时候需要更新,以及跟后端同步。
失效策略
在前端请求领域,通常使用下面三种主流机制:
定期失效(基于时间)
为了避免长时间使用陈旧数据,可以通过设定有效时间,自动刷新或删除缓存数据。很多请求库采用类似的控制机制:
-
在 TanStack Query 中,可通过 staleTime 控制数据在多长时间内视为“新鲜”,通过 cacheTime 控制缓存的生命周期;
-
在 ahooks/useRequest 中,也可设置 cacheTime 来决定缓存保留的时长。
场景: 基于时间的失效策略适用于可以明确实时性的场景,比如对于活动详情一般短时间内不会变化 可以设置缓存10s,但是对于报名商品列表等数据实时性要求较高的场景,建议设置 staleTime: 0,保证每次请求都是最新数据
主动失效 (revalidate)
revalidate 机制适合数据主动变更场景。其工作流程如下:
-
每一份缓存数据都由一个 cacheKey 标识;
-
当数据被更新(例如编辑用户信息、新增商品、删除活动等),前端或后端显式调用 revalidate(cacheKey);
-
系统将该缓存标记为过期,下次访问时会自动触发重新请求并刷新缓存。这时候还是会返回缓存数据,并且将数据标记为stale,不会立即请求
场景: 用户在详情页中修改了活动信息,返回列表页时希望列表页数据自动刷新。这种情况下不希望立即重新请求,而是标记该缓存为过期,等用户回到页面后再自动刷新。
手动刷新 (refresh / mutate)
跟主动失效有点类似,但是区别是会立马发起请求,一般是由用户或业务逻辑主动发起请求来获取最新数据。
场景: 在选择商品报名页面上点击“刷新/查询”按钮,立即重新拉取数据
staleTime与cacheTime模型
staleTime: 数据多长时间内被认为是“新鲜”的,不触发重新请求。在保鲜期不会主动刷新
cacheTime: 数据持久化保存的时间,超过会从内存中移除
一开始我搞不清楚这俩时间的区别,感觉都是超过就会重置缓存,用一个cacheTime就可以了,下面举个例子说明一下:
useQuery(['activity', id], fetcher, {
staleTime: 10 * 1000, // 10 秒
cacheTime: 5 * 60 * 1000 // 5 分钟
});
对于上面的例子,首次请求会把请求结果缓存,在10s之内不会发起请求,但是如果>10s且<5分钟,缓存由于没有过期,所以会优先返回缓存,但是会在后台更新数据 这样保证下次请求的是最新的结果。如果超过了5分钟再请求,由于缓存过期了,所以会直接请求新数据。
根据两个参数的高低,分成下面四种场景,可以根据场景来调整。
| 场景 | staleTime | cacheTime | 特点 |
|---|---|---|---|
| 帮助中心 | 高 | 高 | 数据长时间内不变,适合长期缓存与“秒开”体验 |
| 详情页 | 中 | 高 | 页面跳转频繁,缓存命中率高,延迟刷新即可 |
| 列表 | 低 | 低 | 必须实时拉取数据,不做缓存 |
| 搜索建议 | 低 | 高 | 短暂缓存历史请求结果,提高UI响应,同时后台更新(主要是为了避免重复加载,有点类似防抖) |
乐观更新 useOptimistic
在某些场景中,在用户执行新增、删除、编辑等操作时,为了避免等待接口响应造成 UI 卡顿常采用“乐观更新(Optimistic Update) ”策略:先立即更新本地缓存或界面状态,再异步执行请求,根据最后的结果决定保留还是回滚。
React 19 引入了一个钩子可以实现乐观更新:react.dev/reference/r…
由于它假定了接下来的操作大概率是成功的,所以适用于失败可能性低的场景,比如:
-
列表删除一项(先在UI中移除,再等待接口,有点类似于软删除)
-
对于新增操作,不是所有情况都适合用乐观更新,一般是当响应数据可以根据入参估计得到的时候。比如,创建收品池,服务端返回的信息跟前端入参基本一致,这个时候可以先用入参渲染UI占位符。
缓存策略的注意事项
虽然缓存策略能显著提升性能与用户体验,但使用不当也会带来认知错觉、交互闪烁和一致性问题。在使用缓存时,务必要权衡以下几点风险:
页面闪烁
当缓存数据与服务端数据不一致,且你采用了“先展示缓存 → 后台刷新”的策略,可能会出现页面短暂展示一份数据,然后突变为另一份的视觉效果。
比如缓存有3个数据,服务端更新后展示了5条。数量突然变化会造成突变。
或者是缓存了价格字段,价格突然从999 -> 555,用户看到价格发生变化 肯定会怀疑活动有问题。
旧数据导致错误交互
如果缓存数据是旧的,但用户基于其做出操作,可能会出现“在错误前提下操作”的情况。
比如缓存了订单数据,缓存中订单是待付款状态,即使设置了staleTime比较小,如果用户操作很快,点击进入付款页,其实该订单已取消。
因此需要遵守一个原则:缓存仅用于提升展示速度,不应用作交互逻辑判断的依据。
列表页跳转详情页的预加载策略
在用户浏览商品列表、活动列表、订单列表等页面时,通常会点击某一项进入详情页。如果提前知道用户可能点击的目标项,就可以在用户点击前发起详情接口的请求,从而使详情页能“秒开”。
-
对于移动端 h5 页面,可以判断元素是否在viewport中,可以使用intersectionObserver实现
-
对于pc web端,根据鼠标悬浮时长判断,不用判断在viewport,因为能鼠标悬浮肯定就是在当前viewport
列表场景,给卡片绑定hover事件,悬停>300ms或者点击就预请求详情接口,并更新缓存中的数据。
300ms这个阈值参考自一个优秀的开源预加载库 InstantClick的标准
-
频率控制:在活动列表页面,为了避免频繁触发预请求后端接口,需要给数据设置一个保鲜时间T, 请求之前先判断距离上次缓存是否>T,如果小于时间间隔就不请求。
-
进入详情页,从缓存中根据id查询 并判断是否超过缓存时间,如果没有过期就优先展示缓存的信息。
可以写一个hooks方便使用上面的逻辑:
import { useRef } from 'react'
export interface UseHoverCallbackOptions {
callback: () => void
hoverMs?: number // 悬停阈值,默认 300ms
cooldownMs?: number // 连续触发冷却时间,默认 10s
}
export function useHoverCallback(options: UseHoverCallbackOptions) {
const { callback, hoverMs = 300, cooldownMs = 1000 * 10 } = options
const timerRef = useRef(null)
const lastActionRef = useRef<number>(0)
const onMouseEnter = () => {
if (Date.now() - lastActionRef.current < cooldownMs) return
timerRef.current = setTimeout(async () => {
await callback()
lastActionRef.current = Date.now()
}, hoverMs)
}
const onMouseLeave = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
return { onMouseEnter, onMouseLeave }
}
import { useState, useEffect, useCallback } from 'react'
import { CacheManager } from '../modules/pub/cache'
export interface UseCachedResourceOptions<T> {
cacheKey: string
fetcher: () => Promise<T>
staleTime?: number // 超过此时间后后台更新,依然使用旧数据,相当于防抖,别设置太大
cacheTime?: number // 完全过期时长,超过将重新请求
defaultValue?: T
scene?: string // 上报场景
}
export function useCachedResource<T>({
cacheKey,
fetcher,
staleTime = 0,
cacheTime = 24 * 60 * 60 * 1000,
defaultValue,
scene,
}: UseCachedResourceOptions<T>) {
const [data, setData] = useState<T | null>(defaultValue)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<any>(null)
const memoizedFetcher = useCallback(fetcher, [fetcher])
const load = useCallback(
async (force = false) => {
setLoading(true)
const now = Date.now()
try {
DraReport.recordCache(scene || 'useCachedResource') // 上报缓存次数
const cached = await CacheManager.get<T>(cacheKey, cacheTime)
if (cached && !force) {
DraReport.recordCacheHit(scene || 'useCachedResource') // 上报命中缓存次数
const isStale = staleTime > 0 && now - cached.timestamp > staleTime
setData(cached.data)
setLoading(false)
if (isStale) {
memoizedFetcher()
.then((fresh) => {
setData(fresh)
CacheManager.set(cacheKey, fresh)
})
.catch(() => {})
}
return
}
const fresh = await memoizedFetcher()
setData(fresh)
await CacheManager.set(cacheKey, fresh)
} catch (e) {
setError(e)
} finally {
setLoading(false)
}
},
[cacheKey, memoizedFetcher, staleTime, cacheTime]
)
useEffect(() => {
load()
}, [load])
return {
data,
loading,
error,
refresh: () => load(true),
}
}
重新访问页面时使用缓存
用户常从某页跳转至其他页面,再通过后退或导航栏返回原页。例如从列表进入详情页,点击返回时重新回到列表。
使用缓存可以解决白屏、闪烁等问题。
招人
京东零售交易核心部门招人了,满足下列条件的欢迎发简历到 baichen3@jd.com ,直推大老板,流程很快:
- 3-5年工作经验
- 至少本科全日制,985 211 更优
- 5年内工作经历不超过2家
- 熟悉Web应用的性能优化,了解微前端、lowcode项目优先