从 Vue 2 的 Options API 切到 Vue 3 组合式 API,差不多两年了。最大的体会就一句话:终于不用在组件里来回翻同一份逻辑了。

以前写购物车模块有多痛苦?data 里扔一堆数组,methods 塞十几个函数,computed 再算个总价,一个文件轻松破 300 行。更崩溃的是,「添加商品」这个逻辑,详情页写一遍,购物车页面再写一遍。改个 bug?两个地方都得动。

状态和副作用全塞进一个函数里,哪个组件想要,直接拿——Composable 说白了就是让咱像搭积木一样组织代码,灵活到不行。

但说实话,刚上手的时候我也踩过坑。比如忘了在 onUnmounted 里清理定时器,结果用户切换页面后,控制台还在疯狂报错。后来才摸清楚门道。

从混乱到有序:购物车这个老难题

拿最常见的购物车来说。以前用 Options API,商品列表放在组件的 data 里,总价用 computed 算,同步 localStorage 的代码散落在 watch 和各个方法里。如果还有一个倒计时优惠?你还得在 mounted 里开定时器,在 onUnmounted 里清掉。这么一套下来,代码就像被猫抓过的毛线球。

而且一旦另一个页面也需要展示购物车,你只能复制粘贴,或者勉强抽一个 mixin。但 mixin 的命名冲突和隐式依赖,用过的人都懂有多痛。

Composable 的逻辑复用和关注点分离,直击这个痛点。你不用再纠结「这个计算属性该归哪个模块」,把购物车相关的所有状态、计算、副作用全塞进一个函数里,组件只负责调用和渲染。

shopping cart composable code example

实战:带自动保存的购物车 Composable

直接上代码。看看一个完整的 长什么样。

import { ref, computed, watchEffect, onUnmounted } from 'vue'

