一、先看一个真实的痛点

假设页面上有三个模块:用户信息、文章列表、通知数量。每个都需要从后端拿数据。

如果直接用 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>

问题很明显:

  • 每加一个请求,就要手动写 loadingerrordata 三个状态。

  • 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 定义 loadingerrordata 了。

  • 不需要写 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 这个组合式函数,解决了:

  1. 状态管理重复:loading、error、data 自动管理。

  2. 自动执行immediate 配置让组件挂载时自动发请求。

  3. 数据累加accumulate 配置让分页数据自动累加。

  4. 竞态问题:请求序号让旧请求结果自动失效。

  5. 组件卸载安全:卸载后自动忽略还在路上的请求。

这个 useAsync 可以直接用到你自己的项目里,不管你用的是 Vue3 + Axios 还是其他请求库,它都不依赖任何具体实现,只需要一个返回 Promise 的函数就行。

有问题评论区说,看到就回。下篇见!

更多推荐