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 适用于需求边界较小、团队愿意维护其并发和错误语义的场景。