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. 请求结果与请求条件必须绑定

一次请求的结果不能只看“它是否成功”,还必须看它对应的参数是否仍然有效。

例如:

  1. 用户输入 vue,发出请求 R1
  2. 用户很快改成 vue3,发出请求 R2
  3. R2 先返回;
  4. 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 客户端或自定义适配器不一定完全遵守取消信号;
  • 服务器端处理可能已经开始;
  • 取消和响应完成之间存在竞态。

因此需要两个机制:

  1. 取消控制器:尽量停止旧请求;
  2. 请求代次或请求 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 404500 等响应仍然是一个成功完成的 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=1query=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()
}

这个实现有两个问题:

  1. fetch 可能已经完成,abort() 无法撤销后续的 json() 或业务处理;
  2. 不同请求之间没有身份校验,旧响应仍可能写入 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

一种常见策略是:

  1. 先立即显示过期缓存;
  2. 同时发起后台刷新;
  3. 新数据成功后替换旧数据;
  4. 刷新失败时保留旧数据并提示。

它的核心是把“可见性”和“新鲜度”分开:

有可用数据 ≠ 数据已经最新

示意实现:

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.okfalse 时应进入 HTTP 错误分支。不能只写:

const data = await fetch(url).then(res => res.json())

因为 404500 仍可能进入 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 组件卸载后,响应式引用本身可能仍然存在于闭包中。即使写入它不会总是立刻报错,也没有意义,并可能造成:

  • 无效状态更新;
  • 测试中的异步任务泄漏;
  • 日志中出现已离开页面的请求错误;
  • 资源长期被闭包持有。

因此卸载时应同时:

  1. 停止 watcher 或定时器;
  2. 递增请求代次;
  3. 调用 abort()
  4. 在结果提交前再次验证请求是否仍有效。

十、组件错误处理与请求错误处理不是一回事

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。

但应注意三个边界:

  1. SSR 隔离:store 必须按请求创建,不能让服务端不同用户共享可变状态;
  2. 持久化适用性:搜索结果、权限相关数据通常不应无条件持久化到 localStorage
  3. 取消控制器不是可持久化状态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,而是维护以下不变量:

  1. 当前界面只接受当前请求条件的结果
    通过请求 ID、代次或等价机制保证。

  2. 取消不会被误报为业务故障
    AbortError 和被新请求取代的旧响应应被忽略。

  3. 缓存键完整描述响应输入
    查询、分页、排序、筛选和权限维度不能遗漏。

  4. 已有数据和刷新错误可以同时存在
    “刷新失败”不等于“没有数据”。

  5. HTTP 错误必须显式识别
    fetch 成功返回不代表业务请求成功。

  6. 重试必须有语义边界
    GET 等幂等请求可以有限重试,非幂等操作需要幂等键或服务端协议支持。

  7. 组件销毁后不再提交结果
    卸载清理与提交前校验缺一不可。

当这些条件成立时,取消、竞态、缓存、分页和错误恢复就不再是互相独立的补丁,而是同一个请求状态系统中的不同部分:请求由参数产生,参数决定缓存键,请求代次决定结果能否提交,状态决定界面如何保留数据和恢复错误。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。