组合式函数 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(下一阶段)。

现场跑:两个计数器互不影响

同一个 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 }
}

关键点:

组件里一行搞定:

<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 是"逻辑复用",不是"代码分拆"。逻辑没有复用价值就别硬抽。


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 里调。

这条规则带来三个推论:

  1. 必须在 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>
    
  2. 不需要专属命名空间——和普通工具函数共用 src/。命名约定 useXxx 只是给人看的,编译器不识别

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

两者本质区别:

这就是为什么 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 }
}

自动清理的两条路径:

  1. 生命周期钩子 → instance.um → 组件 unmount 时调 callHooks(instance.um)
  2. 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 解决了一堆工程化问题。判断准则:

项目里 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

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

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


延伸阅读