Vue 基础体系 · 第 12/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复
在 Vue 应用中,“请求数据”不是一个单一动作,而是一组需要同时处理的问题:
- 请求何时开始,何时结束;
- 组件卸载或参数变化后,旧请求是否仍然有效;
- 多个请求同时完成时,哪个结果可以写入界面;
- 已加载数据是否可以复用,复用多久;
- 分页参数如何与查询条件保持一致;
- 网络错误、HTTP 错误和用户取消如何区别处理;
- 重试时如何避免重复提交、请求风暴和错误覆盖。
如果只写出:
const data = ref([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
data.value = await fetch('/api/users').then(res => res.json())
loading.value = false
})
它只能覆盖最顺利的路径。真实应用还必须处理请求失败、组件卸载、搜索词快速变化、分页切换、缓存失效和旧响应覆盖新响应等情况。
一、先建立请求状态模型
1. 请求状态不等于 loading
loading 只能表达“某个请求可能正在进行”,但无法表达:
- 页面是否从未请求过;
- 是否已经有旧数据、当前正在刷新;
- 是否请求失败;
- 是否因为用户取消而结束;
- 是否命中了缓存。
更准确的状态模型至少应包含状态和数据两个维度:
type RequestStatus = 'idle' | 'loading' | 'refreshing' | 'success' | 'error'
interface RequestError {
kind: 'network' | 'http' | 'parse' | 'unknown'
message: string
status?: number
}
状态转换可以表示为:
stateDiagram-v2
[*] --> idle
idle --> loading: 首次请求
loading --> success: 响应成功
loading --> error: 响应失败
success --> refreshing: 参数变化或手动刷新
refreshing --> success: 新响应成功
refreshing --> error: 新响应失败
error --> loading: 重试且没有旧数据
error --> refreshing: 重试且保留旧数据
success --> idle: 清空或重置
这里的关键区别是:
loading:没有可展示的数据,正在首次加载;refreshing:已经有旧数据,但正在获取更新结果;success:当前数据对应当前请求条件;error:当前请求失败,但旧数据可能仍然存在。
保留旧数据时,界面可以继续显示列表,同时展示“正在更新”或“更新失败,点击重试”,而不是把整个页面替换成空白。
2. 请求结果与请求条件必须绑定
一次请求的结果不能只看“它是否成功”,还必须看它对应的参数是否仍然有效。
例如:
- 用户输入
vue,发出请求R1; - 用户很快改成
vue3,发出请求R2; R2先返回;R1后返回。
如果代码按完成顺序直接写入 data,最后界面可能显示 vue 的结果,而当前输入框已经是 vue3。
因此,异步数据应被理解为:
数据 = 请求参数对应的响应
而不是:
数据 = 最近一次完成的响应
二、取消请求:停止无效工作,但不能单独解决竞态
1. AbortController 的作用
浏览器原生 fetch 可以接收 AbortSignal:
const controller = new AbortController()
fetch('/api/users', {
signal: controller.signal,
})
controller.abort()
调用 abort() 后,尚未完成的 fetch 通常会以 AbortError 拒绝。这里的“通常”很重要:取消是对请求过程的控制信号,不应被当作服务器已经撤回了请求。请求是否已经到达服务器、服务器是否已经执行,都不能仅由前端保证。
取消适合解决这些问题:
- 组件卸载后停止等待无用响应;
- 查询参数变化后取消旧请求;
- 用户主动点击取消;
- 避免多个搜索请求同时占用浏览器和服务器资源。
2. 取消不等于防止旧结果写入
仅仅取消旧请求仍然不够。原因包括:
- 旧请求可能已经完成,只是响应处理尚未结束;
- 某些非
fetch客户端或自定义适配器不一定完全遵守取消信号; - 服务器端处理可能已经开始;
- 取消和响应完成之间存在竞态。
因此需要两个机制:
- 取消控制器:尽量停止旧请求;
- 请求代次或请求 ID:即使旧请求完成,也禁止它提交结果。
设请求按开始顺序有代次:
R1 的 token = 1
R2 的 token = 2
提交结果时只允许:
token === 当前 token
这就是“最后一次有效请求获胜”,而不是“最后完成的请求获胜”。
三、一个可运行的 Vue 3 请求组合式函数
下面的示例使用 Vue 3、Composition API、TypeScript 和浏览器原生 fetch。它包含:
- 请求取消;
- 请求竞态保护;
- 基本缓存;
- 首次加载与刷新状态区分;
- HTTP 错误处理;
- 组件卸载清理;
- 分页参数;
- 手动重试。
假设接口为:
GET /api/users?q=vue&page=1&pageSize=20
响应格式为:
{
"items": [
{ "id": 1, "name": "Ada" }
],
"page": 1,
"pageSize": 20,
"total": 42
}
1. 类型和错误对象
// useUsers.ts
import {
computed,
onUnmounted,
ref,
shallowRef,
watch,
} from 'vue'
export interface User {
id: number
name: string
}
export interface UserPage {
items: User[]
page: number
pageSize: number
total: number
}
type RequestStatus =
| 'idle'
| 'loading'
| 'refreshing'
| 'success'
| 'error'
interface RequestError {
kind: 'network' | 'http' | 'parse' | 'unknown'
message: string
status?: number
}
class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'HttpError'
}
}
fetch 只有在网络层失败时才会 reject。HTTP 404、500 等响应仍然是一个成功完成的 fetch,所以必须显式检查 response.ok。
2. 缓存结构
interface CacheEntry<T> {
value: T
expiresAt: number
}
缓存键必须包含所有影响响应的参数:
function createCacheKey(
query: string,
page: number,
pageSize: number,
): string {
return JSON.stringify({
query: query.trim(),
page,
pageSize,
})
}
如果只使用 query 作为键,那么 query=vue&page=1 和 query=vue&page=2 会错误地共享数据。
3. 组合式函数主体
const CACHE_TTL = 30_000
export function useUsers() {
const query = ref('')
const page = ref(1)
const pageSize = ref(20)
const data = shallowRef<UserPage | null>(null)
const status = ref<RequestStatus>('idle')
const error = ref<RequestError | null>(null)
// 这个缓存属于当前 composable 实例。
// 不放在模块顶层,避免 SSR 服务端请求之间共享用户数据。
const cache = new Map<string, CacheEntry<UserPage>>()
let controller: AbortController | null = null
let requestGeneration = 0
const hasData = computed(() => data.value !== null)
const totalPages = computed(() => {
if (!data.value) return 0
return Math.ceil(data.value.total / data.value.pageSize)
})
function isCurrent(generation: number): boolean {
return generation === requestGeneration
}
function toRequestError(reason: unknown): RequestError {
if (reason instanceof HttpError) {
return {
kind: 'http',
status: reason.status,
message: reason.message,
}
}
if (reason instanceof SyntaxError) {
return {
kind: 'parse',
message: '服务器返回的数据格式无效',
}
}
if (reason instanceof TypeError) {
return {
kind: 'network',
message: '网络不可用或请求无法发送',
}
}
if (reason instanceof Error) {
return {
kind: 'unknown',
message: reason.message,
}
}
return {
kind: 'unknown',
message: '发生未知错误',
}
}
async function requestPage(
queryValue: string,
pageValue: number,
pageSizeValue: number,
signal: AbortSignal,
): Promise<UserPage> {
const params = new URLSearchParams({
q: queryValue.trim(),
page: String(pageValue),
pageSize: String(pageSizeValue),
})
const response = await fetch(`/api/users?${params.toString()}`, {
method: 'GET',
signal,
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
let message = `请求失败:HTTP ${response.status}`
try {
const body = await response.json() as { message?: string }
if (body.message) {
message = body.message
}
} catch {
// 错误响应不是 JSON 时使用默认消息。
}
throw new HttpError(response.status, message)
}
return await response.json() as UserPage
}
async function load(options: { force?: boolean } = {}) {
const force = options.force ?? false
// 先递增代次,再取消旧请求。
// 这样旧请求即使在 abort 前后完成,也无法通过 isCurrent 检查。
const generation = ++requestGeneration
controller?.abort()
const nextController = new AbortController()
controller = nextController
const queryValue = query.value.trim()
const pageValue = page.value
const pageSizeValue = pageSize.value
const key = createCacheKey(queryValue, pageValue, pageSizeValue)
const cached = cache.get(key)
const now = Date.now()
if (!force && cached && cached.expiresAt > now) {
data.value = cached.value
error.value = null
status.value = 'success'
controller = null
return
}
status.value = hasData.value ? 'refreshing' : 'loading'
error.value = null
try {
const result = await requestPage(
queryValue,
pageValue,
pageSizeValue,
nextController.signal,
)
// 旧请求不能提交数据,也不能覆盖当前状态。
if (!isCurrent(generation)) return
data.value = result
cache.set(key, {
value: result,
expiresAt: Date.now() + CACHE_TTL,
})
error.value = null
status.value = 'success'
} catch (reason) {
// 被新请求取代的旧请求,静默丢弃。
if (!isCurrent(generation)) return
// AbortError 表示请求被取消,不应显示成用户错误。
if (
reason instanceof DOMException &&
reason.name === 'AbortError'
) {
return
}
error.value = toRequestError(reason)
status.value = 'error'
} finally {
if (isCurrent(generation)) {
controller = null
}
}
}
function retry() {
return load({ force: true })
}
function setQuery(value: string) {
query.value = value
page.value = 1
}
function setPage(value: number) {
const nextPage = Math.max(1, Math.min(value, totalPages.value || 1))
page.value = nextPage
}
const stop = watch(
[query, page, pageSize],
() => {
void load()
},
{
immediate: true,
},
)
onUnmounted(() => {
stop()
// 递增代次使所有尚未完成的请求失效。
requestGeneration++
controller?.abort()
controller = null
})
return {
query,
page,
pageSize,
data,
status,
error,
hasData,
totalPages,
setQuery,
setPage,
load,
retry,
}
}
4. 组件使用方式
<script setup lang="ts">
import { useUsers } from './useUsers'
const {
query,
data,
status,
error,
page,
totalPages,
setQuery,
setPage,
retry,
} = useUsers()
</script>
<template>
<section>
<input
:value="query"
type="search"
placeholder="搜索用户"
@input="setQuery(($event.target as HTMLInputElement).value)"
/>
<p v-if="status === 'loading'">首次加载中……</p>
<p v-else-if="status === 'refreshing'">更新中……</p>
<p v-if="error" role="alert">
{{ error.message }}
<button type="button" @click="retry">重试</button>
</p>
<ul v-if="data">
<li v-for="user in data.items" :key="user.id">
{{ user.name }}
</li>
</ul>
<p v-else-if="status === 'success'">没有数据</p>
<nav v-if="totalPages > 1" aria-label="分页">
<button
type="button"
:disabled="page <= 1 || status === 'loading'"
@click="setPage(page - 1)"
>
上一页
</button>
<span>第 {{ page }} / {{ totalPages }} 页</span>
<button
type="button"
:disabled="page >= totalPages || status === 'loading'"
@click="setPage(page + 1)"
>
下一页
</button>
</nav>
</section>
</template>
这里通过 void load() 明确表示 watcher 不等待异步函数。请求函数内部已经负责捕获异常,因此不会产生未处理的 Promise rejection。
四、竞态:为什么请求 ID 是必要条件
1. 竞态的形式化描述
设:
R_i表示第i次请求;p_i表示它使用的参数;t_i表示它开始时的代次;C_i表示它完成的时间;D_i表示响应数据。
正确的提交条件是:
提交 D_i,当且仅当 t_i = 当前代次
如果当前代次为 3,代次为 1 的请求即使成功返回,也不能修改当前界面。
另一种常见但错误的条件是:
谁最后完成,谁覆盖界面
这等价于把网络调度顺序当成了用户意图顺序。网络延迟、服务器负载和缓存命中都会改变完成顺序,因此这个条件不可靠。
2. 反例:只取消、不检查代次
let controller: AbortController | null = null
async function load(url: string) {
controller?.abort()
controller = new AbortController()
const response = await fetch(url, {
signal: controller.signal,
})
data.value = await response.json()
}
这个实现有两个问题:
fetch可能已经完成,abort()无法撤销后续的json()或业务处理;- 不同请求之间没有身份校验,旧响应仍可能写入
data。
取消减少无效工作,代次校验保证状态正确性。两者解决的是不同问题。
3. 不要把“请求中”作为防竞态手段
下面这种写法也不可靠:
if (loading.value) return
它会阻止新请求发出,但用户的查询条件可能已经改变。此时界面既没有旧查询的正确结果,也没有新查询的结果。
正确做法不是禁止所有并发,而是:
- 允许新请求取代旧请求;
- 尽量取消旧请求;
- 禁止旧请求提交结果。
五、输入搜索的防抖与请求取消
搜索框通常不应当为每个字符立即发送请求。例如输入 vue 可能触发三次请求:
v
vu
vue
取消可以降低无效请求的影响,但不能减少请求已经发出的次数。防抖解决的是“何时开始请求”,取消解决的是“请求已经开始后如何停止”。
可以将 watcher 改成防抖版本:
let timer: ReturnType<typeof setTimeout> | undefined
const stop = watch(
[query, page, pageSize],
() => {
if (timer !== undefined) {
clearTimeout(timer)
}
timer = setTimeout(() => {
void load()
}, 300)
},
{ immediate: true },
)
onUnmounted(() => {
if (timer !== undefined) {
clearTimeout(timer)
}
})
但防抖不能代替竞态保护。用户可能在 300 毫秒之后继续输入,前一个请求仍然可能与后一个请求并行完成,因此仍需要 AbortController 和请求代次。
生产取舍通常是:
- 查询框:防抖 + 取消 + 请求代次;
- 分页按钮:立即请求 + 取消 + 请求代次;
- 用户明确点击“搜索”:不防抖,只在提交时发请求。
六、缓存:复用响应,不是永久保存真相
1. 应用缓存与 HTTP 缓存不同
应用层缓存是 JavaScript 主动保存并复用响应,例如:
Map<string, CacheEntry<UserPage>>
HTTP 缓存则由浏览器根据响应头和 HTTP 缓存规则处理,例如:
Cache-Control: max-age=30
ETag: "users-v5"
两者可以同时存在,但职责不同:
- HTTP 缓存能减少网络传输,并由浏览器处理验证;
- 应用缓存能直接决定 Vue 状态是否复用、是否展示旧数据、是否进行后台刷新。
不能因为浏览器可能缓存了响应,就认为业务状态一定是最新的。
2. TTL 缓存的条件
上面的缓存判断是:
当前时间 < expiresAt
其中:
expiresAt是缓存过期时间;TTL是缓存有效期;expiresAt = 写入时间 + TTL。
例如写入时间为 10:00:00,TTL 为 30 秒,则:
10:00:20命中缓存;10:00:31视为过期,需要重新请求。
缓存键必须覆盖所有响应相关输入。对于分页列表,至少包括:
查询词 + 页码 + 每页数量 + 排序方式 + 筛选条件
遗漏任意一个参数都可能导致错误复用。
3. stale-while-revalidate
一种常见策略是:
- 先立即显示过期缓存;
- 同时发起后台刷新;
- 新数据成功后替换旧数据;
- 刷新失败时保留旧数据并提示。
它的核心是把“可见性”和“新鲜度”分开:
有可用数据 ≠ 数据已经最新
示意实现:
async function loadWithStaleCache(key: string) {
const cached = cache.get(key)
if (cached) {
data.value = cached.value
status.value = 'refreshing'
} else {
status.value = 'loading'
}
try {
const fresh = await fetchFreshData()
data.value = fresh
status.value = 'success'
} catch (reason) {
if (cached) {
status.value = 'error'
error.value = {
kind: 'network',
message: '刷新失败,当前显示的是旧数据',
}
} else {
status.value = 'error'
}
}
}
这里的错误状态仍然可以和旧数据同时存在。UI 不应使用:
<div v-if="error">错误页面</div>
<div v-else>数据页面</div>
因为这样会在刷新失败时隐藏仍然有价值的旧数据。更准确的是分别渲染:
<div v-if="data">
<!-- 旧数据或新数据 -->
</div>
<p v-if="status === 'refreshing'">正在刷新</p>
<p v-if="error">刷新失败:{{ error.message }}</p>
4. 缓存的生产边界
缓存会产生几个真实风险:
- 用户权限变化后仍显示旧数据;
- 服务端数据已删除,客户端继续显示;
- 缓存过大导致内存增长;
- SSR 中将一个用户的数据泄漏给另一个请求;
- 多个组件各自缓存,彼此无法失效。
因此缓存通常还需要:
- TTL;
- 最大条目数或淘汰策略;
- 业务事件触发的失效;
- 用户身份、租户和权限维度;
- SSR 请求级隔离;
- 明确的强制刷新入口。
对于敏感数据,不能只依赖前端缓存控制访问权限。服务端每次请求仍必须验证身份和授权。
七、分页:页码只是数据请求的一部分
1. 偏移分页
偏移分页通常使用:
page=2&pageSize=20
服务端根据偏移量:
offset = (page - 1) × pageSize
计算查询起点。
它适合数据变化不频繁、需要直接跳转页码的列表。响应一般包含:
{
"items": [],
"page": 2,
"pageSize": 20,
"total": 125
}
总页数计算为:
totalPages = ceil(total / pageSize)
例如:
total = 125
pageSize = 20
totalPages = ceil(125 / 20) = 7
第 7 页只有 5 条数据,这是正常结果。
2. 偏移分页的稳定性问题
如果用户正在浏览第 2 页,其他用户在数据库头部插入了新记录,那么原来的第 2 页内容可能发生移动:
第一次请求:第 2 页包含 A、B、C
数据插入后:第 2 页包含 X、A、B
用户可能看到重复项或漏项。
对于持续增长、按时间流入的数据,游标分页通常更稳定:
GET /api/users?limit=20&cursor=eyJpZCI6...
响应:
{
"items": [],
"nextCursor": "eyJpZCI6...",
"hasNext": true
}
游标表示“从某个稳定位置继续”,而不是“跳过前 N 条”。它通常不适合任意跳转到第 50 页,但更适合无限滚动和实时变化列表。
3. 查询条件变化时必须重置页码
假设用户正在第 5 页,然后修改了搜索词。如果仍然请求新搜索词的第 5 页,可能出现:
- 新结果不足 5 页,得到空列表;
- 用户误以为没有匹配项;
- 旧页码和新查询条件不一致。
因此 setQuery 中将页码重置为 1:
function setQuery(value: string) {
query.value = value
page.value = 1
}
这两个响应式值会触发 watcher。由于 Vue 会在同一轮更新中批处理同步修改,通常会只产生一次有效的后续请求;即使某些情况下触发了多次,取消和代次校验仍能保证最终状态正确。
4. 分页边界不能只依赖前端
前端可以禁用按钮:
:disabled="page >= totalPages"
但服务端仍必须验证:
- 页码是否为正数;
- 每页数量是否超过上限;
- 查询条件是否合法;
- 用户是否有权访问该数据集。
前端限制改善交互,不能替代服务端校验。
八、错误恢复:先区分故障类型
1. fetch 的错误边界
请求失败至少有以下几类:
| 类型 | 典型原因 | 是否适合自动重试 |
|---|---|---|
AbortError |
用户取消、参数变化、组件卸载 | 否 |
| 网络错误 | 断网、DNS、连接失败 | 有条件地重试 |
| HTTP 4xx | 参数错误、未登录、无权限、限流 | 通常不直接重试 |
| HTTP 5xx | 服务端临时故障 | GET 可有限重试 |
| 解析错误 | 响应不是预期 JSON | 通常不重试,应报警 |
| 业务错误 | HTTP 200 但业务码失败 | 按业务协议处理 |
response.ok 为 false 时应进入 HTTP 错误分支。不能只写:
const data = await fetch(url).then(res => res.json())
因为 404 和 500 仍可能进入 json()。
2. 重试必须有限、延迟并区分请求语义
对于 GET 请求,有限次数重试通常相对安全:
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry<T>(
operation: () => Promise<T>,
options: {
retries: number
baseDelay: number
},
): Promise<T> {
let attempt = 0
while (true) {
try {
return await operation()
} catch (error) {
if (attempt >= options.retries) {
throw error
}
const exponential = options.baseDelay * 2 ** attempt
const jitter = Math.random() * 100
await sleep(exponential + jitter)
attempt++
}
}
}
当 baseDelay=200、重试次数为 3 时,等待时间大致为:
第 1 次重试:200ms + 抖动
第 2 次重试:400ms + 抖动
第 3 次重试:800ms + 抖动
指数退避避免多个客户端在服务恢复瞬间同时重试。实际实现还应考虑服务端的 Retry-After 响应头。
不应对所有请求无条件重试:
POST可能已经创建资源,再次执行会产生重复数据;DELETE可能改变状态;401需要重新认证或刷新令牌;403需要权限处理;400通常是请求本身错误;429应尊重服务端给出的等待时间。
3. 错误恢复不应清空旧数据
刷新时应保留旧数据:
status.value = data.value ? 'refreshing' : 'loading'
失败后:
status.value = 'error'
error.value = {
kind: 'network',
message: '刷新失败,仍显示上一次成功的数据',
}
这样用户仍可以:
- 阅读旧列表;
- 再次点击重试;
- 修改查询条件;
- 离开当前页面。
只有首次加载且没有任何数据时,错误才适合作为主要页面状态。
九、Vue 生命周期与异步请求的边界
1. onMounted 不是请求唯一入口
onMounted 适合首次加载 DOM 已挂载后的任务,但带参数的数据请求通常应由响应式依赖驱动:
watch([query, page], () => {
void load()
}, { immediate: true })
这样初始化、查询变化和分页变化都使用同一条请求路径,减少状态分支。
如果请求不依赖 DOM,也不需要为了 fetch 特意等待 onMounted。组合式函数在 setup() 中建立 watcher 即可。
2. 组件卸载后的写入
Vue 组件卸载后,响应式引用本身可能仍然存在于闭包中。即使写入它不会总是立刻报错,也没有意义,并可能造成:
- 无效状态更新;
- 测试中的异步任务泄漏;
- 日志中出现已离开页面的请求错误;
- 资源长期被闭包持有。
因此卸载时应同时:
- 停止 watcher 或定时器;
- 递增请求代次;
- 调用
abort(); - 在结果提交前再次验证请求是否仍有效。
十、组件错误处理与请求错误处理不是一回事
1. 请求错误属于业务状态
网络请求失败通常应在请求函数中捕获并转为状态:
try {
await load()
} catch (error) {
requestError.value = normalizeError(error)
}
这类错误需要展示“重试”“重新登录”“检查网络”等用户操作。
2. Vue 错误边界处理组件执行错误
Vue 提供了 errorCaptured 和应用级 app.config.errorHandler 来捕获组件树中的运行时错误,例如:
- render 函数抛错;
- 子组件生命周期钩子抛错;
- watcher 回调中的同步错误;
- 组件事件处理函数中的错误。
示例:
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
const renderError = ref<Error | null>(null)
onErrorCaptured((error) => {
renderError.value = error
return false
})
</script>
<template>
<div v-if="renderError">
组件渲染失败,请刷新页面。
</div>
<slot v-else />
</template>
return false 会停止该错误继续向上级 errorCaptured 传播。是否这样做取决于应用的错误边界设计。
但错误边界不是请求重试机制。一个在 try/catch 中已经处理的网络错误,不应再依赖组件错误边界来展示请求失败 UI。两者的职责分别是:
请求状态:预期的外部故障,转为可恢复的业务状态
错误边界:未预期的组件执行故障,防止整棵界面崩溃
应用级日志可以记录请求失败,但要避免记录:
AbortError;- 用户主动取消;
- 已被新请求取代的旧请求。
否则监控会把正常的请求切换误报成系统故障。
十一、Pinia 中如何放置异步数据状态
当数据只属于一个页面实例时,组合式函数通常足够。如果多个页面共享同一份数据、需要统一失效或需要跨组件触发刷新,可以放入 Pinia store。
一个 store 的状态可能是:
interface UsersState {
data: UserPage | null
status: RequestStatus
error: RequestError | null
query: string
page: number
}
Action 负责:
- 发起请求;
- 维护请求代次;
- 处理取消;
- 写入缓存或状态;
- 提供 retry 和 invalidate。
但应注意三个边界:
- SSR 隔离:store 必须按请求创建,不能让服务端不同用户共享可变状态;
- 持久化适用性:搜索结果、权限相关数据通常不应无条件持久化到
localStorage; - 取消控制器不是可持久化状态:
AbortController、Promise 和请求 ID 等运行时对象应放在 store 的非序列化模块变量中,而不是持久化 state。
组合式函数和 Pinia 的核心请求算法相同:取消、代次校验、缓存键和错误分类不会因为状态容器改变而消失。
十二、常见失败表现与诊断路径
1. 界面显示了旧搜索结果
检查:
- 是否每次参数变化都会发新请求;
- 是否使用请求 ID 或代次;
- 是否只调用了
abort()而没有提交校验; - 是否有其他地方直接写入同一个
data。
在开发环境中可以给每次请求打印:
console.debug('[users request]', {
generation,
query: queryValue,
page: pageValue,
})
响应提交时再打印:
console.debug('[users commit]', {
generation,
currentGeneration: requestGeneration,
})
如果 generation !== currentGeneration,该响应必须被丢弃。
2. HTTP 500 没有进入错误 UI
检查是否写了:
fetch(url).then(res => res.json())
而没有检查:
if (!response.ok) {
throw new HttpError(response.status, message)
}
fetch 对 HTTP 状态码本身不作失败判断。
3. 取消请求被显示成“网络错误”
检查错误分支是否先判断:
reason instanceof DOMException &&
reason.name === 'AbortError'
取消是控制流程,不一定是故障。被新请求取代的旧请求还应结合请求代次直接忽略。
4. 搜索新词后列表为空
检查是否在修改查询词时重置页码:
query.value = value
page.value = 1
同时检查缓存键是否包含查询词、页码、筛选和排序条件。
5. 刷新失败后页面变成空白
检查是否在开始刷新时执行了:
data.value = null
如果已有数据,刷新失败应保留它,并把错误作为附加状态展示。
6. SSR 用户之间出现数据串用
检查缓存或 store 是否定义在模块顶层:
// 有 SSR 风险:所有服务端请求可能共享它
const cache = new Map()
服务端模块通常会被多个请求复用。用户相关的缓存必须是请求级或用户级隔离的;不能把浏览器端单用户假设直接带到 SSR 环境。
十三、最终的数据流应满足这些不变量
一个可靠的异步数据模块,不是依赖某个单独 API,而是维护以下不变量:
-
当前界面只接受当前请求条件的结果
通过请求 ID、代次或等价机制保证。 -
取消不会被误报为业务故障
AbortError和被新请求取代的旧响应应被忽略。 -
缓存键完整描述响应输入
查询、分页、排序、筛选和权限维度不能遗漏。 -
已有数据和刷新错误可以同时存在
“刷新失败”不等于“没有数据”。 -
HTTP 错误必须显式识别
fetch成功返回不代表业务请求成功。 -
重试必须有语义边界
GET 等幂等请求可以有限重试,非幂等操作需要幂等键或服务端协议支持。 -
组件销毁后不再提交结果
卸载清理与提交前校验缺一不可。
当这些条件成立时,取消、竞态、缓存、分页和错误恢复就不再是互相独立的补丁,而是同一个请求状态系统中的不同部分:请求由参数产生,参数决定缓存键,请求代次决定结果能否提交,状态决定界面如何保留数据和恢复错误。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Pinia 状态管理:Store、Getter、Action、持久化和 SSR 边界
- 下一篇:Vue 与 TypeScript:组件类型、泛型、模板检查和 API 契约
- 延伸:Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论