六维教程

Vue3 组合式函数

数据请求那篇把”加载中、报错、成功”三段式写在了组件里,可这种需要请求的页面不止一个,文章列表要请求,商品列表要请求,用户信息也要请求,一套逻辑每个页面抄一遍。

重复的麻烦

同样的逻辑抄三遍,麻烦不少:

  • 想加个超时重试,三个页面各改一遍,漏一个就行为不一致
  • 抄着抄着容易丢东西,这个页面记得卸载时中止请求,那个页面忘了
  • 逻辑和具体页面绑在一起,想复用只能复制粘贴,越贴越长

有没有办法把这套逻辑打包成一个函数,谁要用谁调用?

普通函数为什么不行

最直接的想法,抽一个普通函数:

function fetchList(url) {
  const data = []
  fetch(url).then(res => res.json()).then(json => {
    data = json
  })
  return data
}

组件里这样用:

<script setup>
const articles = fetchList('/api/articles')
</script>

这段代码拿不到数据,fetch 是异步的,函数还没等请求回来就已经 return data 了,返回的 data 永远是空数组。

好,改成 async/await

async function fetchList(url) {
  const res = await fetch(url)
  const data = await res.json()
  return data
}
<script setup>
const articles = await fetchList('/api/articles')
</script>

数据能拿到了,但新问题来了:loading 状态存在哪?error 存哪?组件卸载时谁来取消请求?

这些都得有地方”活着”,普通函数管不了。

用 ref 返回”活”的状态

问题的根源在于,普通函数返回的是一次性快照,数据拿到手就凝固了,后续变了也没人知道。

dataref 包一下就行:

import { ref } from 'vue'

async function useFetch(url) {
  const data = ref([])
  const res = await fetch(url)
  data.value = await res.json()   // ref 变了,模板自动更新
  return { data }
}

data 是一个 ref,赋值 data.value = ... 时 Vue 能追踪到变化,模板里用 data 的地方就会自动更新,数据不再是”死”的了。

但只有 data 还不够,loading 和 error 也得是响应式的,不然页面没法根据状态切换显示。

完整版在后面的 useFetch,先把组合式函数的基本概念弄清楚,再回头读它。

组合式函数是什么

上面那个函数就是组合式函数,组合式函数就是以 use 开头的普通函数,内部用 refcomputedwatch、生命周期钩子这些组合式 API,返回响应式状态和方法。

refcomputed 这些并不是组合式函数的特权,它们就是 vue 导出的普通函数,任何函数里都能调用,组合式函数只是把它们集中组织起来,打包成一块可复用的逻辑。

例子:useCounter

先写个最简单的组合式函数找找感觉:

// src/composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => {
    count.value++
  }
  const reset = () => {
    count.value = initial
  }
  return { count, increment, reset }
}

组件里调用:

<script setup>
import { useCounter } from '../composables/useCounter'

const { count, increment, reset } = useCounter(10)
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+1</button>
  <button @click="reset">重置</button>
</template>

count 是 ref,点按钮 count.value 变了,页面就跟着变。

逻辑从组件里抽出来了,以后哪个组件需要计数,一行调用加模板骨架就行。

独立状态

useCounter 每调一次,函数体就重新跑一遍,ref(initial) 会新建一个全新的 ref。所以每次调用拿到的是互相独立的状态:

const { count: a, increment: addA } = useCounter(0)
const { count: b, increment: addB } = useCounter(100)

addA()  // a 变 1,b 纹丝不动

两个组件各调各的,计数也互不干扰,组合式函数负责”复用逻辑“,不负责”共享状态”。想跨组件共享同一份数据,那是 Pinia 的活(Vue3 状态管理入门)。

进阶:useMouse 与生命周期

例子:useMouse

在写请求封装之前,先看这个例子,纯粹展示”响应式 + 生命周期”的组合用法:

// src/composables/useMouse.js
import { ref, onMounted, onBeforeUnmount } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  const update = (e) => {
    x.value = e.clientX
    y.value = e.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onBeforeUnmount(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

组件里直接用:

<script setup>
import { useMouse } from '../composables/useMouse'

const { x, y } = useMouse()
</script>

<template>
  <p>鼠标位置:{{ x }}, {{ y }}</p>
</template>

xy 是 ref,鼠标一动就更新,模板自动跟着变,事件监听的注册和清理被封装在函数内部,组件不用管。

生命周期钩子为什么能生效

看到 onMountedonBeforeUnmount 可能有个疑问:这些钩子不是只能在组件里用吗,怎么写在普通函数里也能生效?

关键在于调用时机onMounted 这类钩子内部要找”当前正在创建的组件”,把回调登记进组件的钩子队列,什么时候”当前正在创建的组件”存在?在 setup 执行期间。

组合式函数是从 <script setup> 里调用的,<script setup> 本身就是 setup 阶段在跑,所以调用链是这样的:

组件 setup 开始
  → <script setup> 执行
    → useMouse() 执行
      → onMounted(...) 去找"当前组件" → 找到 → 登记成功
  → <script setup> 结束
组件 setup 结束

如果把 useMouse() 挪到按钮点击回调或者 setTimeout 里调用,那时候 setup 早就跑完了,”当前正在创建的组件”已经不存在,钩子就会告警失效。

所以钩子能用,不是因为”组合式函数有特殊能力”,而是因为”在 setup 阶段同步调用了它”。
ref 则没有这个限制,任何时候都能用,两者要分清。

封装数据请求:useFetch

现在回到开头的痛点,有了上面的铺垫,useFetch 的写法就不难理解了:

// src/composables/useFetch.js
import { ref, onBeforeUnmount } from 'vue'

export function useFetch(url) {
  const data = ref([])
  const loading = ref(true)
  const error = ref('')
  const controller = new AbortController()

  const load = async () => {
    loading.value = true
    try {
      const res = await fetch(url, { signal: controller.signal })
      data.value = await res.json()
      error.value = ''
    } catch (e) {
      if (e.name !== 'AbortError') error.value = '加载失败,请稍后重试'
    } finally {
      loading.value = false
    }
  }

  load()
  onBeforeUnmount(() => controller.abort())

  return { data, loading, error, reload: load }
}

逐行看:

  • 三个 ref 分别管数据、加载态、错误信息
  • AbortController 用来在组件卸载时取消请求,signal 传给 fetch,abort() 触发取消
  • load 函数负责发请求、更新三个状态
  • 函数体末尾调用 load() 立刻执行首次请求
  • onBeforeUnmount 登记卸载时取消请求的回调
  • 返回响应式状态和 reload 方法

页面用起来非常简洁:

<script setup>
import { useFetch } from '../composables/useFetch'

const { data: articles, loading, error, reload } = useFetch('/api/articles')
</script>

<template>
  <p v-if="loading">加载中…</p>
  <p v-else-if="error">{{ error }}</p>
  <ul v-else>
    <li v-for="item in articles" :key="item.id">{{ item.title }}</li>
  </ul>
  <button @click="reload">刷新</button>
</template>

加载态、报错态、请求、卸载中止、重新加载,全被 useFetch 收走了,下一个页面还要发请求,一行调用加模板骨架就行,不用再抄请求逻辑。

多处调用 useFetch,会重复请求吗?

会,每次调用都新建独立状态,各发各的请求。

如果需要”全局只拉一次、多处共享”,交给 Pinia(Vue3 状态管理入门)就行。

组合式函数三大规矩

  1. use 开头:约定俗成,见名知意(自动导入插件也按此匹配)。
  2. 只调状态,不碰模板:函数里不出现 DOM,只管返回响应式状态和操作方法,渲染交给组件。
  3. 允许嵌套调用:一个组合式函数内部可以调用另一个,像搭积木。

组合式函数文件一般放 src/composables/(或 src/hooks/),文件名 useXxx.js,导出同名函数。

速查卡片

场景 写法
自定义 Hook export function useXxx() { ... }
命名 必须以 use 开头
返回 返回响应式状态 + 方法(常解构使用)
可用的能力 ref/computed/watch/生命周期钩子 全能调用
典型用途 请求封装、鼠标/键盘监听、计时器、表单逻辑、主题
目录 src/composables/

组合式函数能把逻辑复用搞定,离”写出整洁的大型组件”又近一步。接下来是视觉享受——Vue3 过渡动画TransitionTransitionGroup 一行让界面动起来。

上一篇
Vue3 数据请求
下一篇
Vue3 过渡动画