05|第五章练习答案
第五章概览 / 参考实现。先比较题目要求的可观察行为,再比较代码结构;答案不是唯一写法。
01|搜索页的状态 owner
合理划分如下:inputText/menuOpen 属于搜索组件;已提交的 keyword/sort 属于 URL query;products/requestError 属于以 query 为 key 的远程请求条目。新增“比较清单”需要跨路由存活,才进入独立 store。
<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
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'] 始终是两倍。
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
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|幂等的退出流程
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 的筛选恢复
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后正在路上的旧响应也不能重新填回已失效缓存。
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()
}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|服务端兜底的删除流程
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:
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、事件和请求是否都有停止或取消责任?
- 服务端是否仍是远程事实与授权的最终兜底?