Skip to content

05|第五章练习答案 ​

第五章概览 / 参考实现。先比较题目要求的可观察行为,再比较代码结构;答案不是唯一写法。

01|搜索页的状态 owner ​

合理划分如下:inputText/menuOpen 属于搜索组件;已提交的 keyword/sort 属于 URL query;products/requestError 属于以 query 为 key 的远程请求条目。新增“比较清单”需要跨路由存活,才进入独立 store。

vue
<script setup lang="ts">
import { ref } from 'vue'

const emit = defineEmits<{ search: [keyword: string] }>()
const inputText = ref('')
const error = ref('')

function submit() {
  const keyword = inputText.value.trim()
  if (!keyword) {
    error.value = '请输入关键词'
    return
  }
  error.value = ''
  emit('search', keyword)
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="inputText" aria-label="关键词" />
    <button>搜索</button>
    <p v-if="error" role="alert">{{ error }}</p>
  </form>
</template>

父页面收到 search 后用 router 更新 q;请求层监听 query。这样刷新/分享能恢复已提交条件,输入中的半成品与菜单状态不会污染历史。

02|库存 Setup Store ​

ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useInventoryStore = defineStore('inventory', () => {
  const initial = { book: 3, pen: 5 }
  const stock = ref<Record<string, number>>({ ...initial })
  const error = ref('')
  const totalUnits = computed(() =>
    Object.values(stock.value).reduce((sum, value) => sum + value, 0),
  )

  function reserve(productId: string, quantity: number) {
    const current = stock.value[productId]
    if (!Number.isInteger(quantity) || quantity <= 0) {
      error.value = '数量必须是正整数'
      return false
    }
    if (!Object.hasOwn(stock.value, productId) || current < quantity) {
      error.value = '商品不存在或库存不足'
      return false
    }
    stock.value[productId] = current - quantity
    error.value = ''
    return true
  }

  function reset() {
    stock.value = { ...initial }
    error.value = ''
  }

  return { stock, error, totalUnits, reserve, reset }
})

失败分支在任何写入前返回,因此库存保持原样。Object.hasOwn排除继承的constructor、toString等键;这些名字不是库存商品。断言reserve('constructor', 1) === false,并验证totalUnits === 8且没有新增自有键。测试隔离时分别 setActivePinia(createPinia()) 后创建 store;第二个实例的 book 应仍为 3。组件解构 stock/error/totalUnits 时用 storeToRefs(),action 可直接解构。

03|从 Vuex 迁移计数模块 ​

先锁住旧合同:commit('counter/increment', 2) 同步加 2;dispatch('counter/incrementLater', -1) reject 且 count 不变;getters['counter/doubled'] 始终是两倍。

ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, status: 'idle' as 'idle' | 'waiting' | 'error' }),
  getters: {
    doubled: state => state.count * 2,
  },
  actions: {
    increment(step = 1) {
      this.count += step
    },
    async incrementLater(delayMs: number, step = 1) {
      if (!Number.isFinite(delayMs) || delayMs < 0) {
        this.status = 'error'
        throw new RangeError('delayMs 必须是非负数')
      }
      this.status = 'waiting'
      await new Promise(resolve => setTimeout(resolve, delayMs))
      this.increment(step)
      this.status = 'idle'
      return this.count
    },
  },
})

需要额外盘点:Vuex namespace 字符串调用、持久化/审计插件、subscribeAction、动态模块、SSR state 注入和测试 mock。迁移后 action 的返回 Promise 与异常类型必须保留。

04|先验证,再一次 patch ​

ts
function applyServerSnapshot(
  snapshot: { name: string; email: string; revision: number },
  expectedRevision: number,
) {
  const name = snapshot.name.trim()
  const email = snapshot.email.trim().toLowerCase()

  if (expectedRevision !== this.revision) return false
  if (!name || !email.includes('@')) return false
  if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision <= this.revision) return false

  this.$patch({ name, email, revision: snapshot.revision, error: '' })
  return true
}

订阅测试应保存旧快照并计数:成功时一次 patch object;三类失败时计数不变且 state 深相等。不要在 $patch(state => { ... }) 中先改 name 再校验 email,因为抛错不会撤销前面的赋值。结束时调用 $subscribe() 返回的 stop()。

05|幂等的退出流程 ​

ts
export function createLogoutFlow(deps: {
  logoutOnServer: () => Promise<void>
  clearAuth: () => void
  clearCart: () => void
  clearDrafts: () => void
}) {
  let running: Promise<{ ok: boolean; needsServerRetry: boolean }> | null = null

  function logout() {
    if (running) return running

    running = (async () => {
      let needsServerRetry = false
      try {
        await deps.logoutOnServer()
      } catch {
        needsServerRetry = true
      } finally {
        deps.clearCart()
        deps.clearDrafts()
        deps.clearAuth()
      }
      return { ok: !needsServerRetry, needsServerRetry }
    })()

    return running
  }

  return { logout }
}

