组合式函数 composables:前端的 Service 层

为什么需要 composables

写完几个 Vue 组件后,会发现同样的逻辑总在重复:

问题:在组件里一遍遍写 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 按惯例叫 useXxxuseCounter / 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 }
}

关键点:

组件里一行搞定:

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

好处:


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:

Java 对照:VueUse 之于 Vue,就像 Apache Commons / Hutool 之于 Java——"不要自己造轮子,先翻一下有没有"。

package.json 已经装了 @vueuse/core,直接用。


10. 什么时候不要抽 composable

原则:composable 是"逻辑复用",不是"代码分拆"。逻辑没有复用价值就别硬抽。


小练习:把筛选逻辑抽成 composable

  1. 新建目录 src/composables/
  2. 按本笔记的实战 1 写 useNoteFilter.ts
  3. 改造 src/pages/HomePage.vue:把 selectedCategory / searchQuery / filteredNotes 全换成 useNoteFilter(allNotes) 返回的版本
  4. pnpm buildpnpm dev 确认功能没退化
  5. 想想:如果以后加个"按分类页面"(/category/前端基础),能不能直接复用这个 composable?

做完这步,你就有了前端的 Service 层思维


延伸阅读