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

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 3 开发,或许你需要这样使用请求 API

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/28-Vue3开发,或许你需要这样使用请求API
本文目录11 个章节
  1. 请求 API 常见的使用方式
  2. 一、Axios 二次封装
  3. 二、链式调用和 async/await
  4. 三、Provide/Inject 注入 Axios
  5. 四、为什么需要请求 Hook / Composable?
  6. 五、依赖变化、ready 和防抖
  7. 六、vue-hooks-plus 的历史与当前使用
  8. 七、请求层的推荐分层
  9. 总结
  10. 官方参考
  11. 原文出处

Vue 3 开发,或许你需要这样使用请求 API

本文原文写于 2022 年,重点讨论 Axios 二次封装和 useRequest 请求 hook。本文保留“请求函数集中管理、业务组件只关注状态和配置”的设计思路,同时修复原文中的返回值、TypeScript、PUT 分支和异步竞态问题,并补充 Vue 3.5、AbortController、watch cleanup 和 SSR 边界。

useRequestvue-axiosvue-hooks-plus 都是第三方方案,不是 Vue 官方 API。使用时应按实际安装版本阅读对应文档和源码,不能把某个库的配置项当成 Vue 的通用约定。

请求 API 常见的使用方式

前端项目中常见的请求组织方式包括:

  1. 二次封装 Axios,将每个业务 API 放到独立文件中,返回 Promise,在组件中使用链式调用。
  2. 使用 async/await 调用统一的 API 函数。
  3. 通过插件或 provide/inject 注入 Axios 实例。
  4. 直接在组件中调用 Axios;适合临时示例,不适合作为大型项目的默认结构。
  5. 在 composable 或请求 hook 中统一管理 dataloadingerror、取消、重试和依赖刷新。

没有一种封装能自动适合所有项目。关键是先确定一个稳定的 API 契约:请求层到底返回完整的 AxiosResponse<T>,还是只返回业务数据 T。本文后面的示例统一选择返回业务数据 T

一、Axios 二次封装

创建 Axios 实例

// src/api/http.ts
import axios from 'axios'

export const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_SCREEN_BASE_URL,
  timeout: 10_000,
})

axiosInstance.interceptors.request.use(config => {
  // 例如:添加 token、trace id
  return config
})

axiosInstance.interceptors.response.use(
  response => response,
  error => Promise.reject(error),
)

超时和错误处理应根据业务设置。Axios 0.22.0 起支持 AbortControllersignal,新的请求代码应优先使用它;旧的 CancelToken 已被弃用。

类型安全的请求函数

Axios 的类型参数容易被误用:响应类型不是 AxiosRequestConfig 的第一个泛型。为了让业务层只得到 T,可以在统一入口解包 response.data

// src/api/request.ts
import type { AxiosRequestConfig } from 'axios'
import { axiosInstance } from './http'

type RequestConfig<D = unknown, P = unknown> = Omit<
  AxiosRequestConfig<D>,
  'params'
> & {
  params?: P
}

export async function request<T, D = unknown, P = unknown>(
  url: string,
  config?: RequestConfig<D, P>,
): Promise<T> {
  const response = await axiosInstance.request<T>({
    url,
    ...config,
  })

  return response.data
}

如果后端统一返回的是:

type ApiEnvelope<T> = {
  code: number
  message: string
  data: T
}

可以明确地在请求入口处理:

export async function requestApi<T, D = unknown, P = unknown>(
  url: string,
  config?: RequestConfig<D, P>,
): Promise<T> {
  const response = await axiosInstance.request<ApiEnvelope<T>>({
    url,
    ...config,
  })

  if (response.data.code !== 0) {
    throw new Error(response.data.message)
  }

  return response.data.data
}

这样组件和请求 hook 都只处理 T,不会出现有时写 res.data、有时写 res.data.data 的混乱。拦截器改变运行时返回值后,TypeScript 不会自动知道你的业务协议,应该在封装函数上显式声明返回类型。

管理业务 API

每个业务域可以单独建立 API 文件:

// src/api/report.ts
import { request } from './request'

export interface AnalysisReport {
  id: number
  name: string
  visitUv: number
}

export interface ReportListParams {
  reportGroup?: string | null
  sortCol?: 'visitUv' | 'uploadFileTime'
  sortType?: number
  reportName?: string
}

export function getListReports(
  params?: ReportListParams,
  signal?: AbortSignal,
): Promise<AnalysisReport[]> {
  return request<AnalysisReport[]>('/platform/report/listReports', {
    params: {
      ...params,
      sortType: params?.sortType ?? 2,
    },
    signal,
  })
}

这里 sortType 的类型和传入的数字保持一致。如果后端约定的是字符串,就把接口类型和默认值都改为字符串,不要让类型声明写 string、运行时却传 number

二、链式调用和 async/await

链式调用

如果 getListReports() 已经返回 Promise<AnalysisReport[]>,组件中应直接使用结果:

<script setup lang="ts">
import { ref } from 'vue'
import { getListReports, type AnalysisReport } from '@/api/report'

const reports = ref<AnalysisReport[]>([])
const error = ref<unknown>(null)

getListReports()
  .then(result => {
    reports.value = result
  })
  .catch(reason => {
    error.value = reason
  })
</script>

原文的 getData() 已经通过 resolve(res.data.data) 返回业务数据,后面却又写 data.value = res.data,这是返回契约不一致的错误。若 wrapper 返回 T,就应写 data.value = res;若 wrapper 返回 AxiosResponse<T>,则所有调用方都要统一使用 res.data

async/await 调用

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getListReports, type AnalysisReport } from '@/api/report'

const reports = ref<AnalysisReport[]>([])
const loading = ref(false)
const error = ref<unknown>(null)

async function loadReports() {
  loading.value = true
  error.value = null

  try {
    reports.value = await getListReports()
  } catch (reason) {
    error.value = reason
  } finally {
    loading.value = false
  }
}

onMounted(loadReports)
</script>

async/await 不是性能优化,只是让有依赖的异步流程更容易按顺序表达。请求仍然需要处理 loading、错误、取消和组件卸载。

三、Provide/Inject 注入 Axios

如果项目确实需要在组件树中注入 Axios,可以使用类型安全的 InjectionKey。但普通 API 模块直接导出 axiosInstance 往往更简单,不能为了“全局可用”而把实例挂成 any

// src/api/keys.ts
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'

export const axiosKey: InjectionKey<AxiosInstance> = Symbol('axios')
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { axiosInstance } from './api/http'
import { axiosKey } from './api/keys'

const app = createApp(App)
app.provide(axiosKey, axiosInstance)
app.mount('#app')
<script setup lang="ts">
import { inject } from 'vue'
import { axiosKey } from '@/api/keys'

const axios = inject(axiosKey)
if (!axios) {
  throw new Error('Axios instance has not been provided')
}
</script>

原文的 vue-axios 可以作为 Vue 2/遗留项目方案了解,但它是第三方插件,并不是 Vue 3 官方推荐的请求方式。Vue 3 插件也可以通过 app.config.globalProperties 注入实例,但 Composition API 代码仍需通过导入、inject 或 composable 获得它。

四、为什么需要请求 Hook / Composable?

多个页面常常会重复以下逻辑:

  • 请求前设置 loading;
  • 请求成功后保存 data;
  • 请求失败后保存 error;
  • 某个 ref 或 props 变化后重新请求;
  • 防抖、重试、取消和旧响应保护;
  • 组件卸载时清理副作用。

Vue 官方把利用 Composition API 封装有状态逻辑的函数称为 composable。useRequest 可以是项目内部的 composable,也可以是第三方库提供的 hook;它不是 Vue 的内置 API。

一个最小但可用的 useRequest

下面的实现显式把 AbortSignal 传给 service,同时用请求序号防止旧响应覆盖新响应:

// src/composables/useRequest.ts
import { onUnmounted, ref, type Ref } from 'vue'

export interface RequestContext {
  signal: AbortSignal
}