依赖方向是 logoutFlow → auth/cart/drafts,三个 store 不反向依赖协调器。两个并发调用共享同一个 Promise,清理只执行一次;服务端失败也不会恢复本地 session。若允许登录后再次登出,应在新会话建立时创建新的 flow 实例或重置 running。

06|带 TTL 的筛选恢复 ​

ts
type Filter = { category: string; pageSize: 10 | 20 | 50 }
type Envelope = { version: 2; savedAt: number; value: Filter }

const KEY = 'product-filter:v2'
const DAY = 86_400_000
const fallback = (): Filter => ({ category: 'all', pageSize: 20 })

function isPageSize(value: unknown): value is Filter['pageSize'] {
  return value === 10 || value === 20 || value === 50
}

export function readFilter(storage: Storage, now = Date.now()): Filter {
  try {
    const raw = storage.getItem(KEY)
    if (!raw) return fallback()
    const data = JSON.parse(raw) as Partial<Envelope>
    if (data.version !== 2 || typeof data.savedAt !== 'number' || !Number.isFinite(data.savedAt)) return fallback()
    if (data.savedAt > now || now - data.savedAt >= DAY) return fallback()
    if (typeof data.value?.category !== 'string') return fallback()
    if (!isPageSize(data.value.pageSize)) return fallback()
    return { category: data.value.category, pageSize: data.value.pageSize }
  } catch {
    return fallback()
  }
}

export function writeFilter(storage: Storage, value: Filter, now = Date.now()) {
  try {
    const data: Envelope = { version: 2, savedAt: now, value }
    storage.setItem(KEY, JSON.stringify(data))
    return true
  } catch {
    return false
  }
}

浏览器多标签页通过 storage 事件比较 savedAt,本地有未保存编辑时提示冲突;SSR 返回 fallback,客户端挂载后再读 storage。筛选条件可恢复,token、请求数据和错误堆栈不应进入该信封。

07|可取消并可失效的用户查询 ​

缓存工厂按用户会话创建一次;SSR每请求创建一次。fetchUser是可替换依赖,生产实现负责HTTP状态检查,测试提供受控Promise。版本号使invalidate后正在路上的旧响应也不能重新填回已失效缓存。

js
import { onScopeDispose, readonly, shallowRef, ref, watch } from 'vue'

export function createUserQueries(fetchUser, now = Date.now) {
  const cache = new Map()
  const versions = new Map()
  const version = id => versions.get(id) ?? 0
  function invalidateUser(id) {
    cache.delete(id)
    versions.set(id, version(id) + 1)
  }
  function useUser(id) {
    const data = shallowRef(null)
    const status = ref('idle')
    const error = ref('')
    let current = null
    let disposed = false
    async function reload() {
      if (disposed) return
      current?.abort()
      current = null
      const key = id.value.trim()
      error.value = ''
      if (!key) { data.value = null; status.value = 'idle'; return }
      const entry = cache.get(key)
      if (entry && now() - entry.fetchedAt < 10_000) {
        data.value = entry.data
        status.value = 'success'
        return
      }
      const controller = new AbortController()
      current = controller
      const ownVersion = version(key)
      status.value = 'loading'
      try {
        const result = await fetchUser(key, controller.signal)
        if (disposed || current !== controller || controller.signal.aborted) return
        if (ownVersion !== version(key)) { status.value = 'idle'; return }
        cache.set(key, { data: result, fetchedAt: now() })
        data.value = result
        status.value = 'success'
      } catch (cause) {
        if (disposed || current !== controller || controller.signal.aborted) return
        if (ownVersion !== version(key)) { status.value = 'idle'; return }
        status.value = 'error'
        error.value = cause instanceof Error ? cause.message : String(cause)
      }
    }
    const stop = watch(id, reload, { immediate: true })
    onScopeDispose(() => {
      disposed = true
      current?.abort()
      current = null
      stop()
    })
    return { data: readonly(data), status: readonly(status), error: readonly(error), reload }
  }
  return { useUser, invalidateUser }
}

