技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

浅谈我为什么不直接使用 VueUse,而选择造轮子

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/87-浅谈我为什么不使用VueUse,而选择造轮子
本文目录10 个章节
  1. 一、先说结论:这不是 VueUse 好不好
  2. 二、React 开发者迁移到 Vue 3
  3. 三、VueUse 当前的请求工具
  4. 四、为什么业务会想要自己的 useRequest
  5. 五、一个最小但安全的 Vue 3 useRequest
  6. 六、在核心之上组合业务能力
  7. 七、vue-hooks-plus 的当前定位
  8. 八、到底该不该造轮子
  9. 总结
  10. 参考资料

浅谈我为什么不直接使用 VueUse,而选择造轮子

原文记录了一名 React 开发者使用 Vue 3 时,从 ahooks 的 useRequest 联想到自己实现请求 Hooks 的过程。本文保留这份技术取舍和 vue-hooks-plus 项目背景,但更新 VueUse 当前能力、Nuxt 4 的 useFetch 差异、请求取消和“造轮子”的维护成本。

一、先说结论:这不是 VueUse 好不好

VueUse 和请求管理库解决的问题范围不同:

  • VueUse 是 Vue 生态的通用组合式工具集合,包含响应式、浏览器 API、事件、状态、异步和虚拟列表等工具;
  • @vueuse/coreuseFetch 主要围绕原生 Fetch API;
  • @vueuse/integrationsuseAxios 是对 Axios 的响应式封装;
  • ahooks 的 useRequestvue-hooks-plususeRequest 更接近完整请求状态机和请求策略层。

如果业务只需要一次请求、loading、error、手动执行和取消,VueUse 已经可能够用;如果业务需要轮询、重试、缓存、SWR、聚合、插件和统一竞态控制,选择请求管理库或在项目内封装一层会更合适。

“我为什么不使用 VueUse”更准确地应写成:**当前项目需要的抽象和 VueUse 某个工具的边界不同,所以我选择组合或封装一个更贴近业务的 request 层。**这不是说 VueUse 不能扩展,也不是说自造轮子一定更好。

二、React 开发者迁移到 Vue 3

Vue 3 的 Composition API 和 React Hooks 都支持把状态逻辑抽离到函数中,但生命周期和响应式模型不同:

  • Vue composable 通常使用 refreactivecomputedwatch 和生命周期钩子;
  • 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
  • abortcanAbort 和 timeout;
  • beforeFetchafterFetchonFetchError 等拦截钩子;
  • 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。它提供响应式 dataresponseerrorisLoadingisFinished,并支持手动执行、取消、是否立即执行、上一次请求是否中止、成功/失败/完成回调等选项。

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

常见流程是:

  1. 先返回未过期缓存;
  2. 同时发起后台请求;
  3. 成功后替换缓存和页面数据;
  4. 相同 key 的并发请求复用同一个 Promise;
  5. 需要时支持失效、重新验证和持久化。

缓存 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:

vue-hooks-plus 历史项目主页(已本地化)

vue-hooks-plus 历史文档页面(已本地化)

原文类型签名的注意事项

原文的示例类似:

const {
  loading,
  data,
  error,
  run,
  runAsync,
  refresh,
  refreshAsync,
  mutate,
  cancel,
} = useRequest<TData, TParams>(service, options)

这个 API 形状可以作为设计草图,但以下内容必须以实际版本定义为准:

  • TParams 通常应表达参数元组,例如 TParams extends unknown[]
  • data 是否是 Ref<TData | undefined> 取决于是否提供初始值;
  • runrunAsync 的参数和返回 Promise 需要查看当前类型;
  • onFinally 的参数顺序不是 ahooks、vue-hooks-plus 和自研实现之间的稳定标准;
  • PluginfetchInstance 结构属于库内部契约,不要从文章中盲目复制。

八、到底该不该造轮子

适合自己封装的情况

  • 项目有明确且稳定的请求状态约定;
  • 需要统一鉴权、错误码、日志、竞态和缓存策略;
  • 团队愿意维护测试、类型和版本升级;
  • 需要适配多个 request 实现;
  • 现有库的抽象确实不匹配业务。

不适合的情况

  • 只是因为没读懂 VueUse API;
  • 只需要 loading/data/error 却复制一整套 ahooks;
  • 没有测试,无法处理卸载、竞态、SSR 和取消;
  • 把第三方库的文档、类型和实现直接复制进项目;
  • 业务需求很快变化,却把临时方案包装成公共基础设施。

一个比较稳妥的路线是:先用 VueUse/Nuxt 原生工具完成简单请求;当重复的业务策略达到一定复杂度后,在项目内加一层薄薄的 useAppRequest,最后再决定是否需要独立库。

总结

  • VueUse 不是完整的 ahooks useRequest 替代品,但它的 useFetchuseAxios 已经提供了不少请求能力;
  • Nuxt 4 的 useFetch 与 VueUse 的 useFetch 同名但定位不同;
  • 自定义 request 层的重点是竞态、取消、生命周期、错误、缓存和可扩展性;
  • 普通 Promise 不能凭空取消,cancel 至少要防止旧结果写回,真正中止需要 AbortSignal 或底层库支持;
  • 防抖、节流、轮询、SWR 和插件可以组合,不必全部塞进核心;
  • vue-hooks-plus 仍可作为 Vue 3 请求方案,但 API 以当前版本文档为准;
  • 造轮子是一项长期维护承诺,先确认需求和测试能力,再决定是否自研。

参考资料

原文作者:YongGit。原文关于 React/ahooks 迁移、请求 Hook、自动/手动请求、轮询、防抖、节流、缓存和插件化的主线予以保留;VueUse 能力过时的判断、Nuxt 同名 API 混淆、未处理 Promise 取消和竞态的问题、无效代码围栏及推广噪声已整理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS