Vue 3 开发,或许你需要这样使用请求 API
本文原文写于 2022 年,重点讨论 Axios 二次封装和
useRequest请求 hook。本文保留“请求函数集中管理、业务组件只关注状态和配置”的设计思路,同时修复原文中的返回值、TypeScript、PUT 分支和异步竞态问题,并补充 Vue 3.5、AbortController、watch cleanup 和 SSR 边界。
useRequest、vue-axios、vue-hooks-plus都是第三方方案,不是 Vue 官方 API。使用时应按实际安装版本阅读对应文档和源码,不能把某个库的配置项当成 Vue 的通用约定。
请求 API 常见的使用方式
前端项目中常见的请求组织方式包括:
- 二次封装 Axios,将每个业务 API 放到独立文件中,返回 Promise,在组件中使用链式调用。
- 使用
async/await调用统一的 API 函数。 - 通过插件或
provide/inject注入 Axios 实例。 - 直接在组件中调用 Axios;适合临时示例,不适合作为大型项目的默认结构。
- 在 composable 或请求 hook 中统一管理
data、loading、error、取消、重试和依赖刷新。
没有一种封装能自动适合所有项目。关键是先确定一个稳定的 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 起支持 AbortController 的 signal,新的请求代码应优先使用它;旧的 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-plus 的 useRequest,并在 2023 年补充了 Pinia 请求状态、broadcast-channel 等外置插件。这个项目可以作为第三方请求 hook 了解,但它的参数名、类型和取消语义应以实际安装版本的文档为准。
原文使用的 deps、manual、ready 等名称可能来自当时版本。当前文档常见的配置包括:
manual:是否手动触发;ready:是否允许请求执行;refreshDeps:依赖变化后刷新;debounceWait:防抖等待时间;loading、data、error、run、runAsync、refresh、cancel。
示意写法:
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 文档和仓库截图已本地化:

“覆盖 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是第三方库,refreshDeps和cancel的语义应按匹配版本核对。
官方参考
- Vue Composables
- Vue Watchers
- Vue
watchAPI - Vue Provide / Inject
- Vue TypeScript Provide / Inject
- Axios 请求配置
- Axios 响应结构
- Axios 拦截器
- Axios 取消请求
- MDN AbortController
- MDN AbortSignal
- MDN Fetch 取消请求
- vue-hooks-plus useRequest
- vue-hooks-plus refreshDeps
- vue-hooks-plus debounce
- vue-hooks-plus GitHub
原文出处
作者:YongGit
原文链接:https://juejin.cn/post/7143488626955911181
来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。