Vue.js: 自定义 Composable
最后更新:2026-08-26
Composable 函数(也叫 hook)是 Vue 3 Composition API 的核心复用模式——把"响应式数据 + 业务逻辑"封装成可复用的函数。本质上是用 ref / computed / watch 等组合而成的"逻辑包"。
掌握 Composable 是写可维护 Vue 应用的关键——它让组件代码更简洁,让业务逻辑跨组件共享。本课帮你从 0 写自己的 Composable 库。
1. 你将学到
- 什么是 Composable(hook),为什么需要
- Composable 命名规范
useXxx - 5 个常用 Composable 实战(useMouse / useLocalStorage / useFetch / useDebounce / useToggle)
- 接收参数和返回值的最佳实践
- 生命周期钩子的封装
- 跨组件共享 Composable(composables/ 目录)
- VueUse 库(30+ 现成 Composable)
2. 一个购物车"获取 + 防抖"逻辑被复制 5 次
(1) 痛点:5 个搜索框,5 份重复代码
Alice 的后台有 5 个搜索输入框(订单/商品/用户等),每个都有 fetch + 防抖逻辑:
JS
// ❌ 翻车版:5 个组件,5 份重复代码
// OrdersSearch.vue
let timer = null
const searchQuery = ref('')
const results = ref([])
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
const res = await fetch(`/api/orders?q=${val}`)
results.value = await res.json()
}, 300)
})
// ProductsSearch.vue - 完全相同的逻辑(只改 URL)
// UsersSearch.vue - 完全相同的逻辑(只改 URL)
// ... 还有 2 个 ...
合计:50 行 × 5 = 250 行重复代码。修 Bug 要改 5 处。
产品经理 Charlie 追加新需求:
"Alice,把防抖从 300ms 改成 500ms。再加个最小长度检查(3 个字符以上才搜索)。"
Alice 不得不改 5 个文件。容易出错。
(2) Vue Composable 解法:1 个 useSearch,5 处复用
JS
// composables/useSearch.js - 1 个 Composable
const { ref, watch } = Vue
export function useSearch(apiUrl, options = {}) {
const { debounceMs = 300, minLength = 0 } = options
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
let timer = null
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
if (val.length < minLength) {
results.value = []
return
}
timer = setTimeout(async () => {
loading.value = true
const res = await fetch(`${apiUrl}?q=${val}`)
results.value = await res.json()
loading.value = false
}, debounceMs)
})
return { searchQuery, results, loading }
}
VUE
<!-- OrdersSearch.vue - 1 行调用 -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/orders', { debounceMs: 500, minLength: 3 })
</script>
<!-- ProductsSearch.vue - 同样的 1 行(不同 URL) -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/products', { debounceMs: 500, minLength: 3 })
</script>
1 个 useSearch 函数 → 5 处复用。改防抖 1 处生效。
(3) 收益
使用 Composable 后:
- 代码量:250 → 60 行(-76%)
- 改 1 处生效:1 个 Composable 文件
- 新增搜索框:1 行调用
- 可测试:useSearch 可单独单元测试
3. Composable 基础
(1) 命名规范
JS
// ✅ 正确:useXxx 命名
useMouse()
useLocalStorage('key')
useFetch('/api/users')
useDebounce(searchQuery, 500)
useToggle(false)
// ❌ 错误:其他命名
fetchUser() // 不以 use 开头
mouseTracker() // 不以 use 开头
getStorage() // 动词 get/set 不算 hook
(2) 5 大特征
| 特征 | 说明 |
|---|---|
| 以 use 开头 | 行业标准(React 也是 useXxx) |
| 返回响应式数据 | 返回 ref / computed / reactive |
| 可接收参数 | 通常接收基础类型(string/number/object) |
| 可独立使用 | 组件内 useXxx() 即可 |
| 可组合 | 多个 Composable 可嵌套调用 |
(3) 文件组织
TEXT
📖 仅展示
src/
├── components/ # 组件
├── views/ # 页面
├── composables/ # Composable 函数(重点)
│ ├── useMouse.js
│ ├── useLocalStorage.js
│ ├── useFetch.js
│ ├── useDebounce.js
│ ├── useToggle.js
│ └── index.js # 统一导出
├── stores/ # Pinia stores
└── App.vue
4. 5 大实战 Composable
(1) useMouse:跟踪鼠标位置
JS
// composables/useMouse.js
const { ref, onMounted, onUnmounted } = Vue
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
VUE
<!-- MouseTracker.vue -->
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>Mouse: {{ x }}, {{ y }}</p>
</template>
(2) useLocalStorage:localStorage 响应式
JS
// composables/useLocalStorage.js
const { ref, watch } = Vue
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
VUE
<!-- ThemeToggle.vue -->
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const theme = useLocalStorage('theme', 'light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Current: {{ theme }}
</button>
</template>
(3) useFetch:通用数据获取
JS
// composables/useFetch.js
const { ref } = Vue
export function useFetch(url) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
error.value = null
try {
const res = await fetch(url.value || url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// 立即获取
fetchData()
return { data, loading, error, refetch: fetchData }
}
VUE
<!-- UserList.vue -->
<script setup>
import { useFetch } from '@/composables/useFetch'
const { data: users, loading, error, refetch } = useFetch('/api/users')
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refetch">Refresh</button>
</template>
(4) useDebounce:值防抖
JS
// composables/useDebounce.js
const { customRef } = Vue
export function useDebounce(value, delay = 300) {
let timer = null
// 内部维护独立状态,不直接代理原始 ref
let state = value
return customRef((track, trigger) => ({
get() {
track()
return state
},
set(newValue) {
clearTimeout(timer)
state = newValue
timer = setTimeout(() => {
trigger()
}, delay)
}
}))
}
JS
// 使用
const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 500)
watch(debouncedSearch, (val) => {
console.log('Search:', val)
// 500ms 后才执行
})
(5) useToggle:开关切换
JS
// composables/useToggle.js
const { ref } = Vue
export function useToggle(initialValue = false) {
const value = ref(initialValue)
function toggle() {
value.value = !value.value
}
function setTrue() {
value.value = true
}
function setFalse() {
value.value = false
}
return { value, toggle, setTrue, setFalse }
}
VUE
<!-- ModalToggle.vue -->
<script setup>
import { useToggle } from '@/composables/useToggle'
const { value: showModal, setTrue, setFalse } = useToggle(false)
</script>
<template>
<button @click="setTrue">Open Modal</button>
<Modal v-if="showModal" @close="setFalse" />
</template>
5. Composable 最佳实践
(1) 6 大参数设计
JS
// 1. 简单参数
useDebounce(value, 300)
// 2. 配置对象
useFetch(url, { method: 'POST', body: data })
// 3. 引用类型(响应式)
useFetch(ref('/api/users'))
// 4. 函数参数(callback)
useEventListener('click', (e) => console.log(e))
// 5. 多参数
useLocalStorage('key', defaultValue, { mergeDefaults: true })
// 6. 泛型(TypeScript)
useLocalStorage<User>('user', { name: '', age: 0 })
(2) 5 大返回值设计
JS
// 1. 直接返回 ref
export function useCounter() {
const count = ref(0)
return { count }
}
// 2. 返回 ref + 方法
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// 3. 返回命名空间(推荐)
export function useCounter() {
const count = ref(0)
return {
state: { count },
actions: { increment: () => count.value++ }
}
}
// 4. 返回 ref(解构友好)
export function useCounter() {
return ref(0) // 直接返回 ref,外部用 .value
}
// 5. 返回 readonly(防外部修改)
const { readonly } = Vue
export function useCounter() {
const count = ref(0)
return { count: readonly(count) }
}
(3) 6 大生命周期封装
JS
// 1. onMounted + onUnmounted(最常见)
export function useEventListener(event, handler) {
onMounted(() => window.addEventListener(event, handler))
onUnmounted(() => window.removeEventListener(event, handler))
}
// 2. watch 自动清理
export function useWatch(source, callback) {
const stop = watch(source, callback)
onUnmounted(() => stop())
}
// 3. setInterval 自动清理
export function useInterval(fn, delay) {
let timer = null
onMounted(() => { timer = setInterval(fn, delay) })
onUnmounted(() => clearInterval(timer))
}
// 4. setTimeout 自动清理
export function useTimeout(fn, delay) {
let timer = null
onMounted(() => { timer = setTimeout(fn, delay) })
onUnmounted(() => clearTimeout(timer))
}
// 5. 异步任务取消
export function useAsyncTask(task) {
let cancelled = false
onUnmounted(() => { cancelled = true })
return async () => {
if (cancelled) return
await task()
}
}
// 6. 路由跳转清理
import { onBeforeRouteLeave } from 'vue-router'
export function useRouteLeave(callback) {
onBeforeRouteLeave((to, from) => {
if (callback()) return false // 阻止离开
})
}
6. VueUse 库(30+ Composable)
(1) 什么是 VueUse?
VueUse 是 Vue 社区的 Composable 工具库,提供 200+ 现成 Composable(useMouse / useLocalStorage / useDebounce / useEventListener 等),省去自己写的麻烦。
BASH
# 安装
npm install @vueuse/core
JS
// main.js
const { createApp } = Vue
import App from './App.vue'
// VueUse 的 composable 函数按需导入,无需作为插件安装
// 用法:import { useMouse } from '@vueuse/core'
createApp(App).mount('#app')
(2) 5 大常用 VueUse Composable
JS
import { useMouse, useLocalStorage, useDebounceFn, useEventListener, useToggle } from '@vueuse/core'
// 1. 鼠标位置
const { x, y } = useMouse()
// 2. localStorage 响应式
const theme = useLocalStorage('theme', 'light')
// 3. 函数防抖
const debouncedFn = useDebounceFn(() => {
console.log('Debounced!')
}, 500)
// 4. 全局事件监听
useEventListener('resize', () => {
console.log('Window resized')
})
// 5. 开关切换
const { value, toggle } = useToggle()
(3) 5 大使用场景
| 场景 | VueUse Composable |
|---|---|
| 鼠标位置 | useMouse / useMouseInElement |
| 滚动位置 | useScroll / useInfiniteScroll |
| localStorage | useLocalStorage / useStorage |
| 网络状态 | useOnline / useNetwork |
| 媒体查询 | useMediaQuery / useBreakpoints |
| 全屏 | useFullscreen |
| 剪贴板 | useClipboard |
| 鼠标拖拽 | useDraggable |
| 元素大小 | useElementSize / useResizeObserver |
| 防抖节流 | useDebounceFn / useThrottleFn |
7. 完整示例:5 个 Composable 综合
▶ 示例:useMouse + useLocalStorage + useToggle 综合演示
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id="app">
<div style="padding: 1rem; border: 1px solid #ddd; margin: 0.5rem 0; border-radius: 4px;">
<h4>1. useToggle 开关</h4>
<p>当前: {{ showPanel ? '开' : '关' }}</p>
<button @click="showPanel = !showPanel">切换</button>
</div>
<div v-if="showPanel" style="padding: 1rem; border: 1px solid #ddd; margin: 0.5rem 0; border-radius: 4px;">
<h4>2. useMouse 鼠标位置(移动鼠标看)</h4>
<p>X: {{ mouseX }}, Y: {{ mouseY }}</p>
</div>
<div style="padding: 1rem; border: 1px solid #ddd; margin: 0.5rem 0; border-radius: 4px;">
<h4>3. useLocalStorage 持久化(刷新页面保留)</h4>
<p>当前 username: {{ username || '(空)' }}</p>
<input v-model="inputVal" placeholder="输入名字">
<button @click="username = inputVal">保存</button>
<button @click="clearStorage">清除</button>
</div>
</div>
<script>
const { createApp, ref, onMounted, onUnmounted, watch } = Vue
// ✅ Composable:useToggle
function useToggle(initialValue = false) {
const value = ref(initialValue)
function toggle() { value.value = !value.value }
function setTrue() { value.value = true }
function setFalse() { value.value = false }
return { value, toggle, setTrue, setFalse }
}
// ✅ Composable:useMouse
function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.clientX
y.value = e.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
// ✅ Composable:useLocalStorage
function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
const App = {
setup() {
// 1. useToggle
const { value: showPanel, toggle } = useToggle(true)
// 2. useMouse(只在 showPanel 为 true 时挂载)
// 注意:这里直接调用,监听器会跟着 setup 生命周期自动清理
// 3. useLocalStorage
const username = useLocalStorage('demo-username', '')
const inputVal = ref('')
function clearStorage() {
localStorage.removeItem('demo-username')
username.value = ''
inputVal.value = ''
}
// 因为 useMouse 在 setup 中调用,需要在 showPanel 显示时才挂载
// 这里做一个简化:直接在 App 根组件 setup 中调用
const { x: mouseX, y: mouseY } = useMouse()
return {
showPanel, toggle,
username, inputVal, clearStorage,
mouseX, mouseY
}
}
}
createApp(App).mount('#app')
</script>
▶ 示例:useFetch + useDebounce(搜索防抖)
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
.result { padding: 0.5rem; background: #f0f9ff; margin: 4px 0; border-radius: 4px; }
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; width: 200px; }
</style>
<div id="app">
<h4>搜索(防抖 500ms)</h4>
<input v-model="searchInput" placeholder="输入查询词...">
<p>实际触发次数: {{ triggerCount }}</p>
<p>防抖后搜索词: {{ debouncedSearch || '(空)' }}</p>
</div>
<script>
const { createApp, ref, watch, customRef } = Vue
// ✅ useDebounce:customRef 实现
function useDebounce(value, delay = 500) {
let timer = null
let state = value
return customRef((track, trigger) => ({
get() {
track()
return state
},
set(newValue) {
clearTimeout(timer)
state = newValue
timer = setTimeout(() => {
trigger()
}, delay)
}
}))
}
const App = {
setup() {
const searchInput = ref('')
const triggerCount = ref(0)
// 把普通 ref 包成"防抖 ref"
const debouncedRef = useDebounce(searchInput.value, 500)
const debouncedSearch = ref('')
// 监听输入,每次都增加触发次数
watch(searchInput, () => { triggerCount.value++ })
// 防抖 ref 触发时(即延迟结束),更新 debouncedSearch
watch(debouncedRef, (val) => {
debouncedSearch.value = val
})
return { searchInput, triggerCount, debouncedSearch }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:5 个常见错误速查
| 错误 | 现象 | 解决 |
|---|---|---|
| 不以 use 开头 | 团队不认 | 命名 useXxx |
| 返回普通对象 | 失去响应式 | 返回 ref |
| 不清理副作用 | 内存泄漏 | 在 onUnmounted 清理 |
| 过度抽象 | 简单场景用 Composable | 简单逻辑直接放组件 |
| 嵌套 5 层 Composable | 难调试 | 拆成小组件或 Pinia |
▶ 示例:Composable 5 大性能对比
| 模式 | 复用度 | 性能 | 维护性 | 适用 |
|---|---|---|---|---|
| 复制粘贴 | ❌ | ⭐⭐⭐ | ❌ | 1 次性 |
| Mixin(Vue 2) | ⭐⭐ | ⭐⭐ | ⭐⭐ | 老项目 |
| Composable | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 推荐 |
| Pinia | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 大型应用 |
| Event Bus | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | 跨组件通信 |
❓ 常见问题
Q Composable 和 mixin 区别?
A Mixin 是 Vue 2 复用方式(隐式合并、命名冲突)。Composable 是 Vue 3 方式(显式返回、类型友好)。Vue 3 推荐 Composable。
Q Composable 必须在 setup 顶层调用吗?
A 是的。Composable 用到了 ref/watch/lifecycle hooks,必须在
<script setup> 顶层调用。Q Composable 接收 props 吗?
A 不直接接收。但可以接收 ref 引用(响应式)或普通值。VueUse 库大量使用 ref 参数。
Q Composable 之间能互相调用吗?
A 能。Composable 本质是函数,可以嵌套调用。例:
useSearch 内部可以用 useFetch + useDebounce。Q 何时自己写 vs 用 VueUse?
A 业务专属逻辑(如 useSearch / useCart)自己写。通用逻辑(鼠标/滚动/防抖/全屏)用 VueUse,省 80% 时间。
Q Composable 是 React Hook 的复制吗?
A 是的,灵感来自 React Hooks。Vue 3 借鉴并改进:基于 ref(自动依赖收集)+ setup 顶层调用(不用 useEffect 包装)。
Q 测试 Composable 怎么写?
A Composable 是普通 JS 函数,直接调:
const { data } = useFetch('/api'),断言 data.value。用 Vitest 单元测试。📖 小节
- Composable(hook)是用 ref/computed/watch 组合的可复用函数
- 5 大特征:use 开头 / 返回响应式 / 接收参数 / 独立使用 / 可组合
- 5 大实战:useMouse / useLocalStorage / useFetch / useDebounce / useToggle
- 6 大参数设计 + 5 大返回值设计
- 6 大生命周期封装模式(onMounted/onUnmounted)
- VueUse 库提供 200+ Composable
- Composable vs Pinia:小逻辑用 Composable,大状态用 Pinia
📝 作业
-
基础题(难度⭐) 实现 useToggle 函数:
- 接收初始值(默认 false)
- 返回 { value, toggle, setTrue, setFalse }
- 在组件中测试 4 种用法
-
进阶题(难度⭐⭐) 实现 useFetch 完整版:
- 支持 ref 参数(响应式 URL)
- 支持 refetch 方法
- 支持 watch 重新获取
- 错误处理 + loading 状态
- 在 2 个组件中测试
-
挑战题(难度⭐⭐⭐) 实现一个完整的"电商后台 Composable 库":
- 5 个 Composable:useCart / useAuth / useSearch / usePagination / useTable
- 每个 Composable 至少 50 行实现 + 完整测试
- 统一在
composables/index.js导出 - 写 5 个测试用例(用 Vitest)
- 对比自己的实现和 VueUse 库的差异
- 文档化每个 Composable 的 API