缓存、失效与请求竞态
问题:为什么“把结果放进 Map”还不够
两个组件同时请求同一项目时,希望只发一次请求;刚编辑项目后,又希望旧结果立即作废。如果失效发生时旧请求仍在途中,它晚到后还可能把旧值重新写回缓存。这说明缓存不只是容器,而是带时间和所有权的协议。
React 核心没有通用客户端数据缓存。稳定版 React.cache 面向 React 管理的服务端组件请求记忆化,并不是下面这类浏览器服务端状态库的替代品。这里实现小型缓存是为了看清去重、TTL、失效和竞态;生产需求还会涉及重试、持久化、分页、离线、垃圾回收和跨标签页同步,通常应采用框架 loader 或专门库。
完整可运行实验
保存为 cache-demo.mjs,使用 Node 18+ 运行 node cache-demo.mjs。它不依赖 React,因为缓存协议应先能独立验证,再由 Hook 或路由层订阅。
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 项被取消;旧请求不能在失效后回填。
下一篇
进入SSR、水合与服务端组件,观察数据与组件跨过服务器边界以后,首屏 HTML 和交互如何接起来。