Vue.js: 自定义 Composable

最后更新:2026-10-20

Composable 函数(也叫 hook)是 Vue 3 Composition API 的核心复用模式——把"响应式数据 + 业务逻辑"封装成可复用的函数。本质上是用 ref / computed / watch 等组合而成的"逻辑包"。

掌握 Composable 是写可维护 Vue 应用的关键——它让组件代码更简洁,让业务逻辑跨组件共享。本课帮你从 0 写自己的 Composable 库。

1. 你将学到


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 后:


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

▶ 示例:写一个 useCounter,两个组件各自独立复用

▶ 试一试

输出:

TEXT
并排两张绿边卡片「计数器 A」「计数器 B」,初始都是「10(双倍 20)」。
点 A 的 +1 三次 → A 显示「13(双倍 26)」,B 仍然是「10(双倍 20)」——每次调用 useCounter 都创建一套新状态。
点 A 的「重置」→ 回到 10,B 完全不受影响。

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>

▶ 示例:useMouse + useLocalStorage + useToggle 综合演示

▶ 展开运行示例(67 行)
▶ 试一试

输出:

TEXT
三个面板:
1) useToggle:当前: 开,点「切换」变「当前: 关」,下方鼠标位置面板同时隐藏,再点一次又显示;
2) useMouse:初始 X: 0, Y: 0,移动鼠标后两个数字实时跟随;
3) useLocalStorage:当前 username: (空),输入名字点「保存」后显示该名字,刷新页面仍然保留;点「清除」回到 (空) 并删掉 localStorage 记录。

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

(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. 综合演练:useFetch + useDebounce 搜索防抖

▶ 示例:useFetch + useDebounce(搜索防抖)

▶ 试一试

输出:

TEXT
初始显示「搜索(防抖 500ms)」、占位符「输入查询词...」的输入框、「实际触发次数: 0」、「防抖后搜索词: (空)」。
输入 vue 时每敲一个字符「实际触发次数」+1,敲完显示 3,输入框内容实时可见。
停止输入 500ms 后防抖窗口结束,此时才轮到防抖回调执行,「防抖后搜索词」更新为 vue。

(1) 📊 5 个常见错误速查

错误 现象 解决
不以 use 开头 团队不认 命名 useXxx
返回普通对象 失去响应式 返回 ref
不清理副作用 内存泄漏 在 onUnmounted 清理
过度抽象 简单场景用 Composable 简单逻辑直接放组件
嵌套 5 层 Composable 难调试 拆成小组件或 Pinia

(2) 📊 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 单元测试。

📖 小节


📝 作业

  1. 基础题(难度⭐) 实现 useToggle 函数:

    • 接收初始值(默认 false)
    • 返回 { value, toggle, setTrue, setFalse }
    • 在组件中测试 4 种用法
  2. 进阶题(难度⭐⭐) 实现 useFetch 完整版:

    • 支持 ref 参数(响应式 URL)
    • 支持 refetch 方法
    • 支持 watch 重新获取
    • 错误处理 + loading 状态
    • 在 2 个组件中测试
  3. 挑战题(难度⭐⭐⭐) 实现一个完整的"电商后台 Composable 库":

    1. 5 个 Composable:useCart / useAuth / useSearch / usePagination / useTable
    2. 每个 Composable 至少 50 行实现 + 完整测试
    3. 统一在 composables/index.js 导出
    4. 写 5 个测试用例(用 Vitest)
    5. 对比自己的实现和 VueUse 库的差异
    6. 文档化每个 Composable 的 API
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