浅谈我为什么不直接使用 VueUse,而选择造轮子
原文记录了一名 React 开发者使用 Vue 3 时,从 ahooks 的
useRequest联想到自己实现请求 Hooks 的过程。本文保留这份技术取舍和vue-hooks-plus项目背景,但更新 VueUse 当前能力、Nuxt 4 的useFetch差异、请求取消和“造轮子”的维护成本。
一、先说结论:这不是 VueUse 好不好
VueUse 和请求管理库解决的问题范围不同:
- VueUse 是 Vue 生态的通用组合式工具集合,包含响应式、浏览器 API、事件、状态、异步和虚拟列表等工具;
@vueuse/core的useFetch主要围绕原生 Fetch API;@vueuse/integrations的useAxios是对 Axios 的响应式封装;- ahooks 的
useRequest、vue-hooks-plus的useRequest更接近完整请求状态机和请求策略层。
如果业务只需要一次请求、loading、error、手动执行和取消,VueUse 已经可能够用;如果业务需要轮询、重试、缓存、SWR、聚合、插件和统一竞态控制,选择请求管理库或在项目内封装一层会更合适。
“我为什么不使用 VueUse”更准确地应写成:**当前项目需要的抽象和 VueUse 某个工具的边界不同,所以我选择组合或封装一个更贴近业务的 request 层。**这不是说 VueUse 不能扩展,也不是说自造轮子一定更好。
二、React 开发者迁移到 Vue 3
Vue 3 的 Composition API 和 React Hooks 都支持把状态逻辑抽离到函数中,但生命周期和响应式模型不同:
- Vue composable 通常使用
ref、reactive、computed、watch和生命周期钩子; - React Hook 依靠重新执行组件函数、依赖数组和 React 调度;
- Vue composable 在每次组件调用时通常拥有独立状态;
- 异步请求都需要处理竞态、卸载、错误、重复执行和缓存,不应只把 Promise 放进一个 ref。
Vue 官方把 use... 函数称为 composable。一个好的 composable 应该聚焦于一类可复用的有状态逻辑,同时明确浏览器 API、SSR 和清理边界。
三、VueUse 当前的请求工具
1. useFetch
VueUse 当前的 useFetch 基于原生 Fetch API,支持的能力包括:
- 响应式 URL;
immediate: false后手动execute;- URL/payload 变化后的可选
refetch; abort、canAbort和 timeout;beforeFetch、afterFetch、onFetchError等拦截钩子;get/post/put/delete等方法链和json/text/blob解析;createFetch创建带 base URL 或默认鉴权的实例。
import { ref } from 'vue'
import { useFetch } from '@vueuse/core'
const userId = ref('1')
const { data, error, isFetching, execute, abort } = useFetch(
() => `/api/users/${userId.value}`,
{ immediate: false, refetch: true },
).get().json()
await execute()
2. Nuxt 4 的同名 useFetch
Nuxt 4 自带一个 useFetch,它面向 SSR/异步数据获取,具有 Nuxt 的服务端预取、payload 转移和数据缓存语义。VueUse 文档也特别提醒:在 Nuxt 中,VueUse 的 useFetch 不会自动覆盖 Nuxt 的同名 composable。
如果确实要用 VueUse 版本,应显式导入:
import { useFetch as useVueUseFetch } from '@vueuse/core'
如果目标是页面首屏 SSR 数据,优先评估 Nuxt 的 useFetch/useAsyncData;不要因为名字相同就把两个 API 当成一回事。
3. useAxios
VueUse 的 useAxios 位于 @vueuse/integrations,需要单独安装 Axios,并支持传入 Axios instance/config。它提供响应式 data、response、error、isLoading、isFinished,并支持手动执行、取消、是否立即执行、上一次请求是否中止、成功/失败/完成回调等选项。
import axios from 'axios'
import { useAxios } from '@vueuse/integrations/useAxios'
const api = axios.create({ baseURL: '/api' })
const { data, isLoading, execute } = useAxios(
'/users',
api,
{ immediate: false },
)
await execute()
原文认为 useAxios 只能提供非常基础的能力,这个判断需要更新:它已经覆盖不少通用请求状态,但它仍然不是带完整缓存、轮询、SWR、插件和业务策略的 useRequest。如果项目不使用 Axios,选择 useFetch 或自己的 Promise service 更合适。
四、为什么业务会想要自己的 useRequest
原文的实际痛点仍然常见:
- 同一项目可能从 Axios 切换到
fetch、$fetch或自研 request; - 搜索框需要防抖,按钮操作可能需要节流;
- 列表需要分页、刷新、轮询和加载更多;
- 失败后需要按策略重试,并区分业务错误和网络错误;
- 页面重新获得焦点后需要刷新;
- 首屏希望展示旧缓存,同时后台重新验证(SWR);
- 相同 key 的请求希望复用或去重;
- 业务团队需要统一日志、鉴权、错误提示和插件钩子。
这些需求不是“调用一次 Promise”能够解决的,而是一个请求状态机:
idle
↓ run
pending ── success ──> finished
│
├─ error ── retry / finished
├─ cancel
└─ superseded(被更新的请求取代)
五、一个最小但安全的 Vue 3 useRequest
下面的实现只覆盖核心状态和竞态保护,作为理解原文的基础,不是生产级请求库:
import {
onScopeDispose,
ref,
shallowRef,
} from 'vue'
export type Service<TData, TParams extends unknown[]> =
(...params: TParams) => Promise<TData>
export interface UseRequestOptions<TData, TParams extends unknown[]> {
manual?: boolean
defaultParams?: TParams
onSuccess?: (data: TData, params: TParams) => void
onError?: (error: unknown, params: TParams) => void
onFinally?: (params: TParams) => void
}
export function useRequest<
TData,
TParams extends unknown[] = [],
>(
service: Service<TData, TParams>,
options: UseRequestOptions<TData, TParams> = {},
) {
const loading = ref(false)
const data = shallowRef<TData>()
const error = shallowRef<unknown>()
const params = ref<TParams | undefined>(options.defaultParams)
let requestId = 0
const runAsync = async (...nextParams: TParams) => {
const id = ++requestId
params.value = nextParams
loading.value = true
error.value = undefined
try {
const result = await service(...nextParams)
// 只允许最后一次请求写入状态,避免旧响应覆盖新响应。
if (id === requestId) {
data.value = result
options.onSuccess?.(result, nextParams)
}
return result
} catch (cause) {
if (id === requestId) {
error.value = cause
options.onError?.(cause, nextParams)
}
throw cause
} finally {
if (id === requestId) {
loading.value = false
options.onFinally?.(nextParams)
}
}
}
const run = (...nextParams: TParams) => {
void runAsync(...nextParams).catch(() => {})
}
const cancel = () => {
// 这只能阻止旧 Promise 更新状态,不能取消底层网络请求。
// 要真正取消,需要让 service 接收 AbortSignal,并调用 controller.abort()。
requestId++
loading.value = false
}
const refreshAsync = () => {
const previous = params.value || ([] as unknown as TParams)
return runAsync(...previous)
}
if (!options.manual) {
run(...(options.defaultParams || ([] as unknown as TParams)))
}
onScopeDispose(cancel)
return {
loading,
data,
error,
params,
run,
runAsync,
refresh: () => { void refreshAsync().catch(() => {}) },
refreshAsync,
cancel,
}
}
需要特别注意的两个问题
1. Promise 不能凭空取消
原文把 cancel 作为请求能力的一部分,但普通 Promise 没有取消协议。上面 cancel 只是让旧结果不再写入状态;如果要中止 fetch,应把 AbortSignal 传给底层 service:
async function getUser(id: string, signal?: AbortSignal) {
const response = await fetch(`/api/users/${id}`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
Axios、Fetch、$fetch 和自研 request 的取消方式可能不同,request 层应定义统一 adapter,而不是假设所有 Promise 都支持 abort。
2. 卸载和竞态要同时处理
onScopeDispose 可以在组件卸载或 effect scope 销毁时阻止旧结果写回;requestId 则处理同一组件中连续执行的请求:
请求 A 发出
请求 B 发出
请求 B 先返回 → 允许更新
请求 A 后返回 → 丢弃旧结果
如果业务需要保留每个请求的结果,则不能使用“最后一次获胜”策略,应改为按请求 key 分开存储。
六、在核心之上组合业务能力
1. 防抖和节流
不要把防抖逻辑硬编码到所有请求中。搜索场景可以在调用层组合:
import { useDebounceFn } from '@vueuse/core'
const { run } = useRequest(search, { manual: true })
const runSearch = useDebounceFn(
(keyword: string) => run(keyword),
300,
)
点击提交、滚动加载等场景可能需要节流;防抖和节流并不是同一个策略,应该由业务选择。
2. 轮询
import { onScopeDispose } from 'vue'
const fetchLatest = () => fetch('/api/latest').then(response => response.json())
const { refreshAsync } = useRequest(fetchLatest, { manual: true })
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
async function poll() {
if (stopped) return
await refreshAsync().catch(() => {})
if (!stopped) {
timer = setTimeout(poll, 5000)
}
}
onScopeDispose(() => {
stopped = true
if (timer) clearTimeout(timer)
})
生产实现还要处理页面不可见、请求失败退避、手动停止、组件卸载和重复启动。可以使用 VueUse 的 useIntervalFn,不必重复实现定时器生命周期。
3. 重试
重试应该有次数、延迟和可重试错误判断:
async function withRetry(task, {
retries = 2,
delay = 300,
shouldRetry = () => true,
} = {}) {
let attempt = 0
while (true) {
try {
return await task()
} catch (error) {
if (attempt >= retries || !shouldRetry(error)) throw error
attempt++
await new Promise(resolve => setTimeout(resolve, delay * attempt))
}
}
}
不能对 401、参数错误等确定失败的业务请求无限重试。
4. 缓存和 SWR
缓存层至少要定义:
cacheKey → data、timestamp、promise、error
常见流程是:
- 先返回未过期缓存;
- 同时发起后台请求;
- 成功后替换缓存和页面数据;
- 相同 key 的并发请求复用同一个 Promise;
- 需要时支持失效、重新验证和持久化。
缓存 key 必须包含 URL、参数、用户身份、租户和影响结果的请求头,否则可能把错误用户的数据复用给别人。SSR 场景还要隔离请求缓存,不能使用不安全的进程级用户缓存。
5. 插件/中间件
插件的价值是把日志、格式化、错误提示、缓存和广播等横切逻辑从核心请求状态机中拆出去:
type RequestPlugin<TData> = {
onBefore?: (ctx: { params: unknown[] }) => void
onSuccess?: (ctx: { data: TData }) => void
onError?: (ctx: { error: unknown }) => void
onFinally?: () => void
}
原文中的 formatter 插件本质上是“请求成功后改变 data”。这种设计可以保留,但要明确 formatter 是复制、映射还是直接修改响应,避免插件之间互相覆盖结果。
七、vue-hooks-plus 的当前定位
原文实现的项目是 vue-hooks-plus。当前仓库仍将它定位为 Vue 3 Hooks 库,并强调:
- TypeScript 类型;
- SSR 支持;
useRequest请求中间层;- 手动/自动请求;
- loading delay、轮询、重试、刷新依赖、窗口聚焦刷新;
- 防抖、节流、缓存/SWR 和插件等扩展。
但项目的选项、返回值和插件类型可能随版本变化,不能把原文中的类型签名直接复制到最新版本。使用时应锁定版本并以仓库文档为准:
import { useRequest } from 'vue-hooks-plus'
原文项目的历史页面截图如下,仅用于记录文章发表时的状态,不能代表当前文档 UI:


原文类型签名的注意事项
原文的示例类似:
const {
loading,
data,
error,
run,
runAsync,
refresh,
refreshAsync,
mutate,
cancel,
} = useRequest<TData, TParams>(service, options)
这个 API 形状可以作为设计草图,但以下内容必须以实际版本定义为准:
TParams通常应表达参数元组,例如TParams extends unknown[];data是否是Ref<TData | undefined>取决于是否提供初始值;run、runAsync的参数和返回 Promise 需要查看当前类型;onFinally的参数顺序不是 ahooks、vue-hooks-plus 和自研实现之间的稳定标准;Plugin的fetchInstance结构属于库内部契约,不要从文章中盲目复制。
八、到底该不该造轮子
适合自己封装的情况
- 项目有明确且稳定的请求状态约定;
- 需要统一鉴权、错误码、日志、竞态和缓存策略;
- 团队愿意维护测试、类型和版本升级;
- 需要适配多个 request 实现;
- 现有库的抽象确实不匹配业务。
不适合的情况
- 只是因为没读懂 VueUse API;
- 只需要
loading/data/error却复制一整套 ahooks; - 没有测试,无法处理卸载、竞态、SSR 和取消;
- 把第三方库的文档、类型和实现直接复制进项目;
- 业务需求很快变化,却把临时方案包装成公共基础设施。
一个比较稳妥的路线是:先用 VueUse/Nuxt 原生工具完成简单请求;当重复的业务策略达到一定复杂度后,在项目内加一层薄薄的 useAppRequest,最后再决定是否需要独立库。
总结
- VueUse 不是完整的 ahooks
useRequest替代品,但它的useFetch和useAxios已经提供了不少请求能力; - Nuxt 4 的
useFetch与 VueUse 的useFetch同名但定位不同; - 自定义 request 层的重点是竞态、取消、生命周期、错误、缓存和可扩展性;
- 普通 Promise 不能凭空取消,
cancel至少要防止旧结果写回,真正中止需要AbortSignal或底层库支持; - 防抖、节流、轮询、SWR 和插件可以组合,不必全部塞进核心;
vue-hooks-plus仍可作为 Vue 3 请求方案,但 API 以当前版本文档为准;- 造轮子是一项长期维护承诺,先确认需求和测试能力,再决定是否自研。
参考资料
- VueUse
useFetch - VueUse
useAxios - VueUse
useDebounceFn - Vue 3 Composables
- Nuxt 4
useFetch - vue-hooks-plus GitHub
- vue-hooks-plus 文档
- React ahooks
useRequest对比参考
原文作者:YongGit。原文关于 React/ahooks 迁移、请求 Hook、自动/手动请求、轮询、防抖、节流、缓存和插件化的主线予以保留;VueUse 能力过时的判断、Nuxt 同名 API 混淆、未处理 Promise 取消和竞态的问题、无效代码围栏及推广噪声已整理或修正。