Composable 函数是 Vue 3 Composition API 的核心实践模式。本文从概念、编写、约束到测试,给出一份可以直接落地的完整说明。
一、是什么
Composable 函数是利用 Composition API 封装和复用有状态逻辑 的普通 JavaScript 函数,约定以 use 开头命名。
它本质上就是一个函数:内部使用 ref、computed、watch、生命周期钩子等 Composition API,向外返回响应式状态和操作方法。
js
// useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}
使用:
vue
<script setup>
import { useCounter } from './useCounter'
const { count, increment, decrement } = useCounter(10)
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</template>
每个组件调用 useCounter 都会得到独立的响应式状态,互不干扰。
二、为什么需要它
Vue 2 时代用 Mixins 复用逻辑,问题很明确:
| 问题 | 说明 |
|---|---|
| 命名冲突 | 多个 Mixin 的同名属性/方法会互相覆盖,且不报错 |
| 来源不透明 | 组件里凭空出现的属性,难以追溯来自哪个 Mixin |
| 逻辑分散 | 一个功能的代码被拆到多个 Mixin 中,改一处要跳多个文件 |
Composable 通过显式导入、显式解构解决了这些问题:逻辑来源一目了然,命名冲突会直接报错,一个功能的内聚在一个文件里。
同时,即使逻辑只在一个组件中使用,抽成 Composable 也能让 <script setup> 变成清晰的"目录",提升可读性。
三、编写规范
1. 始终返回包含 ref 的普通对象
不要返回 reactive 对象。 因为使用方解构 reactive 对象会立刻丢失响应性:
js
// ❌ 错误示范
export function useCounter() {
const state = reactive({ count: 0 })
return state
}
// 使用方:const { count } = useCounter() ------ count 已失去响应性
// ✅ 正确做法
export function useCounter() {
const count = ref(0)
return { count }
}
// 使用方:const { count } = useCounter() ------ count 仍是 ref,响应性保留
如果使用方希望以 .count 方式访问(不写 .value),可在使用方用 reactive() 包裹返回值:
js
const counter = reactive(useCounter(10))
// counter.count 自动解包,仍响应式
注意:对 reactive() 结果再次解构仍会丢失响应性,这是 Vue 响应式系统的基本规则。
2. 调用必须同步
Composable 内部若使用生命周期钩子(onMounted、onUnmounted)或 provide/inject,依赖 Vue 内部的"当前活跃组件实例指针"。该指针只在 setup() 或 <script setup> 顶层同步执行期间有效。
js
// ❌ 错误:await 之后调用,钩子可能静默失效
async function init() {
await someAsyncWork()
const { data } = useFetchData('/api')
}
// ✅ 允许:<script setup> 中的顶层 await
<script setup>
const { data } = await useFetchData('/api')
</script>
在普通 setup() 函数中,await 之后调用 Composable 是不安全的。这类 bug 很难发现------代码"看起来运行了",只是清理逻辑从未执行。
3. 单一职责
一个 Composable 只负责一个逻辑关注点。数据请求、表单校验、权限判断应拆成独立的函数:
js
const { user, loading } = useUserProfile()
const { form, errors, validate } = useProfileForm(user)
const { canEdit } = usePermissions(user)
4. 命名以 use 开头
便于识别,也符合社区约定(useFetch、useMouse、useLocalStorage)。
四、复杂场景示例
js
// useFetchData.js
import { ref, onMounted } from 'vue'
export function useFetchData(apiUrl) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
const fetchData = async () => {
loading.value = true
try {
const res = await fetch(apiUrl)
data.value = await res.json()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
onMounted(fetchData)
return { data, error, loading, fetchData }
}
组件中:
vue
<script setup>
import { useFetchData } from './useFetchData'
const { data, error, loading } = useFetchData('https://api.example.com/data')
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<pre v-else>{{ data }}</pre>
</template>
五、与 React Hooks 的本质区别
| 维度 | Vue Composable | React Hooks |
|---|---|---|
| 执行模型 | setup 只执行一次,后续由响应式系统驱动 |
每次渲染重新执行整个函数体 |
| 调用顺序约束 | 无,可在条件分支中调用(同步路径内) | 有,不能放在条件/循环中 |
| 依赖追踪 | computed/watchEffect 自动追踪 |
手动写依赖数组,漏写导致闭包旧值 |
| 状态匹配方式 | 无隐式匹配 | 依靠调用顺序匹配内部状态 |
但 Vue 并非没有闭包问题------在异步回调中读取非响应式普通变量同样会拿到旧值,只是形态不同。
六、可测试性
Vue 官方将 Composable 分为两类,测试方式不同:
不依赖组件实例的 (只用 ref、computed、watch):直接调用、断言返回值即可。
依赖组件实例的 (用了 onMounted、provide/inject):需要宿主组件包装。官方提供的 helper:
js
export function withSetup(composable) {
let result
const app = createApp({
setup() {
result = composable()
return () => {}
}
})
app.mount(document.createElement('div'))
return [result, app]
}
设计时可以考虑:把请求触发改为由使用方显式调用(返回 fetchData),而非在内部 onMounted 自动执行,这样不依赖组件实例,测试更简单。
七、三条核心要点
- 返回包含 ref 的普通对象 ,不要返回
reactive,否则解构即失去响应性。 - 调用必须同步,异步操作之后的调用可能让生命周期钩子静默失效。
- 测试方式取决于是否依赖组件实例,设计时优先考虑可独立测试的形态。
做到这三点,加上单一职责和 use 命名约定,就能在项目中稳定、可维护地使用 Composable 函数。