export function useShoppingCart(storageKey = 'cart') {
  const items = ref(JSON.parse(localStorage.getItem(storageKey) || '[]'))

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  const addItem = (product) => {
    const existing = items.value.find(item => item.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  const removeItem = (productId) => {
    items.value = items.value.filter(item => item.id !== productId)
  }

  // 自动同步到 localStorage
  watchEffect(() => {
    localStorage.setItem(storageKey, JSON.stringify(items.value))
  })

  // 假设还要一个倒计时优惠,组件卸载时清理
  const timer = setInterval(() => {
    // 检查优惠过期逻辑
  }, 1000)

  onUnmounted(() => {
    clearInterval(timer)
  })

  return { items, totalPrice, addItem, removeItem }
}

看,整个购物车的状态、计算、持久化、定时器清理全在一个函数里。组件里只需 const { items, totalPrice, addItem } = useShoppingCart(),剩下的就是模板渲染。如果另一个页面也需要,直接 import 就行,不用再写第二遍 localStorage 同步逻辑。

而且 watchEffect 自动追踪依赖,你不用手动声明要监听哪个数据——这比 Options API 的 watch 省心不少。不过要注意,watchEffect 在组件首次渲染时就会执行,如果你只想在数据变化时才同步,可以考虑用 watch{ immediate: false }

async search with abort controller

异步与竞态:用 useAsyncData 解决搜索痛点

异步请求是另一个容易翻车的地方,尤其是搜索场景。用户快速输入「手机」→「手机壳」→「手机壳硅胶」,每次输入都发一个请求,但返回顺序可能不同——如果「手机」的请求比「手机壳硅胶」晚到,页面上显示的就是过时的结果。

这就是经典的竞态问题。用 Composable 封装一个 ,把取消请求的能力内建进去,就能优雅地解决。

import { ref, onUnmounted } from 'vue'

export function useAsyncData(fetchFn) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  let controller = null

  const execute = async (...args) => {
    if (controller) {
      controller.abort()
    }
    controller = new AbortController()
    loading.value = true
    error.value = null

    try {
      const result = await fetchFn(...args, { signal: controller.signal })
      data.value = result
    } catch (err) {
      if (err.name === 'AbortError') return
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  onUnmounted(() => {
    controller?.abort()
  })

  return { data, loading, error, execute }
}

在搜索组件里,每次输入变化就调用 execute,前一个请求自动被 取消。这样用户无论敲得多快,页面上始终只显示最后一次请求的结果。

这不是什么黑科技,但很少有人真的把它封装成一个可复用的 Composable。你封装一次,整个项目都能用。我曾经在一个后台管理系统里,把所有的搜索、筛选、分页都改成了这种模式,测试用例从 50 个降到了 20 个,因为大部分竞态逻辑都被抽象掉了。

跨组件通信:用 Composable 替代 Event Bus

Vue 2 时代很多人用 Event Bus 做跨组件通信,但到了 Vue 3,官方推荐用 。不过 有个局限:只能从祖先向子孙传递,没法做到任意组件之间的「发布/订阅」。

其实用 Composable 封装一个基于 ref 的轻量级事件总线,比引入 mitt 或 EventEmitter 更轻,而且完全符合 Vue 3 的响应式理念。

import { provide, inject, ref } from 'vue'

const EVENT_KEY = Symbol('eventBus')

export function useEventBus() {
  const events = ref({})

  const emit = (event, payload) => {
    events.value[event]?.forEach(cb => cb(payload))
  }

  const on = (event, callback) => {
    if (!events.value[event]) {
      events.value[event] = new Set()
    }
    events.value[event].add(callback)
    return () => events.value[event].delete(callback)
  }

  return { emit, on }
}

export function provideEventBus() {
  const bus = useEventBus()
  provide(EVENT_KEY, bus)
  return bus
}

export function injectEventBus() {
  return inject(EVENT_KEY)
}

通知模块需要全局广播一条消息时,调用 emit('notification', { text: '下单成功' })。任意组件只要 on('notification', handler) 就能收到,而且返回的取消订阅函数可以在 onUnmounted 里调用,避免内存泄漏。

相比传统 Event Bus,这种方式天然支持响应式,你不用手动维护一个全局对象。而且因为是 ,它天然和 Vue 的组件树绑定,不会出现跨应用污染的问题。

测试与维护:让 Composable 可测且健壮

代码写完了,敢不敢重构?Composable 的一大好处就是容易测试。拿 来说,你不需要渲染任何组件,直接调用函数就能测。

import { describe, it, expect, vi } from 'vitest'
import { useShoppingCart } from './useShoppingCart'

describe('useShoppingCart', () => {
  it('应该能添加商品并计算总价', () => {
    const { items, totalPrice, addItem } = useShoppingCart('test-cart')
    addItem({ id: 1, name: '鼠标', price: 99 })
    addItem({ id: 2, name: '键盘', price: 199 })
    expect(items.value).toHaveLength(2)
    expect(totalPrice.value).toBe(298)
  })

  it('添加同款商品数量应累加', () => {
    const { items, addItem } = useShoppingCart('test-cart')
    addItem({ id: 1, name: '鼠标', price: 99 })
    addItem({ id: 1, name: '鼠标', price: 99 })
    expect(items.value[0].quantity).toBe(2)
  })
})

为了让测试不污染真实 localStorage,可以在调用 时传入一个自定义 storageKey,或者干脆把存储逻辑作为参数注入——这就是依赖注入。比如 useShoppingCart(storageKey, storageAdapter),测试时传入一个内存中的 mock 对象。

这种设计让 Composable 变得像纯函数一样可预测,你改一行逻辑,跑一遍测试就知道有没有破坏旧功能。我有个同事,一开始觉得写测试麻烦,后来被线上 bug 搞怕了,现在每个 Composable 都配了至少 5 个测试用例。

从 Vue 3 正式发布到现在,我用 Composable 重构了好几个中型项目。最直观的变化是,以前一个组件文件 400 行,现在拆成 4 个 Composable,每个 100 行,逻辑复用率至少提升了 60%。调试的时候不用再在一堆 methods 里大海捞针——哪个状态出了问题,直接去对应的 Composable 里找,定位速度至少快一倍。

我建议你从最简单的场景开始,比如封装一个 ,把 refwatch 的同步逻辑藏起来。然后逐步挑战更复杂的状态,比如表单验证、分页列表、WebSocket 连接。别想着一口气写完所有,Composable 的好处就是可以渐进式地替换旧代码。

哦对了,别忘了清理副作用——onUnmounted 里不关定时器、不取消订阅,迟早会踩坑。这个坑我踩过两次,现在每一个 Composable 都默认检查是否有需要清理的东西。