export async function fetchUser(id, signal) {
  const response = await fetch(`/api/users/${encodeURIComponent(id)}`, { signal })
  if (response.status === 404) throw new Error('用户不存在')
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return response.json()
}
js
import assert from 'node:assert/strict'
import { effectScope, ref, nextTick } from 'vue'
import { createUserQueries } from './user-query.mjs'
const requests = []
let time = 1000
const queries = createUserQueries((id, signal) => new Promise((resolve, reject) => {
  requests.push({ id, signal, resolve, reject })
}), () => time)
const id = ref('a')
const scope = effectScope()
const user = scope.run(() => queries.useUser(id))
id.value = 'b'
await nextTick()
assert.equal(requests[0].signal.aborted, true)
requests[1].resolve({ id: 'b' })
await Promise.resolve()
requests[0].resolve({ id: 'a' })
await Promise.resolve()
assert.equal(user.data.value.id, 'b')
await user.reload()
assert.equal(requests.length, 2) // 同key新鲜命中
queries.invalidateUser('b')
const retry = user.reload()
assert.equal(requests.length, 3)
requests[2].reject(new Error('HTTP 500'))
await retry
assert.equal(user.data.value.id, 'b') // 保留旧值
assert.equal(user.error.value, 'HTTP 500')
const invalidated = user.reload()
queries.invalidateUser('b')
requests[3].resolve({ id: 'b', name: 'invalidated-result' })
await invalidated
assert.equal(user.data.value.id, 'b')
assert.notEqual(user.data.value.name, 'invalidated-result')
const fresh = user.reload()
assert.equal(requests.length, 5) // 被失效的结果没有重新填缓存
requests[4].resolve({ id: 'b', name: 'fresh' })
await fresh
time = 10_999
await user.reload()
assert.equal(requests.length, 5)
time = 11_000
const expired = user.reload()
assert.equal(requests.length, 6) // 刚好10秒,不再命中
requests[5].resolve({ id: 'b', name: 'newest' })
await expired
id.value = ''
await nextTick()
assert.equal(user.data.value, null)
assert.equal(user.status.value, 'idle')
assert.equal(requests.length, 6)
id.value = 'd'
await nextTick()
queries.invalidateUser('d')
requests[6].reject(new Error('obsolete error'))
await Promise.resolve()
assert.equal(user.error.value, '')
assert.equal(user.status.value, 'idle')
id.value = 'c'
await nextTick()
scope.stop()
assert.equal(requests[7].signal.aborted, true)
requests[7].resolve({ id: 'late' })
await Promise.resolve()
assert.equal(user.data.value, null)

空id会先取消再清空当前结果,不请求;404错误来自适配器,而500保留旧data供错误提示旁展示。若当前id已变,UI应标注旧数据身份,不能把旧用户当成新用户。缓存共享不等于请求去重:本实现不合并两个观察者同时发起的网络请求,复杂去重/后台刷新应交给专门的数据层。

08|服务端兜底的删除流程 ​

ts
type DeleteResult =
  | { ok: true }
  | { ok: false; kind: 'anonymous' | 'forbidden' | 'retryable' }

async function deleteDocument(id: string): Promise<DeleteResult> {
  const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, {
    method: 'DELETE',
    credentials: 'include',
  })

  if (response.ok) return { ok: true }
  if (response.status === 401) {
    cancelUserRequests()
    clearUserCaches()
    auth.clearSession()
    return { ok: false, kind: 'anonymous' }
  }
  if (response.status === 403) {
    await auth.restore().catch(() => undefined)
    return { ok: false, kind: 'forbidden' }
  }
  return { ok: false, kind: 'retryable' }
}

按钮显示条件是 auth.can('document:delete'),但函数本身不跳过服务端请求。权限被撤销后,下一次点击得到 403 并刷新权限;后台也可定期/事件驱动恢复 session。日志只写状态码和 request id,不写 cookie、token 或原始响应。

09|阻止旧搜索覆盖 ​

用递增 request id 可以让测试不依赖真实 AbortController:

ts
let latestRequest = 0

async function search(term: string) {
  const requestId = ++latestRequest
  status.value = 'loading'

  try {
    const result = await api.search(term)
    if (requestId !== latestRequest) return false
    items.value = result
    status.value = 'success'
    return true
  } catch (error) {
    if (requestId !== latestRequest) return false
    status.value = 'error'
    throw error
  }
}

测试顺序:保存 vue 和 pinia 两个 deferred;依次调用两次 search;先 resolve pinia,断言结果为 pinia;再 resolve/reject vue,断言 items 与 status 都不变。$onAction 只记录 action 名、request id 和耗时,$subscribe 只记录 mutation type;测试结束调用两个 stop 函数。

最终对照 ​

  • 每份 state 是否只有一个 owner,派生值是否未重复存储?
  • 失败是否在写入前被挡住,或有明确回滚/补偿?
  • 缓存 key、TTL、主动失效和用户范围是否完整?
  • 订阅、watch、事件和请求是否都有停止或取消责任?
  • 服务端是否仍是远程事实与授权的最终兜底?

用心记录每一步成长