组合式函数 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(下一阶段)。
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 是"逻辑复用",不是"代码分拆"。逻辑没有复用价值就别硬抽。
小练习:把筛选逻辑抽成 composable
- 新建目录
src/composables/ - 按本笔记的实战 1 写
useNoteFilter.ts - 改造
src/pages/HomePage.vue:把selectedCategory / searchQuery / filteredNotes全换成useNoteFilter(allNotes)返回的版本 - 跑
pnpm build和pnpm dev确认功能没退化 - 想想:如果以后加个"按分类页面"(
/category/前端基础),能不能直接复用这个 composable?
做完这步,你就有了前端的 Service 层思维。