组合式函数 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 按惯例叫 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 是"逻辑复用",不是"代码分拆"。逻辑没有复用价值就别硬抽。


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 顶层同步调用——不能在 ifonMounted 回调、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 的 effectinstance.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 buildpnpm dev 确认功能没退化
  5. 想想:如果以后加个"按分类页面"(/category/前端基础),能不能直接复用这个 composable?

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


延伸阅读