Vue3 异步数据管理:从满地都是 loading 到优雅的 useRequest,保姆级优化之路
一、先看一个真实的痛点
假设页面上有三个模块:用户信息、文章列表、通知数量。每个都需要从后端拿数据。
如果直接用 Axios 写在组件里,代码大概长这样:
vue
<template>
<div>
<!-- 用户信息 -->
<div v-if="userLoading">用户信息加载中...</div>
<div v-else-if="userError">用户信息加载失败:{{ userError }}</div>
<div v-else>{{ user?.name }}</div>
<!-- 文章列表 -->
<div v-if="articleLoading">文章加载中...</div>
<div v-else-if="articleError">文章加载失败:{{ articleError }}</div>
<ul v-else><li v-for="item in articles" :key="item.id">{{ item.title }}</li></ul>
<!-- 通知数量 -->
<div v-if="notifyLoading">通知加载中...</div>
<div v-else-if="notifyError">通知加载失败:{{ notifyError }}</div>
<div v-else>{{ notifyCount }} 条新通知</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getUserInfo, getArticles, getNotifyCount } from '@/api'
// 用户信息相关状态
const user = ref(null)
const userLoading = ref(false)
const userError = ref(null)
// 文章列表相关状态
const articles = ref([])
const articleLoading = ref(false)
const articleError = ref(null)
// 通知数量相关状态
const notifyCount = ref(0)
const notifyLoading = ref(false)
const notifyError = ref(null)
// 获取用户信息
async function fetchUser() {
userLoading.value = true
userError.value = null
try {
user.value = await getUserInfo()
} catch (e) {
userError.value = e.message
} finally {
userLoading.value = false
}
}
// 获取文章列表
async function fetchArticles() {
articleLoading.value = true
articleError.value = null
try {
articles.value = await getArticles()
} catch (e) {
articleError.value = e.message
} finally {
articleLoading.value = false
}
}
// 获取通知数量
async function fetchNotify() {
notifyLoading.value = true
notifyError.value = null
try {
notifyCount.value = await getNotifyCount()
} catch (e) {
notifyError.value = e.message
} finally {
notifyLoading.value = false
}
}
onMounted(() => {
fetchUser()
fetchArticles()
fetchNotify()
})
</script>
问题很明显:
-
每加一个请求,就要手动写
loading、error、data三个状态。 -
try/catch/finally模板代码复制来复制去,整个组件一大半是重复逻辑。 -
三个请求各管各的,互相之间不好协调。
目标: 把这些重复的“状态管理”逻辑抽出去,组件只关心“拿什么数据”和“怎么展示”。
二、第一步:写一个最简单的 useAsync
把“发请求 + 管状态”抽成一个组合式函数。
javascript
// src/hooks/useAsync.js
import { ref } from 'vue'
// asyncFn 是一个返回 Promise 的函数,比如 () => getUserInfo()
export function useAsync(asyncFn) {
const data = ref(null) // 存放请求返回的数据
const loading = ref(false) // 是否正在加载
const error = ref(null) // 错误信息
// 执行请求的函数
async function execute(...args) {
// 每次执行前重置状态
loading.value = true
error.value = null
// 注意:data 不在这里清空,因为有时候想让旧数据先保留着
// 如果想清空,加一行 data.value = null
try {
// 调用传进来的异步函数,把参数原样传过去
const result = await asyncFn(...args)
data.value = result // 成功就把结果存起来
return result // 把结果也返回出去,方便外面链式调用
} catch (err) {
// 失败就把错误信息存起来
error.value = err.message || '请求失败'
throw err // 把错误继续抛出,让外面也能 catch
} finally {
loading.value = false // 不管成功失败,最终 loading 都设成 false
}
}
// 把状态和方法返回出去
return { data, loading, error, execute }
}
使用方式:
vue
<template>
<div>
<div v-if="userLoading">加载中...</div>
<div v-else-if="userError">错误:{{ userError }}</div>
<div v-else>{{ userData?.name }}</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { getUserInfo } from '@/api/user'
import { useAsync } from '@/hooks/useAsync'
// 把 getUserInfo 函数传进去,拿到状态和 execute 方法
const { data: userData, loading: userLoading, error: userError, execute: fetchUser } = useAsync(getUserInfo)
onMounted(() => {
fetchUser()
})
</script>
对比原始写法:
-
不需要手动写
ref定义loading、error、data了。 -
不需要写
try/catch/finally了。 -
组件代码直接从十几行缩减到三行。
但 useAsync 还是有个小问题:每次都要自己调 execute。能不能让它在组件挂载时自动执行?
三、第二步:加上自动执行
给 useAsync 加个配置项,让它能选择“自动执行”还是“手动执行”。
javascript
// src/hooks/useAsync.js(升级版)
import { ref, onMounted } from 'vue'
export function useAsync(asyncFn, options = {}) {
const { immediate = false } = options // immediate 为 true 时自动执行
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function execute(...args) {
loading.value = true
error.value = null
try {
const result = await asyncFn(...args)
data.value = result
return result
} catch (err) {
error.value = err.message || '请求失败'
throw err
} finally {
loading.value = false
}
}
// 如果 immediate 为 true,在组件挂载时自动执行一次
onMounted(() => {
if (immediate) {
execute()
}
})
return { data, loading, error, execute }
}
使用:
javascript
// 自动执行:组件挂载时自动发请求
const { data, loading, error } = useAsync(getUserInfo, { immediate: true })
// 手动执行:需要点击按钮之类才发请求
const { data, loading, error, execute } = useAsync(submitForm)
// 调用 execute(data) 才发请求
四、第三步:让 data 能“刷新”而不是只赋值一次
现在 data 是 ref(null),每次请求成功就覆盖旧值。但有些场景需要追加数据,比如分页加载:新数据应该拼在旧数组后面,而不是替换。
加一个 accumulate 配置,允许累加数组。
javascript
// src/hooks/useAsync.js(继续升级)
import { ref, onMounted } from 'vue'
export function useAsync(asyncFn, options = {}) {
const { immediate = false, accumulate = false } = options
// 如果 accumulate 为 true,数据初始化为空数组
const data = ref(accumulate ? [] : null)
const loading = ref(false)
const error = ref(null)
async function execute(...args) {
loading.value = true
error.value = null
try {
const result = await asyncFn(...args)
if (accumulate && Array.isArray(result)) {
// 累加模式:把新数据追加到旧数组后面
data.value = [...data.value, ...result]
} else {
data.value = result
}
return result
} catch (err) {
error.value = err.message || '请求失败'
throw err
} finally {
loading.value = false
}
}
onMounted(() => {
if (immediate) execute()
})
return { data, loading, error, execute }
}
使用场景:
javascript
// 场景一:普通获取,每次覆盖(比如用户信息)
const { data: user, loading } = useAsync(getUserInfo, { immediate: true })
// 场景二:分页加载,每次追加(比如文章列表下拉加载更多)
const { data: articles, loading, execute: loadMore } = useAsync(
(page) => getArticles({ page }),
{ accumulate: true }
)
// 第一次加载第一页
loadMore(1)
// 下拉后加载第二页,会拼到第一页后面
loadMore(2)
五、第四步:处理竞态问题
如果用户快速切换页面,或者连续点按钮,可能会发出多个请求,后发的请求先返回,导致数据错乱。
比如:切换用户 ID 时,先请求用户1(慢),再请求用户2(快),用户2的数据先回来了,但紧接着用户1的数据也回来了,页面最终显示的是旧数据。
我们给 useAsync 加上请求序号来解决。
javascript
// src/hooks/useAsync.js(完整版)
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function useAsync(asyncFn, options = {}) {
const { immediate = false, accumulate = false } = options
const data = ref(accumulate ? [] : null)
const loading = ref(false)
const error = ref(null)
// 记录请求的序号,解决竞态问题
let requestId = 0
async function execute(...args) {
// 每次执行前,序号加 1,并记住当前序号
requestId++
const currentRequestId = requestId
loading.value = true
error.value = null
try {
const result = await asyncFn(...args)
// 请求回来后,检查序号是否还是最新的
// 如果不是最新,说明又发了新请求,这个旧结果直接丢弃
if (currentRequestId !== requestId) {
console.log('请求已过期,丢弃结果')
return result
}
if (accumulate && Array.isArray(result)) {
data.value = [...data.value, ...result]
} else {
data.value = result
}
return result
} catch (err) {
// 错误也需要判断序号,避免旧请求的错误覆盖新请求的状态
if (currentRequestId === requestId) {
error.value = err.message || '请求失败'
}
throw err
} finally {
// 同样,只有最新请求才把 loading 置为 false
if (currentRequestId === requestId) {
loading.value = false
}
}
}
onMounted(() => {
if (immediate) execute()
})
// 组件卸载时,把序号设为负数,让所有进行中的请求都失效
onBeforeUnmount(() => {
requestId = -1
})
return { data, loading, error, execute }
}
解释:
-
每次调用
execute,内部requestId自增。 -
请求返回时,检查
currentRequestId是否还等于最新的requestId,如果不等说明中间有更新的请求,旧结果被抛弃。 -
组件卸载时把
requestId设为 -1,所有还在路上的请求即使返回也不会更新状态,避免“组件已销毁但请求还在跑”的报错。
六、实战:完整的列表页(带分页、刷新、错误重试)
综合上面所有优化,做一个完整的文章列表页面。
vue
<template>
<div class="article-list">
<h2>文章列表</h2>
<!-- 首次加载中 -->
<div v-if="loading && articles.length === 0">加载中...</div>
<!-- 加载失败 -->
<div v-else-if="error && articles.length === 0">
<p>加载失败:{{ error }}</p>
<button @click="refresh">重试</button>
</div>
<!-- 文章列表 -->
<div v-else>
<div v-for="article in articles" :key="article.id" class="article-item">
<h3>{{ article.title }}</h3>
<p>{{ article.summary }}</p>
</div>
<!-- 加载更多时的小 loading -->
<div v-if="loading && articles.length > 0">加载更多...</div>
<!-- 没有更多了 -->
<p v-if="noMore">—— 到底了 ——</p>
<!-- 加载更多按钮 -->
<button v-if="!noMore" @click="loadMore">加载更多</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { getArticles } from '@/api/article'
import { useAsync } from '@/hooks/useAsync'
const page = ref(1)
const pageSize = 10
// 用 useAsync 管理请求,accumulate: true 让分页数据累加
const { data: articles, loading, error, execute } = useAsync(
() => getArticles({ page: page.value, pageSize }),
{ accumulate: true, immediate: true }
)
// 是否已到底
const noMore = computed(() => articles.value.length > 0 && articles.value.length % pageSize !== 0)
// 加载更多
function loadMore() {
page.value++
execute()
}
// 刷新(重置页码,清空旧数据)
function refresh() {
page.value = 1
articles.value = [] // 清空旧数据
execute()
}
</script>
效果:
-
进来自动加载第一页。
-
点“加载更多”追加数据。
-
失败了可以点“重试”。
-
完全不用手动写 loading/error 的样板代码。
七、总结
今天我们从一个很乱的组件开始,一步步抽象出了 useAsync 这个组合式函数,解决了:
-
状态管理重复:loading、error、data 自动管理。
-
自动执行:
immediate配置让组件挂载时自动发请求。 -
数据累加:
accumulate配置让分页数据自动累加。 -
竞态问题:请求序号让旧请求结果自动失效。
-
组件卸载安全:卸载后自动忽略还在路上的请求。
这个 useAsync 可以直接用到你自己的项目里,不管你用的是 Vue3 + Axios 还是其他请求库,它都不依赖任何具体实现,只需要一个返回 Promise 的函数就行。
有问题评论区说,看到就回。下篇见!
更多推荐
所有评论(0)