07|服务端状态与缓存失效
第五章概览 / 07。目标:把 API 数据看成远程事实的本地副本;它有 key、时间、并发和失效问题,不是“fetch 后塞进 Pinia”就结束。
四类状态不要混在一起
| 类型 | 例子 | owner |
|---|---|---|
| 远程数据 | /api/projects/42 返回的项目 | 服务端;客户端只是缓存副本 |
| 请求状态 | loading/error/lastFetchedAt | 当前请求或缓存条目 |
| UI 状态 | 对话框、输入中草稿 | 组件或客户端 store |
| 派生视图 | 过滤后的项目、总数 | computed/getter |
远程数据可能被其他用户、定时任务或另一个标签页改变,因此客户端副本必须回答“何时过期、谁使它失效、失败时是否继续显示旧值”。
不依赖第三方库的最小 composable
// useProject.ts
import { onScopeDispose, readonly, ref, watch, type Ref } from 'vue'
interface Project { id: string; name: string }
type Status = 'idle' | 'loading' | 'success' | 'error'
export function useProject(id: Ref<string>) {
const data = ref<Project | null>(null)
const status = ref<Status>('idle')
const error = ref('')
let activeController: AbortController | null = null
let displayedId = ''
async function load() {
const requestedId = id.value.trim()
if (!requestedId) {
activeController?.abort()
activeController = null
data.value = null
displayedId = ''
status.value = 'idle'
error.value = ''
return
}
activeController?.abort()
if (displayedId !== requestedId) data.value = null
const controller = new AbortController()
activeController = controller
status.value = 'loading'
error.value = ''
try {
const response = await fetch(`/api/projects/${encodeURIComponent(requestedId)}`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const project = await response.json() as Project
if (controller !== activeController || controller.signal.aborted) return
data.value = project
displayedId = requestedId
status.value = 'success'
} catch (cause) {
if (controller.signal.aborted) return
if (controller !== activeController || controller.signal.aborted) return
status.value = 'error'
error.value = cause instanceof Error ? cause.message : '加载失败'
}
}
const stop = watch(id, load, { immediate: true })
onScopeDispose(() => {
stop()
activeController?.abort()
activeController = null
})
return { data: readonly(data), status: readonly(status), error: readonly(error), reload: load }
}owner 链条是:id 决定请求 key;当前 composable 拥有 controller 和请求状态;服务端拥有项目事实;调用组件只消费只读结果并触发 reload。
fetch 只有网络失败/取消等才 reject,HTTP 404/500 仍会 resolve,因此必须检查 response.ok。新 id 开始时先 abort 旧请求,并用 controller 身份再挡一次不支持或忽略 abort 的适配器,旧结果就不能覆盖新页面。
这段 composable 应在组件 setup 中调用;项目提供 /api/projects/:id,响应为 { id: string, name: string }。无后端时可用开发服务器的固定响应替代;下面的缓存实验则完全不依赖网络。首次读取 a:null/idle → null/loading → a/success;重新加载 a 失败:保留 a 并进入 error,页面可同时显示旧值和刷新失败。切到 b 时先清 a:不能在“项目 b”的标题下继续把 a 当作当前事实。若产品要保留跨 key 的占位数据,应另行返回其身份和占位标记。
加缓存时先定义 key 与 freshness
interface CacheEntry<T> {
data: T
fetchedAt: number
}
const projectCache = new Map<string, CacheEntry<Project>>()
const STALE_MS = 30_000
function readFreshProject(id: string, now = Date.now()) {
const entry = projectCache.get(id)
return entry && now - entry.fetchedAt < STALE_MS ? entry.data : null
}
function invalidateProject(id: string) {
projectCache.delete(id)
}key 必须包含所有影响结果的输入;例如项目列表还要包含 page/filter/sort/userScope。漏掉用户范围可能把 A 用户缓存展示给 B 用户。字符串拼接 key 要使用稳定编码,或使用结构化 key 的数据请求库。
常见策略:
- cache-first:新鲜时直接读,过期才请求。
- stale-while-revalidate:先显示旧数据并标记正在刷新,成功后替换;失败时保留旧数据与错误提示。
- 失效后重取:创建/修改成功后使相关详情和列表 key 失效,而不是等待 TTL。
TTL 只解决“多久可能旧”,不解决“写入后一定旧”。写操作成功后要显式更新缓存或 invalidation。
把缓存接到请求流程,而不制造第二个owner
缓存读取发生在创建网络请求之前;命中后直接更新当前视图,不新建controller。未命中才请求,成功时同时写缓存条目与当前视图,失败时只改错误状态。cache是可复用数据,composable的loading/error是本次观察者的状态,两者不能用一个全局布尔值混在一起。
以10秒TTL为例:t=0请求a并写缓存;t=4再次读a直接命中;t=5保存a成功后显式invalidate;t=6即使未到TTL也必须请求。多个请求并发时,旧请求不能在失效后重新把旧条目写回缓存。删除Map条目只删除现在,不会撤销已发请求拥有的写入机会。
实验:失效为什么还需要版本
下面只研究一个会话内的缓存,不涉及组件、HTTP 或自动重试。保存成 cache-version.mjs,用 node cache-version.mjs 运行。返回值区分命中、加载与已失效结果,使调用方不会把丢弃结果当成正常数据。
import assert from 'node:assert/strict'
function createCache(fetchValue, now, ttl = 10_000) {
const entries = new Map()
const versions = new Map()
const version = key => versions.get(key) ?? 0
function invalidate(key) {
entries.delete(key)
versions.set(key, version(key) + 1)
}
async function read(key) {
const entry = entries.get(key)
if (entry && now() - entry.fetchedAt < ttl) {
return { kind: 'hit', data: entry.data }
}
const ownVersion = version(key)
const data = await fetchValue(key)
if (ownVersion !== version(key)) return { kind: 'discarded' }
entries.set(key, { data, fetchedAt: now() })
return { kind: 'loaded', data }
}
return { read, invalidate }
}
let time = 0
const requests = []
const cache = createCache(key => new Promise(resolve => {
requests.push({ key, resolve })
}), () => time)
const old = cache.read('a')
cache.invalidate('a') // 保存成功让在途旧读取也失效
const latest = cache.read('a')
requests[1].resolve('new')
assert.deepEqual(await latest, { kind: 'loaded', data: 'new' })
requests[0].resolve('old')
assert.deepEqual(await old, { kind: 'discarded' })
time = 9_999
assert.deepEqual(await cache.read('a'), { kind: 'hit', data: 'new' })
assert.equal(requests.length, 2)
time = 10_000 // 等于 TTL 已不新鲜
const expired = cache.read('a')
assert.equal(requests.length, 3)
requests[2].resolve('refreshed')
assert.deepEqual(await expired, { kind: 'loaded', data: 'refreshed' })
console.log('cache version and TTL passed')第一次读取持有版本 0;invalidate 把 a 变成版本 1;第二次请求持有版本 1。即使旧响应最后到达,它也不能写回。版本只针对一个 key,所以使 a 失效不会阻止 b 正常完成。把 ownVersion 检查暂时删除,第一次断言会暴露错误;只清Map或只在页面判断请求轮次,都不能替共享缓存解决这个问题。
这仍不是完整请求库:两个同版本请求可能同时发生,没有去重;fetchValue 拒绝会向调用方传播,没有错误缓存;同版本并发返回顺序也没有服务端revision仲裁。将它接入前面的 composable 时,需要同时满足“观察者仍是当前controller”和“缓存key仍是当前version”才接受结果。前一个保护页面,后一个保护共享数据,两条许可不能相互替代。
跨用户或SSR应用把cache放进工厂,每个用户会话/请求创建一次并注入;不要把教学片段中的模块Map直接当成跨用户安全缓存。退出登录除了删除数据,还应中止正在执行的请求,否则晚到结果可能重新填充。
乐观更新与服务端兜底
乐观更新先改本地,再请求服务端。它必须保存旧值、定义并发顺序,并在失败时回滚;若服务端返回了规范化后的最终对象,应以响应替换本地猜测。金额、库存、权限等高风险事实通常不适合无条件乐观确认。
认证失效是特殊失败:401 通常触发清会话和重新登录,403 表示身份已知但无权限,404 可能是真不存在或服务端刻意隐藏资源。UI 文案可以统一,客户端不能推导出额外权限。
何时使用专门的数据请求库
多个页面需要去重、缓存、后台重新验证、分页、重试、乐观更新与 SSR hydration 时,优先使用成熟的 Vue 数据请求方案,而不是在 Pinia 中重写一套缓存。Pinia 可以保存客户端业务状态,远程缓存库保存 query key 和请求生命周期;二者可以协作但不互相复制数据。
练习:可取消的用户查询
实现 useUser(id),接口 /api/users/:id。契约:空 id 不请求;快速从 a 切到 b 时取消 a,最终只能显示 b;404 显示“用户不存在”,500 保留旧用户并允许重试;组件作用域结束时取消请求。
再加 10 秒 cache-first:相同 id 在有效期内不重复请求;invalidateUser(id) 后下一次必须请求。用可控 Promise 写出调用次数和完成顺序断言。
参考答案:第 07 题。
本页结论
服务端状态的 owner 不在浏览器。客户端实现的核心是 key、freshness、取消、错误分类、失效和清理;是否使用 Pinia只是容器选择。