Vue 3 useMutate:封装可控的命令式异步请求

Vue 3 useMutate:封装可控的命令式异步请求

useMutate 适合封装“由用户动作触发一次异步请求”的场景,例如提交表单、重新获取详情或执行预览。一个可靠的版本至少要回答四个问题:数据的空值是什么、错误如何交给界面、并发时谁可以更新状态、组件卸载后是否还写状态。

实现

import {
  onScopeDispose,
  ref,
  shallowRef,
  type Ref,
  type ShallowRef
} from 'vue'

interface UseMutateOptions<TData> {
  initialData?: TData
}

interface UseMutateResult<TData, TArgs extends unknown[]> {
  data: ShallowRef<TData | undefined>
  error: ShallowRef<unknown>
  loading: Ref<boolean>
  mutate: (...args: TArgs) => Promise<TData>
  reset: () => void
}

export function useMutate<TData, TArgs extends unknown[]>(
  requester: (...args: TArgs) => Promise<TData>,
  options: UseMutateOptions<TData> = {}
): UseMutateResult<TData, TArgs> {
  const data = shallowRef<TData | undefined>(options.initialData)
  const error = shallowRef<unknown>()
  const loading = ref(false)

  let latestRequestId = 0
  let disposed = false

  async function mutate(...args: TArgs): Promise<TData> {
    const requestId = ++latestRequestId
    loading.value = true
    error.value = undefined

    try {
      const result = await requester(...args)

      if (!disposed && requestId === latestRequestId) {
        data.value = result
      }

      return result
    } catch (cause) {
      if (!disposed && requestId === latestRequestId) {
        error.value = cause
      }

      // 保留 rejected Promise,让调用方能决定 toast、重试或表单错误。
      throw cause
    } finally {
      if (!disposed && requestId === latestRequestId) {
        loading.value = false
      }
    }
  }

  function reset() {
    latestRequestId += 1
    data.value = options.initialData
    error.value = undefined
    loading.value = false
  }

  onScopeDispose(() => {
    disposed = true
    latestRequestId += 1
  })

  return {
    data,
    error,
    loading,
    mutate,
    reset
  }
}

使用示例

<script setup lang="ts">
import { useMutate } from '@/hooks/useMutate'
import { getUserDetail } from '@/request/user'

const {
  data: user,
  error,
  loading,
  mutate: loadUser
} = useMutate(getUserDetail)

async function handleSearch(id: string) {
  try {
    await loadUser(id)
  } catch {
    // 页面在这里决定如何提示用户。
  }
}
</script>

<template>
  <button :disabled="loading" @click="handleSearch('42')">
    {{ loading ? '加载中…' : '查询用户' }}
  </button>

  <p v-if="error">请求失败,请稍后重试。</p>
  <pre v-else-if="user">{{ user }}</pre>
</template>

为什么不在失败时写入空对象

{} as T 掩盖错误,会让类型系统失去意义:调用方以为数据完整,运行时却可能访问不存在的字段。更清楚的状态是:

  • data === undefined:还没有成功数据,或被重置。
  • error !== undefined:最近一次有效请求失败。
  • loading === true:最近一次请求仍在进行。
  • data 有值且 error 为空:存在可展示的成功结果。

是否在重新请求时清空旧数据是产品策略。上面的实现默认保留旧数据,避免界面闪烁;如果页面要求骨架屏,可以在 mutate 开始时显式清空。

并发语义

示例采用“最后一次调用更新状态”。假设先查询 A,再查询 B,但 A 更晚返回,A 的 Promise 仍会正常完成,Hook 却不会让它覆盖 B。

这并不等于取消请求。搜索联想、文件上传等高频或高成本场景,应让 requester 接受取消信号,并在新请求开始时主动中止旧请求。

什么时候不要自己写

如果页面需要缓存、请求去重、失焦重试、分页缓存、乐观更新或服务端状态同步,成熟的数据请求库通常更合适。自定义 Hook 适用于需求边界较小、团队愿意维护其并发和错误语义的场景。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。