type Service<T, P extends unknown[]> = (
  params: P,
  context: RequestContext,
) => Promise<T>

interface UseRequestOptions<P extends unknown[]> {
  immediate?: boolean
  initialParams?: P
}

export function useRequest<T, P extends unknown[]>(
  service: Service<T, P>,
  options: UseRequestOptions<P> = {},
): {
  data: Ref<T | undefined>
  loading: Ref<boolean>
  error: Ref<unknown>
  run: (...params: P) => Promise<T>
  cancel: () => void
} {
  const data = ref<T>()
  const loading = ref(false)
  const error = ref<unknown>(null)

  let controller: AbortController | undefined
  let requestId = 0

  function cancel() {
    // 先使旧请求的结果失效,再尝试中止底层请求
    requestId++
    controller?.abort()
    controller = undefined
    loading.value = false
  }

  async function run(...params: P): Promise<T> {
    cancel()
    const currentId = requestId
    const nextController = new AbortController()
    controller = nextController
    loading.value = true
    error.value = null

    try {
      const result = await service(params, {
        signal: nextController.signal,
      })

      if (currentId === requestId) {
        data.value = result
      }
      return result
    } catch (reason) {
      if (currentId === requestId && !isAbortError(reason)) {
        error.value = reason
      }
      throw reason
    } finally {
      if (currentId === requestId) {
        loading.value = false
        controller = undefined
      }
    }
  }

  onUnmounted(cancel)

  if (options.immediate && options.initialParams) {
    void run(...options.initialParams).catch(() => {
      // 由调用方决定是否需要额外记录初始化错误
    })
  }

  return { data, loading, error, run, cancel }
}

function isAbortError(error: unknown): boolean {
  return (
    (typeof DOMException !== 'undefined' &&
      error instanceof DOMException &&
      error.name === 'AbortError') ||
    (typeof error === 'object' &&
      error !== null &&
      (error as { code?: string }).code === 'ERR_CANCELED')
  )
}

上面是用于讲解结构的基础版本,实际项目还可以扩展 refresh、重试、分页和防抖。本文实现中,immediate 需要同时提供 initialParams;无参数 service 可以传 initialParams: [],带必需参数时应提供明确的初始参数,不要通过类型断言传空参数。

请求 service 可以这样写:

const reportsRequest = useRequest(
  async ([params], { signal }) => {
    return request<AnalysisReport[]>('/platform/report/listReports', {
      params,
      signal,
    })
  },
)

await reportsRequest.run({ reportName: 'Vue' })

如果使用 Axios,request() 需要把 signal 原样传给 Axios 配置:

request<AnalysisReport[]>('/platform/report/listReports', {
  params,
  signal,
})

五、依赖变化、ready 和防抖

当请求依赖响应式值时,可以使用 watch

<script setup lang="ts">
import { ref, watch } from 'vue'
import { getListReports, type AnalysisReport } from '@/api/report'

const keyword = ref('')
const ready = ref(true)
const reports = ref<AnalysisReport[]>([])
let timer: number | undefined
let controller: AbortController | undefined
let latestRequest = 0

watch(keyword, (value, _oldValue, onCleanup) => {
  if (!ready.value) return

  if (timer !== undefined) window.clearTimeout(timer)
  controller?.abort()

  const currentController = new AbortController()
  controller = currentController
  const requestId = ++latestRequest

  timer = window.setTimeout(async () => {
    try {
      const result = await getListReports(
        { reportName: value },
        currentController.signal,
      )
      if (requestId === latestRequest) {
        reports.value = result
      }
    } catch (error) {
      // 真实代码中使用 axios.isCancel() 排除取消错误
      console.error(error)
    }
  }, 300)

  onCleanup(() => {
    if (timer !== undefined) window.clearTimeout(timer)
    currentController.abort()
  })
})
</script>

防抖只是延迟触发,不会自动终止已经发出的 HTTP 请求。要真正取消请求,应把 AbortSignal 传给 fetch 或 Axios,并在 watcher 清理时调用 abort()。如果只需要防止旧结果写入页面,也可以用递增序号比较。

