别再用错缓存!前端工程师必懂的 staleTime 与 cacheTime 差异详解

333 阅读8分钟

背景

在现代前端开发中,客户端缓存已成为提升页面响应速度、优化用户体验的重要手段。本文关注的核心是请求级别的数据缓存,即如何在前端框架中管理接口请求的缓存生命周期与一致性问题,常用于 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分钟再请求,由于缓存过期了,所以会直接请求新数据。

根据两个参数的高低,分成下面四种场景,可以根据场景来调整。

场景staleTimecacheTime特点
帮助中心数据长时间内不变,适合长期缓存与“秒开”体验
详情页页面跳转频繁,缓存命中率高,延迟刷新即可
列表必须实时拉取数据,不做缓存
搜索建议短暂缓存历史请求结果,提高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的标准

  1. 频率控制:在活动列表页面,为了避免频繁触发预请求后端接口,需要给数据设置一个保鲜时间T, 请求之前先判断距离上次缓存是否>T,如果小于时间间隔就不请求。

  2. 进入详情页,从缓存中根据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 ,直推大老板,流程很快:

  1. 3-5年工作经验
  2. 至少本科全日制,985 211 更优
  3. 5年内工作经历不超过2家
  4. 熟悉Web应用的性能优化,了解微前端、lowcode项目优先