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 返回”活”的状态
问题的根源在于,普通函数返回的是一次性快照,数据拿到手就凝固了,后续变了也没人知道。
把 data 用 ref 包一下就行:
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 开头的普通函数,内部用 ref、computed、watch、生命周期钩子这些组合式 API,返回响应式状态和方法。
ref、computed 这些并不是组合式函数的特权,它们就是 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>
x、y 是 ref,鼠标一动就更新,模板自动跟着变,事件监听的注册和清理被封装在函数内部,组件不用管。
生命周期钩子为什么能生效
看到 onMounted、onBeforeUnmount 可能有个疑问:这些钩子不是只能在组件里用吗,怎么写在普通函数里也能生效?
关键在于调用时机,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 状态管理入门)就行。
组合式函数三大规矩
- 以
use开头:约定俗成,见名知意(自动导入插件也按此匹配)。 - 只调状态,不碰模板:函数里不出现 DOM,只管返回响应式状态和操作方法,渲染交给组件。
- 允许嵌套调用:一个组合式函数内部可以调用另一个,像搭积木。
组合式函数文件一般放 src/composables/(或 src/hooks/),文件名 useXxx.js,导出同名函数。
速查卡片
| 场景 | 写法 |
|---|---|
| 自定义 Hook | export function useXxx() { ... } |
| 命名 | 必须以 use 开头 |
| 返回 | 返回响应式状态 + 方法(常解构使用) |
| 可用的能力 | ref/computed/watch/生命周期钩子 全能调用 |
| 典型用途 | 请求封装、鼠标/键盘监听、计时器、表单逻辑、主题 |
| 目录 | src/composables/ |
组合式函数能把逻辑复用搞定,离”写出整洁的大型组件”又近一步。接下来是视觉享受——Vue3 过渡动画,Transition 与 TransitionGroup 一行让界面动起来。