Skip to content

缓存、失效与请求竞态 ​

问题:为什么“把结果放进 Map”还不够 ​

两个组件同时请求同一项目时,希望只发一次请求;刚编辑项目后,又希望旧结果立即作废。如果失效发生时旧请求仍在途中,它晚到后还可能把旧值重新写回缓存。这说明缓存不只是容器,而是带时间和所有权的协议。

React 核心没有通用客户端数据缓存。稳定版 React.cache 面向 React 管理的服务端组件请求记忆化,并不是下面这类浏览器服务端状态库的替代品。这里实现小型缓存是为了看清去重、TTL、失效和竞态;生产需求还会涉及重试、持久化、分页、离线、垃圾回收和跨标签页同步,通常应采用框架 loader 或专门库。

完整可运行实验 ​

保存为 cache-demo.mjs,使用 Node 18+ 运行 node cache-demo.mjs。它不依赖 React,因为缓存协议应先能独立验证,再由 Hook 或路由层订阅。

js
import assert from 'node:assert/strict'

function createResourceCache(load, { ttlMs = 30_000, now = Date.now } = {}) {
  const entries = new Map()
  const versions = new Map()

  function versionOf(key) {
    return versions.get(key) ?? 0
  }

  function get(key) {
    const current = entries.get(key)
    if (current?.status === 'success' && current.expiresAt > now()) {
      return Promise.resolve(current.value)
    }
    if (current?.status === 'pending') return current.promise

    const controller = new AbortController()
    const startedAtVersion = versionOf(key)
    let loaded
    try {
      loaded = load(key, { signal: controller.signal })
    } catch (error) {
      loaded = Promise.reject(error)
    }
    const promise = Promise.resolve(loaded)
      .then(value => {
        if (versionOf(key) === startedAtVersion) {
          entries.set(key, { status: 'success', value, expiresAt: now() + ttlMs })
        }
        return value
      })
      .catch(error => {
        if (versionOf(key) === startedAtVersion) entries.delete(key)
        throw error
      })

    entries.set(key, { status: 'pending', promise, controller })
    return promise
  }

  function invalidate(key) {
    versions.set(key, versionOf(key) + 1)
    const entry = entries.get(key)
    if (entry?.status === 'pending') entry.controller.abort()
    entries.delete(key)
  }

  return { get, invalidate }
}

const pending = []
const load = (key, { signal }) => new Promise((resolve, reject) => {
  const request = { key, resolve, reject, signal }
  signal.addEventListener('abort', () => reject(
    new DOMException('aborted', 'AbortError'),
  ), { once: true })
  pending.push(request)
})

let clock = 1_000
const cache = createResourceCache(load, { ttlMs: 100, now: () => clock })

const first = cache.get('p1')
const duplicate = cache.get('p1')
assert.equal(first, duplicate, '并发读取必须共享同一个 Promise')
assert.equal(pending.length, 1)

pending[0].resolve({ id: 'p1', title: '旧标题' })
assert.deepEqual(await first, { id: 'p1', title: '旧标题' })
assert.deepEqual(await cache.get('p1'), { id: 'p1', title: '旧标题' })
assert.equal(pending.length, 1, 'TTL 内命中结果缓存')

clock = 1_101
const expired = cache.get('p1')
assert.equal(pending.length, 2, '过期后重新加载')
pending[1].resolve({ id: 'p1', title: '新标题' })
assert.equal((await expired).title, '新标题')

const stale = cache.get('p2')
cache.invalidate('p2')
await assert.rejects(stale, error => error.name === 'AbortError')
const afterInvalidation = cache.get('p2')
assert.equal(pending.length, 4)
pending[3].resolve({ id: 'p2', title: '失效后结果' })
assert.equal((await afterInvalidation).title, '失效后结果')

