组合式函数 composables:前端的 Service 层
为什么需要 composables
写完几个 Vue 组件后,会发现同样的逻辑总在重复:
- 首页有"防抖搜索",设置页也有"防抖输入"
- 笔记详情页需要"键盘快捷键监听",评论编辑器也需要
- 两个不同的页面都要"从 localStorage 读配置 + 响应式更新"
问题:在组件里一遍遍写 ref / watch / onMounted / onUnmounted,改一次要翻 N 个文件。
Java 对照:这和 Spring 里把重复逻辑抽到 Service 层是一回事。
// 各 Controller 里都要做"查用户 + 检查权限"
// 把它抽到 UserService,Controller 只调 userService.checkAndGet(id)
composables 就是 Vue 的 Service 层——把"有状态 + 有响应式 + 有生命周期"的逻辑抽成一个函数,任何组件都能用。
1. 一个最简单的 composable
需求:多个组件都要用"计数器"。
原始写法(每个组件里重复)
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() { count.value++ }
function reset() { count.value = 0 }
</script>
抽成 composable
// src/composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => (count.value = initial)
return { count, increment, decrement, reset }
}
组件里用:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, reset } = useCounter(10)
</script>
<template>
<p>{{ count }}</p>
<button @click="increment">+1</button>
<button @click="reset">重置</button>
</template>
Java 对照:
@Service
public class CounterService {
private int count = 0;
public int increment() { return ++count; }
public void reset() { this.count = 0; }
}
不同的是:每次调用 useCounter() 得到的是独立的 state,不像 @Service 是全局单例。想共享状态得用 Pinia(下一阶段)。
现场跑:两个计数器互不影响
同一个 useCounter,两次调用得到两份独立状态:
<script setup>
import { ref } from 'vue'
function useCounter(initial = 0) {
const count = ref(initial)
return {
count,
inc: () => count.value++,
reset: () => (count.value = initial),
}
}
const a = useCounter(0)
const b = useCounter(100)
</script>
<template>
<div class="box">
<p>A:{{ a.count }} <button @click="a.inc">+1</button> <button @click="a.reset">重置</button></p>
<p>B:{{ b.count }} <button @click="b.inc">+1</button> <button @click="b.reset">重置</button></p>
<p class="tip">两份状态互不影响 —— composable 是工厂函数,不是单例</p>
</div>
</template>
<style scoped>
.box { padding: 20px; font-family: system-ui; line-height: 2; }
button { margin: 0 4px; padding: 2px 12px; }
.tip { color: #64748b; font-size: 13px; }
</style>
2. 命名约定:以 use 开头
所有 composable 按惯例叫 useXxx:useCounter / useMouse / useFetch / useDebounce。这是 Vue 社区的强约定,违反了 IDE 和 ESLint 也不会推断你是 composable。
Java 对照:像 Spring 的 *Service / *Repository 后缀,是团队间的沟通协议。
3. composable 里能用什么
任何响应式 API 和生命周期钩子都能用,和组件里写没区别:
import { ref, computed, watch, 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 }
}
关键点:
onMounted写在 composable 里,调用它的组件挂载时自动执行onUnmounted同理,组件卸载时自动清理- 不需要调用方写任何生命周期代码
组件里一行搞定:
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>鼠标位置: {{ x }}, {{ y }}</p>
</template>
这就是 composable 的魔法——把生命周期管理封装进去,调用方零心智负担。
4. 实战 1:给首页抽个 useNoteFilter
当前 src/pages/HomePage.vue 里的筛选逻辑:
const selectedCategory = ref<string>('全部')
const searchQuery = ref<string>('')
const filteredNotes = computed(() => { /* 分类 + 关键字过滤 */ })
const isSearching = computed(() => searchQuery.value.trim().length > 0)
抽成 composable:
// src/composables/useNoteFilter.ts
import { ref, computed } from 'vue'
import type { NoteListItem } from '@/types/note'
export function useNoteFilter(allNotes: NoteListItem[]) {
const selectedCategory = ref<string>('全部')
const searchQuery = ref<string>('')
const filteredNotes = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return allNotes.filter((n) => {
if (selectedCategory.value !== '全部' && n.category !== selectedCategory.value) {
return false
}
if (!q) return true
return (
n.title.toLowerCase().includes(q) ||
n.summary.toLowerCase().includes(q) ||
n.category.toLowerCase().includes(q) ||
n.tags.some((t) => t.toLowerCase().includes(q))
)
})
})
const isSearching = computed(() => searchQuery.value.trim().length > 0)
function clearSearch() {
searchQuery.value = ''
}
return {
selectedCategory,
searchQuery,
filteredNotes,
isSearching,
clearSearch,
}
}
页面变得超级干净:
<script setup>
import { useNoteFilter } from '@/composables/useNoteFilter'
import { getNoteList } from '@/utils/notes'
const allNotes = getNoteList()
const { selectedCategory, searchQuery, filteredNotes, isSearching, clearSearch } =
useNoteFilter(allNotes)
</script>
好处:
- 测试方便:直接单元测
useNoteFilter(mockData),不用挂载组件 - 复用:以后"分类页"也能用同一个筛选逻辑
- 页面组件只管 UI,不管业务
5. 实战 2:useLocalStorage——响应式的 localStorage
持久化偏好设置:
// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'
export function useLocalStorage<T>(key: string, initial: T): Ref<T> {
const raw = localStorage.getItem(key)
const data = ref<T>(raw !== null ? JSON.parse(raw) : initial) as Ref<T>
watch(
data,
(v) => localStorage.setItem(key, JSON.stringify(v)),
{ deep: true }
)
return data
}
用法:
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
// 切换主题:改一次 theme.value,自动写入 localStorage
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
切换为 {{ theme === 'light' ? '暗' : '亮' }}色
</button>
</template>
注意:VueUse 已经提供了现成的 useLocalStorage(功能更完整)。实际项目里直接用 VueUse,这里只是练手。
6. 返回什么:ref 还是 reactive?
惯例:返回 ref 和函数,不返回 reactive。
// ✅ 推荐
export function useCounter() {
const count = ref(0)
return { count, increment: () => count.value++ }
}
// 调用方可以解构后继续保持响应式
const { count, increment } = useCounter()
如果返回 reactive 对象:
// ❌ 解构后失去响应式
export function useCounter() {
return reactive({ count: 0, increment() { this.count++ } })
}
const { count } = useCounter() // count 是普通 number,改了页面不动
这也是为什么前面笔记里强调项目里优先用 ref——composables 返回 ref,调用方才能放心解构。
7. 多个 composable 组合
composable 可以调用其他 composable,像 Spring 里 Service 调 Service:
// useDebouncedSearch.ts
import { ref, watch } from 'vue'
import { useNoteFilter } from './useNoteFilter'
export function useDebouncedSearch(notes: NoteListItem[], delay = 300) {
const filter = useNoteFilter(notes)
const immediateQuery = ref('')
let timer: number
watch(immediateQuery, (v) => {
clearTimeout(timer)
timer = window.setTimeout(() => {
filter.searchQuery.value = v
}, delay)
})
return {
...filter,
immediateQuery, // 模板绑到 v-model 上的立即值
}
}
组件只需要知道"我有个带防抖的搜索",不用关心内部是怎么组合的。
8. 为什么 Vue 3 推 Composition API(而非 Options API)
Vue 2 的 Options API:
export default {
data() { return { count: 0, user: null } },
computed: { doubleCount() { return this.count * 2 } },
watch: { count(v) { /* ... */ } },
methods: { increment() { this.count++ } },
mounted() { /* ... */ },
}
问题:一个功能(比如搜索)的相关代码散落在 data / computed / watch / methods / mounted 五个地方。组件一大,要来回翻。
Composition API + composable 把同一个功能的代码收到一起:
<script setup>
const { searchQuery, filteredNotes } = useNoteFilter(notes) // 搜索相关
const { theme, toggleTheme } = useTheme() // 主题相关
const { mousePos } = useMouse() // 鼠标相关
</script>
每个 composable 都是按功能组织的封闭小模块。改一个功能,打开它的文件,改完就走。
Java 对照:Options API 像把一个类的所有方法按"字段/方法/构造/生命周期"分区写(不关心功能关联);Composition API 像按"业务职责"拆 Service,每个 Service 管一件事。
9. 用 VueUse 省去大半重复工作
VueUse 是 Vue 官方推的 composables 工具库,提供了 200+ 现成的 composable:
useLocalStorage/useSessionStorage:响应式持久化useMouse/useMousePressed/useScroll:交互状态useDebounceFn/useThrottleFn/useDebouncedRef:防抖节流useDark/useColorMode:主题切换useClipboard:剪贴板useEventListener:自动清理的事件监听
Java 对照:VueUse 之于 Vue,就像 Apache Commons / Hutool 之于 Java——"不要自己造轮子,先翻一下有没有"。
package.json 已经装了 @vueuse/core,直接用。
10. 什么时候不要抽 composable
- 一次性逻辑:只一个组件用到,且 20 行以内——留在组件里更清楚
- 纯展示:没有响应式、没有生命周期的工具函数——放
src/utils/就够了 - 强耦合 UI:逻辑里直接操作 DOM ID、依赖组件特定结构——抽了反而混乱
原则:composable 是"逻辑复用",不是"代码分拆"。逻辑没有复用价值就别硬抽。
11. 原理深挖:composable 不是魔法,只是"setup 同步代码的延伸"
很多人把 composable 当成 Vue 的特性,其实它没有任何专属语法——就是一个普通函数。它能用 onMounted、能用 watch、解构后还响应式,全是因为它在 setup 同步路径里被调用。
// composable
function useMouse() {
const x = ref(0)
onMounted(() => window.addEventListener('mousemove', ...))
onUnmounted(() => window.removeEventListener('mousemove', ...))
return { x }
}
// 等价于把这段代码"原地展开"到 setup 里
<script setup>
const x = ref(0)
onMounted(() => window.addEventListener('mousemove', ...))
onUnmounted(() => window.removeEventListener('mousemove', ...))
</script>
编译/运行机制上根本没区分——useMouse() 调用时 currentInstance 还是组件本身,所以里面调 onMounted 等价于直接在 setup 里调。
这条规则带来三个推论:
-
必须在 setup 顶层同步调用——不能在
if、onMounted回调、await之后调,因为那时 currentInstance 已被清空<script setup> const { x } = useMouse() // ✅ if (condition) useMouse() // ✅(同步条件分支也行) onMounted(() => { const { x } = useMouse() // ❌ 此时 currentInstance 已变 }) await something() const { x } = useMouse() // ❌ await 后 currentInstance 没了 </script> -
不需要专属命名空间——和普通工具函数共用
src/。命名约定useXxx只是给人看的,编译器不识别 -
composable 嵌套 composable 完全没问题——只要外层在 setup 里调,内层调用链都在 setup 同步路径上
Java 对照:composable 不是 Spring @Service 那种容器管理的对象——它更像 Kotlin 的扩展函数 + 协程的 suspend 函数:必须在特定上下文(CoroutineScope / setup)里调用。
12. 原理深挖:为什么解构后还能保持响应式
解构 reactive 对象会丢响应式,解构 composable 返回的对象却不会——这是因为 composable 返回的是ref 容器,不是值:
function useCounter() {
const count = ref(0) // count 本身是 RefImpl 实例
return { count }
}
const { count } = useCounter()
// 解构出来的 count 还是同一个 RefImpl 引用
// 后续 count.value 还是经过 RefImpl 的 getter/setter
count.value = 5 // 触发 RefImpl.set value → 通知所有依赖
对比 reactive:
const state = reactive({ count: 0 })
const { count } = state
// state.count 通过 Proxy.get 取出 0(基本类型)
// count 现在是普通 number 0,和原 state 没关系了
count = 5 // 普通赋值,不触发 trigger
两者本质区别:
- ref:把响应式装在对象里(RefImpl.value),传引用就传响应式
- reactive:把响应式装在Proxy 层,把内部值取出来就脱离了响应式
这就是为什么 Vue 3 鼓励"composable 返回 ref" 而不是 reactive——可解构、能传递、不丢响应式。
toRefs 的存在就是为了在必须用 reactive 时(比如和老代码兼容)也能保持解构后的响应式:
const state = reactive({ count: 0, name: 'foo' })
const refs = toRefs(state) // { count: Ref, name: Ref }
const { count, name } = refs // 现在解构是 ref,OK
count.value++ // 触发 state.count 的依赖
toRefs 内部就是给每个字段做了 toRef(state, 'count')——返回一个特殊 ref,每次访问 .value 都重新去 state 上读。
13. 原理深挖:composable 里的 effect 自动清理 = 借组件 effectScope
第 04-lifecycle 笔记讲过 effectScope。这里把 composable 的视角加进来:
function useMouse() {
const x = ref(0)
const update = e => { x.value = e.pageX }
// 这个 watch 注册的 effect 自动登记到当前 effectScope
watch(x, () => console.log('moved to', x.value))
// onUnmounted 注册到当前 instance.um
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x }
}
自动清理的两条路径:
- 生命周期钩子 →
instance.um→ 组件 unmount 时调callHooks(instance.um) - watch / watchEffect / computed 的 effect →
instance.scope.effects→ 组件 unmount 时调instance.scope.stop()
所以:composable 里只要用的是 Vue 的 API(watch、onMounted),都自动跟随调用方组件的生命周期。
不会自动清理的反例:
function useTimer() {
setInterval(() => console.log('tick'), 1000) // ❌ 原生 setInterval 不知道组件
}
要么手动包:
function useTimer() {
let id
onMounted(() => { id = setInterval(...) })
onUnmounted(() => clearInterval(id))
}
要么直接用 VueUse 的 useIntervalFn——它内部就是这么实现的。
判断准则:composable 内部用的所有"会持续占资源"的东西(事件监听、定时器、连接、订阅),都要找一个对应的 onUnmounted 兜底。
14. 原理深挖:手动 effectScope 让 composable 跨出组件
有时候想在组件之外用 composable——比如在 Pinia store 里、在路由守卫里、在工具模块顶层。这时 currentInstance 是 null,onMounted 会无声失败。
手动创建 scope 兜底:
import { effectScope } from 'vue'
const scope = effectScope(true) // true = detached(不绑父 scope)
const result = scope.run(() => {
// 这里跑的代码 watch / computed / watchEffect 会登记到 scope
return useMouse()
})
// 不用了主动停
scope.stop()
Pinia 内部就是这么用的——defineStore 创建一个 scope 跑 setup 函数,store 销毁时 scope.stop()。这就是为什么 Pinia store 里能用 watch / computed,且能正常清理。
真实场景:写一个全局的"用户偏好同步"逻辑,不绑任何组件:
// src/utils/preferenceSync.ts
import { effectScope, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
const scope = effectScope(true)
scope.run(() => {
const theme = useLocalStorage('theme', 'light')
watch(theme, (v) => {
document.documentElement.setAttribute('data-theme', v)
}, { immediate: true })
})
// 模块永远活着,不调 stop
Java 对照:scope 像 Spring 的 ApplicationContext,能装一组带生命周期的"对象"(这里是 effect),统一销毁。
15. 原理深挖:异步 composable 的陷阱(async 之后丢 instance)
// ❌ 看似合理,实际上 onMounted 不生效
async function useAsyncData(url) {
const data = ref(null)
data.value = await fetch(url).then(r => r.json()) // ★ 这一行 await 之后
onMounted(() => console.log('mounted')) // ❌ currentInstance 已经是 null
return { data }
}
为什么——await 让出执行权后,setup 流程已结束,currentInstance 被清掉。await 后跑的代码只是 setup 的"延续",但对 Vue 来说 setup 已经返回了。
两种修复方式:
// 方式 1:所有 onXxx 钩子放 await 之前
async function useAsyncData(url) {
const data = ref(null)
onMounted(() => console.log('mounted')) // ✅ 先注册
data.value = await fetch(url).then(r => r.json())
return { data }
}
// 方式 2:不用 onMounted,直接同步初始化
function useAsyncData(url) {
const data = ref(null)
fetch(url).then(r => r.json()).then(d => data.value = d) // ★ 不 await,let it ride
return { data }
}
// 方式 3:用 Suspense + setup 顶层 await(下一篇笔记)
这个坑非常隐蔽——开发模式下 Vue 会警告,但生产模式下静默失败。记住规则:composable 里的 onMounted / inject / getCurrentInstance 都必须在所有 await 之前调。
16. 原理深挖:composable vs Pinia store 的真正区别
第 9 节说 VueUse 的 useDark 是 composable,但又有"全局共享"的特点——这跟 Pinia 不是一回事吗?
仔细看 VueUse 实现:
// 简化版
const sharedTheme = ref('light') // ★ 模块级别变量,所有调用方共享
export function useDark() {
// 不创建新的 ref,直接用模块级别的
return sharedTheme
}
诶——composable 想全局共享,只需要把 ref 提到模块顶层。这一招 Pinia 也能做到吗?
// 用 composable 模拟 store
const userState = ref(null)
export function useUser() {
return userState
}
// 任何组件都能拿到同一份 userState
那为什么还要 Pinia?区别在三个工程化层面:
| 维度 | 模块级 ref(手搓 store) | Pinia |
|---|---|---|
| DevTools | 看不到 | 专属面板 + time travel |
| HMR | 改文件后状态丢 | 保留状态 |
| SSR | 多请求共享同一个状态(数据污染) | 每请求独立实例 |
| 命名空间 | 自己管 | defineStore 自动 |
| 持久化 | 自己写 | 插件一行 |
| TypeScript | 弱 | 强(store id 联想等) |
所以技术上模块级 ref 等价于 store,但 Pinia 解决了一堆工程化问题。判断准则:
- 这个状态需要在 DevTools 里调试 → Pinia
- 这个状态在 SSR 下要按请求隔离 → Pinia
- 单纯几个组件想共享个 boolean → 模块级 ref 就行
项目里 in4vue 的"主题切换"用 useDark 即可——单一 ref + 写 localStorage,不值得 Pinia。
17. 原理深挖:composable 单元测试不需要挂载组件
composable 是普通函数,但用了响应式 API + 生命周期——怎么测?
最简单的写法(用 Vitest):
import { useCounter } from './useCounter'
import { describe, expect, it } from 'vitest'
describe('useCounter', () => {
it('inc 让 count 加 1', () => {
const { count, increment } = useCounter(10)
expect(count.value).toBe(10)
increment()
expect(count.value).toBe(11)
})
})
只要 composable 没用 onMounted、inject 这种"必须有 currentInstance"的 API,直接调就能测——ref / computed / watch 在没有 instance 时也能工作。
碰到必须挂载的(useMouse 里的 onMounted):
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
function withSetup(composable) {
let result
const Comp = defineComponent({
setup() {
result = composable()
return () => h('div')
}
})
const wrapper = mount(Comp)
return { result, wrapper }
}
it('useMouse 监听 mousemove', () => {
const { result, wrapper } = withSetup(() => useMouse())
window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 200 }))
expect(result.x.value).toBe(100)
wrapper.unmount() // 触发 onUnmounted 清理
})
Java 对照:纯函数 composable 像 POJO Service 的方法,直接 new 测;带生命周期的 composable 像 @Component,需要 Spring 容器(这里是 Vue 组件)启动后才能完整测。
小练习:把筛选逻辑抽成 composable
- 新建目录
src/composables/ - 按本笔记的实战 1 写
useNoteFilter.ts - 改造
src/pages/HomePage.vue:把selectedCategory / searchQuery / filteredNotes全换成useNoteFilter(allNotes)返回的版本 - 跑
pnpm build和pnpm dev确认功能没退化 - 想想:如果以后加个"按分类页面"(
/category/前端基础),能不能直接复用这个 composable?
做完这步,你就有了前端的 Service 层思维。