Skip to content

07|服务端状态与缓存失效 ​

第五章概览 / 07。目标:把 API 数据看成远程事实的本地副本;它有 key、时间、并发和失效问题,不是“fetch 后塞进 Pinia”就结束。

四类状态不要混在一起 ​

类型例子owner
远程数据/api/projects/42 返回的项目服务端;客户端只是缓存副本
请求状态loading/error/lastFetchedAt当前请求或缓存条目
UI 状态对话框、输入中草稿组件或客户端 store
派生视图过滤后的项目、总数computed/getter

远程数据可能被其他用户、定时任务或另一个标签页改变,因此客户端副本必须回答“何时过期、谁使它失效、失败时是否继续显示旧值”。

不依赖第三方库的最小 composable ​

ts
// 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 ​

ts
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 运行。返回值区分命中、加载与已失效结果,使调用方不会把丢弃结果当成正常数据。

js
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只是容器选择。

用心记录每一步成长