组合式函数(Composables)是 Vue 3 逻辑复用的官方答案。它取代了 Vue 2 时代的 mixins,解决了命名冲突、来源不清、类型推导弱三大顽疾。本文从零封装五个高频 Composable,讲透设计要点与避坑细节。
为什么是 Composables 而不是 Mixins
先看 mixins 的三大原罪:
- 来源不透明:组件里用了一个
this.fetchData(),根本不知道是组件自己的还是某个 mixin 的 - 命名冲突:两个 mixin 定义了同名属性,静默覆盖
- 类型黑洞:TS 无法推导 mixin 注入的内容
Composables 是普通函数,数据来源明确(const { data } = useFetch(url)),返回值命名完全由调用方决定,天然解决以上所有问题:
const { data: orderList, loading } = useFetch('/api/orders')
const { data: userList, loading: userLoading } = useFetch('/api/users')
// 命名冲突?重命名就行Composable 的三条铁律
- 命名以
use开头,这是社区强约定 - 只在 setup 上下文中同步调用,不能在回调、事件处理器里调用(因为内部可能依赖
onMounted等生命周期钩子,它们要求在 setup 同步调用期间注册) - 返回值用普通对象包 ref,而不是数组里混 ref 和普通值,方便调用方解构重命名
实战一:useRequest —— 通用请求管理
这是使用频率最高的 Composable,覆盖请求、竞态、手动触发:
// composables/useRequest.ts
import { ref, shallowRef, onUnmounted, type Ref, unref, watchEffect } from 'vue'
interface UseRequestOptions<T> {
immediate?: boolean // 是否立即执行
debounce?: number // 防抖毫秒数
}
export function useRequest<T>(
url: () => string | Ref<string>,
options: UseRequestOptions<T> = {}
) {
const { immediate = true, debounce } = options
const data = shallowRef<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
let abortController: AbortController | null = null
let timer: ReturnType<typeof setTimeout> | null = null
async function execute() {
// 竞态处理:取消上一次未完成的请求
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch(unref(url), {
signal: abortController.signal
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e: any) {
if (e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
function run() {
if (debounce) {
timer && clearTimeout(timer)
timer = setTimeout(execute, debounce)
} else {
execute()
}
}
// url 是响应式 ref 时,自动重新请求
watchEffect(() => {
unref(url)
if (immediate) run()
})
// 组件卸载时取消进行中的请求,防止内存泄漏
onUnmounted(() => {
abortController?.abort()
timer && clearTimeout(timer)
})
return { data, loading, error, run }
}使用起来非常顺手:
<script setup>
import { computed } from 'vue'
const page = ref(1)
const url = computed(() => `/api/orders?page=${page.value}`)
const { data, loading, run } = useRequest(url)
function nextPage() {
page.value++
// url 变了会自动重新请求,也可以手动 run()
}
</script>几个设计细节值得注意:
shallowRef存 data:大列表数据不必深度响应式,浅层够用且省内存AbortController防竞态:搜索框快速输入时,只有最后一次请求的结果会生效onUnmounted清理:Composable 内注册生命周期钩子,自动绑定到调用它的组件上,这是 Composable 相比普通函数的核心优势
实战二:useMouse —— 事件监听封装
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e: MouseEvent) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}调用方代码极其干净:
<script setup>
const { x, y } = useMouse()
</script>
<template>鼠标位置:{{ x }}, {{ y }}</template>如果希望返回值可以直接当数字用,用 toValue 系列工具或返回 getter:
import { computed } from 'vue'
const { x, y } = useMouse()
const pos = computed(() => `(${x.value}, ${y.value})`)实战三:useLocalStorage —— 持久化响应式状态
// composables/useLocalStorage.ts
import { ref, watch, type WatchCallback } from 'vue'
export function useLocalStorage<T>(key: string, initialValue: T) {
// 初始化:优先读缓存
const stored = localStorage.getItem(key)
const state = ref<T>(
stored ? JSON.parse(stored) : initialValue
) as Ref<T>
// 监听变化自动写入,深度监听覆盖对象/数组
watch(state, (val) => {
try {
localStorage.setItem(key, JSON.stringify(val))
} catch (e) {
console.warn('localStorage 写入失败', e)
}
}, { deep: true })
// 多标签页同步:storage 事件
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key === key && e.newValue) {
state.value = JSON.parse(e.newValue)
}
})
}
function remove() {
localStorage.removeItem(key)
state.value = initialValue
}
return { state, remove }
}const { state: settings } = useLocalStorage('app-settings', {
theme: 'dark',
fontSize: 14
})
// 改 settings.value.theme 自动持久化,刷新页面不丢实战四:useToggle —— 布尔状态切换
export function useToggle(initial = false) {
const state = ref(initial)
const toggle = (val?: boolean) => {
state.value = val ?? !state.value
}
const setTrue = () => toggle(true)
const setFalse = () => toggle(false)
return { state, toggle, setTrue, setFalse }
}看着简单,但弹窗、下拉、折叠面板全用得上,能把散落各处的 showXxx = !showXxx 收编成统一模式。
实战五:useInterval —— 定时器管理
import { ref, onUnmounted } from 'vue'
export function useInterval(cb: () => void, delay = 1000, immediate = true) {
const timer = ref<ReturnType<typeof setInterval> | null>(null)
function start() {
if (timer.value) return
timer.value = setInterval(cb, delay)
}
function stop() {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
}
if (immediate) start()
onUnmounted(stop) // 组件销毁自动清理,杜绝定时器泄漏
return { start, stop }
}倒计时验证码是典型场景:
const seconds = ref(60)
const { start, stop } = useInterval(() => {
if (--seconds.value <= 0) stop()
}, 1000)响应式输入:ref、getter 还是原始值?
设计 Composable 的输入参数时,Vue 3.3+ 提供了统一的 toValue():
import { toValue, type MaybeRefOrGetter } from 'vue'
export function useFoo(source: MaybeRefOrGetter<string>) {
const value = toValue(source) // ref / getter / 原始值通吃
}这样调用方想传什么就传什么,Composable 内部不需要关心:
useFoo('静态字符串')
useFoo(computed(() => dynamicUrl.value))
useFoo(() => props.someProp) // getter,保持响应性常见错误与修正
错误一:返回响应式丢失
// ❌ 返回解构后的值,丢失响应性
return { data: data.value, loading: loading.value }
// ✅ 返回 ref 本身
return { data, loading }错误二:异步函数里调用 Composable
// ❌ setup 已经执行完,onMounted 无法注册
async function setup() {
const { x } = await Promise.resolve().then(() => useMouse())
}
// ✅ 同步调用,内部再异步
const { x } = useMouse()错误三:忘记清理副作用
事件监听、定时器、WebSocket 连接必须在 onUnmounted 中清理。判断标准很简单:你注册了什么,就要注销什么。
总结
- Composables 用函数作用域取代 mixins 的属性合并,来源清晰、类型完整
- 生命周期钩子可以在 Composable 中注册,自动绑定到调用组件——这是它管理副作用的杀手锏
- 数据用
shallowRef、输入用MaybeRefOrGetter+toValue(),是高阶设计模式 - useRequest/useMouse/useLocalStorage 是三个最值得亲手写一遍的练手项目
- VueUse 已经收录了 200+ 高质量 Composables,先读它的源码再动手,事半功倍
把业务里的重复逻辑沉淀成 Composables,是 Vue 项目从"堆页面"走向"有架构"的第一步。
评论 (0)