Vue.js: 自定义 Composable

最后更新:2026-08-26

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

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>
逻辑代码 67 行(超过 40 行限制,仅展示)

▶ 示例: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>
逻辑代码 45 行(超过 40 行限制,仅展示)

▶ 示例: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 单元测试。

📖 小节


📝 作业

  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%

🙏 帮我们做得更好

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

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