let attempts = 0
const syncFailureCache = createResourceCache(() => {
  attempts++
  if (attempts === 1) throw new Error('同步失败')
  return Promise.resolve({ id: 'p3', title: '重试成功' })
})
await assert.rejects(syncFailureCache.get('p3'), /同步失败/)
assert.equal((await syncFailureCache.get('p3')).title, '重试成功')
assert.equal(attempts, 2, '同步异常也要清除失败条目,让下一次读取可重试')

console.log('cache contract: ok')

三个概念不要混用 ​

  • 请求去重:同一时刻、同一 key 的调用共享一个 pending Promise;它解决重复工作。
  • 结果缓存:在 TTL 内直接返回已完成值;它解决返回页面时的等待。
  • 失效:业务事件宣布已有值不再可信;它不是“等 TTL 自然过期”。保存成功、权限变化或实时消息都可能触发主动失效。

versions 是关键。invalidate 先递增版本再取消和删除;即使某个加载器不响应 AbortSignal,旧 Promise 兑现时看到版本已变化,也无权回填缓存。取消用于节约资源,版本比较用于保证正确性,两者不能互相替代。

保存已成功,为什么屏幕还是旧标题 ​

假设 get('project:1') 缓存了旧标题,保存接口随后接受新标题。保存成功改变的是服务器记录;本地 entries 并不知道外部发生了写入。如果下一次读取仍处于 TTL 内,就会直接得到旧值。保存方需要明确执行 invalidate('project:1'),接着由页面重新读取,或将可信的保存响应按约定写入缓存。

失效也不等于已有 Promise 被改写。若旧加载器忽略 abort,旧调用者仍可能拿到它自己的旧结果,只是不能再回填缓存。视图仍需要 Effect 清理中的相关性检查;否则缓存协议正确,旧组件请求仍可能覆盖界面。这是缓存写入权与视图发布权两道不同的检查。

这个 Map 没有订阅,所以只调用 invalidate 不会自动重渲染或重新发请求。库提供的失效常常还负责通知活跃观察者重新加载,不能把同名方法看成完全相同语义。后续练习的 peek 也只是读取,不是订阅入口。

从缓存返回到组件 ​

一个 Hook 可以在 Effect 中调用 cache.get(key),把得到的快照写入本地视图状态,并在清理时忽略旧结果。但多个组件要随缓存变化自动更新,还需要订阅协议(例如 useSyncExternalStore 所要求的 subscribe/getSnapshot),不能只共享一个可变 Map 后期待 React 自己重渲染。

成熟的服务端状态库通常已经处理订阅、失效、重新聚焦加载、重试和垃圾回收。选择它时要写清:query key 的规范、fresh/stale 时间、mutation 后失效范围、错误重试策略和卸载后的请求责任。

变体与边界 ​

  • 错误是否缓存是产品契约。示例删除失败条目,所以下次读取可重试;若缓存错误,必须有明确 TTL 或手动恢复入口。
  • loader 可能返回 Promise,也可能在创建 Promise 前同步抛错。try/catch 把两种失败都送进同一个拒绝通道,同时保留 get() 调用时立即启动加载的行为。
  • TTL 只说明“最多相信多久”,不能证明值没有变化。强一致需求要依赖版本号、ETag、推送或保存后的精确失效。
  • key 必须稳定且完整。把用户身份、过滤条件或语言漏出 key 会造成数据串用;把对象直接当 key 又可能因新引用而永远不命中。
  • 缓存不是授权层。旧权限数据即使在内存里,也不能替代服务端权限判断。

练习:按条件失效并只读观察 ​

在缓存中加入 peek(key) 与 invalidateWhere(predicate):peek 只在未过期成功条目上返回值,不能触发加载;invalidateWhere 对所有满足谓词的 key 执行与单 key 失效相同的版本递增、取消和删除。谓词接收 key。用断言证明:两个 project: key 被清除、user: key 保留;pending 项被取消;旧请求不能在失效后回填。

核对第 03 篇答案

下一篇 ​

进入SSR、水合与服务端组件,观察数据与组件跨过服务器边界以后,首屏 HTML 和交互如何接起来。

用心记录每一步成长