异步 Action、竞态与服务端状态边界
异步 action 的难点不是 await,而是“这次响应是否仍然属于当前输入”。搜索框快速输入 v、vu、vue 时,网络返回顺序可以是 vue、v、vu;如果每个响应都直接写入 store,旧结果会覆盖新结果。
用请求身份保护提交
ts
import { reactive } from 'vue'
type SearchState = { status: 'idle' | 'pending' | 'success' | 'empty' | 'error'; items: string[]; error: Error | null }
const state = reactive<SearchState>({ status: 'idle', items: [], error: null })
const fetchResults = (keyword: string) => fetch(`/api/search?q=${encodeURIComponent(keyword)}`).then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<string[]>
})
let requestVersion = 0
async function search(keyword: string) {
const version = ++requestVersion
state.status = 'pending'
try {
const result = await fetchResults(keyword)
if (version !== requestVersion) return { ignored: true }
state.items = result
state.status = result.length ? 'success' : 'empty'
return { ignored: false }
} catch (error) {
if (version !== requestVersion) return { ignored: true }
state.status = 'error'
state.error = error instanceof Error ? error : new Error('unknown error')
throw error
}
}版本号只负责“最后一次提交”,不能停止已经发出的请求;搜索、详情切换等场景还应配合 AbortController 释放不再需要的网络和解析工作。取消属于控制流,不应在界面上误报成服务器失败。
不要把缓存问题伪装成 Pinia 问题
Pinia 很适合保存当前用户、编辑草稿和跨页面 UI 状态;带 query key、过期时间、失效、重试、后台重取的服务器状态应交给 TanStack Query 等专门工具。若仍把列表缓存放进 Pinia,至少要显式保存 fetchedAt、请求参数和失效规则,禁止用“刷新页面就好了”作为一致性策略。
练习
实现一个可控 Promise 的搜索测试:先发出 old 再发出 new,让 new 先 resolve,随后让 old resolve;最终列表必须仍是 new。再加入取消路径,说明取消后 loading 如何结束。
下一步
完成练习后打开本章答案,再回到Pinia 课程总览。