从 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 的逻辑复用和关注点分离,直击这个痛点。你不用再纠结「这个计算属性该归哪个模块」,把购物车相关的所有状态、计算、副作用全塞进一个函数里,组件只负责调用和渲染。
实战:带自动保存的购物车 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 }。
异步与竞态:用 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 里找,定位速度至少快一倍。
我建议你从最简单的场景开始,比如封装一个 ,把 ref 和 watch 的同步逻辑藏起来。然后逐步挑战更复杂的状态,比如表单验证、分页列表、WebSocket 连接。别想着一口气写完所有,Composable 的好处就是可以渐进式地替换旧代码。
哦对了,别忘了清理副作用——onUnmounted 里不关定时器、不取消订阅,迟早会踩坑。这个坑我踩过两次,现在每一个 Composable 都默认检查是否有需要清理的东西。
评论