Vue 3.5+ 还提供 onWatcherCleanup()

import axios from 'axios'
import { onWatcherCleanup, watch } from 'vue'
import { request } from '@/api/request'

watch(keyword, value => {
  const controller = new AbortController()

  void request<AnalysisReport[]>('/platform/report/listReports', {
    params: { reportName: value },
    signal: controller.signal,
  }).catch(error => {
    if (!axios.isCancel(error)) console.error(error)
  })
  onWatcherCleanup(() => controller.abort())
})

onWatcherCleanup() 必须在 watcher callback 的同步执行阶段注册,不能放在 await 之后。为了兼容更多 Vue 3 版本,watch callback 第三个参数提供的 onCleanup 仍然是很好的选择。

六、vue-hooks-plus 的历史与当前使用

原文第三方请求库页面截图(本地化)

原文作者还介绍了 vue-hooks-plususeRequest,并在 2023 年补充了 Pinia 请求状态、broadcast-channel 等外置插件。这个项目可以作为第三方请求 hook 了解,但它的参数名、类型和取消语义应以实际安装版本的文档为准。

原文使用的 depsmanualready 等名称可能来自当时版本。当前文档常见的配置包括:

  • manual:是否手动触发;
  • ready:是否允许请求执行;
  • refreshDeps:依赖变化后刷新;
  • debounceWait:防抖等待时间;
  • loadingdataerrorrunrunAsyncrefreshcancel

示意写法:

const {
  data,
  loading,
  error,
  run,
  runAsync,
  refresh,
  cancel,
} = useRequest(
  (keyword: string) => getListReports({ reportName: keyword }),
  {
    manual: true,
    debounceWait: 300,
  },
)

注意:一些 useRequest 库中的 cancel() 只会忽略当前 Promise 的结果或关闭 loading,并不一定向底层 Axios/fetch 传递 AbortSignal。如果业务明确要求终止网络请求,应使用库提供的 AbortController 集成,或自行把 signal 传入 service。不要看到 cancel() 这个名字就假设 HTTP 已经中止。

原文中的 API 文档和仓库截图已本地化:

原文 useRequest 文档截图(本地化)

“覆盖 99% 业务”等说法属于作者的项目经验和宣传表达,不能当作经过独立测试的结论。选用第三方库时还应检查版本、维护状态、SSR 行为、错误处理和类型声明。

七、请求层的推荐分层

一个较清晰的目录可以是:

src/
├─ api/
│  ├─ http.ts       # Axios 实例、拦截器
│  ├─ request.ts    # 通用 request<T>,统一返回业务数据
│  └─ report.ts     # 具体业务 API 和类型
├─ composables/
│  └─ useRequest.ts # loading/error/取消/竞态等状态逻辑
└─ views/
   └─ ReportView.vue

各层的职责:

  • Axios 层处理 baseURL、超时、认证、序列化和统一错误。
  • API 文件处理 URL、请求参数和响应类型。
  • composable 处理请求状态、依赖刷新、取消和竞态。
  • 组件处理页面展示和用户交互,不重复写 Axios 解包和通用清理。

总结

  • Axios 本身已经返回 Promise,不要无意义地再包一层 new Promise
  • 先确定 wrapper 返回 AxiosResponse<T> 还是业务 T,整个项目保持一致。
  • AxiosRequestConfig 的泛型不能代替响应类型;请求函数应显式声明 Promise<T>
  • PUT 必须调用 PUT;代码示例中的括号、逗号和类型都应可以直接复制运行。
  • composable/request hook 应管理 loading、error、cleanup、取消和旧响应竞态。
  • 防抖不等于取消 HTTP;真正取消请求使用 AbortController.signal
  • Vue 3.5 的 onWatcherCleanup() 不能在异步 callback 的 await 之后注册。
  • vue-hooks-plus 是第三方库,refreshDepscancel 的语义应按匹配版本核对。

官方参考

原文出处

作者:YongGit

原文链接:https://juejin.cn/post/7143488626955911181

来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS