Vue3 组合式 API 最佳实践:我这一年踩过的坑
去年我把一个维护了两年的 Vue2 储能监控平台迁到 Vue3,本以为只是把 data 换成 ref、methods 换成普通函数,结果项目组集体翻车:watch 莫名其妙不触发、组件状态像雪球一样越滚越大、同一个逻辑在五个组件里复制粘贴。一年过去,我把自己踩过的坑和最终沉淀下来的写法整理出来,希望能帮你少走弯路。
一、组合式 API 的核心收益:不是写法,是组织方式
很多团队迁移后觉得"组合式 API 只是把代码从 A 处搬到 B 处",那是因为还在用 Options API 的思维写 Composition API。两者的本质区别在于:Options API 按选项类型组织代码,组合式 API 按业务领域组织代码。
举个真实例子,储能大屏里有一个「设备告警」模块,它同时涉及 WebSocket 订阅、定时轮询、告警声音播报和红点角标四部分逻辑。用 Options API 写,这四块逻辑被拆散在 data、computed、watch、methods 四个选项中;而用组合式 API,它们可以收敛成一个自包含的 useAlarmCenter()。
// useAlarmCenter.ts —— 设备告警中心的领域封装
import { ref, onMounted, onUnmounted, watch } from 'vue'
export interface AlarmItem {
id: number
deviceId: string
level: 'info' | 'warning' | 'critical'
message: string
timestamp: number
}
export function useAlarmCenter(deviceIds: () => string[]) {
const alarms = ref<AlarmItem[]>([])
const unreadCount = ref(0)
let ws: WebSocket | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
function playAlert(level: AlarmItem['level']) {
// 不同等级播放不同提示音
}
function pushAlarm(alarm: AlarmItem) {
alarms.value.unshift(alarm)
unreadCount.value++
playAlert(alarm.level)
}
function connectWs() {
ws = new WebSocket(`wss://monitor.energy.dev/alarms`)
ws.onmessage = (e) => {
const alarm: AlarmItem = JSON.parse(e.data)
pushAlarm(alarm)
}
}
function startPolling() {
pollTimer = setInterval(async () => {
const list = await fetch(`/api/alarms?deviceIds=${deviceIds().join(',')}`).then(r => r.json())
alarms.value = list
}, 30000)
}
onMounted(() => {
connectWs()
startPolling()
})
onUnmounted(() => {
ws?.close()
if (pollTimer) clearInterval(pollTimer)
})
// 监听设备列表变化,设备变化时自动刷新
watch(deviceIds, startPolling)
return { alarms, unreadCount, markAllRead: () => (unreadCount.value = 0) }
}
组件里只需要一行:const { alarms, unreadCount, markAllRead } = useAlarmCenter(() => props.deviceIds)。这个函数可以被任意组件复用,也可以在多个页面共享同一份告警状态——逻辑复用第一次变得如此自然。
二、setup 的正确打开方式:从"大杂烩"到"领域拆分"
这是新手最容易踩的坑:把 setup 当成了 data + methods 的合并区,几十个 ref 和函数堆在一起,比 Options API 还难读。我建议遵循以下拆分原则:
- 一个业务领域 = 一个组合式函数,函数名以
use开头,返回最小必要的状态和方法; - 组合式函数之间可以互相调用,比如
useAlarmCenter内部调用useAudioPlayer来播报提示音; setup里只做"组装",不写业务细节,保证一眼能看懂组件由哪些模块构成。
再看一个状态提升的实战。电网调峰场景下,父页面和子组件需要共享「当前选中设备」和「实时功率」两个状态,以前要么用事件层层冒泡,要么直接上 Vuex。现在用一个模块级组合式函数就能优雅解决:
// stores/useDeviceStore.ts —— 模块级共享状态(替代小型 Vuex)
import { reactive, readonly } from 'vue'
interface DeviceState {
selectedDeviceId: string
powerMap: Record<string, number> // deviceId -> 实时功率 kW
}
// 模块顶层创建,多个组件共享同一实例
const state = reactive<DeviceState>({
selectedDeviceId: '',
powerMap: {},
})
export function useDeviceStore() {
function selectDevice(id: string) {
state.selectedDeviceId = id
}
function updatePower(deviceId: string, powerKw: number) {
state.powerMap[deviceId] = powerKw
}
return {
state: readonly(state),
selectDevice,
updatePower,
}
}
⚠️ 关键点:
reactive必须在模块顶层创建一次。如果放在函数内部每次调用都新建,状态就"断片"了,这是共享状态最常见的 bug。
三、响应式陷阱:ref 与 reactive 的边界
迁移过程中,我们项目 80% 的线上 bug 都出在响应式转换上。三个高频坑,每个都有血泪教训:
坑 1:用 reactive 包裹基本类型。 reactive('abc') 不会报错,但返回的 Proxy 根本不会触发响应,数据更新了视图纹丝不动。规则很简单:基本类型用 ref,对象用 reactive,或者干脆全部用 ref(Vue3.2 之后 ref 对对象也会自动深响应,团队内统一一种心智模型更省心)。
坑 2:解构 reactive 丢响应性。 这个坑隐蔽性极高,const { powerMap } = reactive(...) 之后修改 powerMap 页面不更新。因为解构拿到的是原始对象的浅拷贝,Proxy 失效。解决方式是 toRefs:
import { reactive, toRefs } from 'vue'
const deviceState = reactive({ current: 0, max: 1000 })
// 错误:const { current } = deviceState —— current 失去响应性
// 正确:解构时保持响应性
const { current, max } = toRefs(deviceState)
// 模板里 current 是 ref,自动解包,直接用
console.log(current.value) // 脚本里要 .value
坑 3:watch 的"监听对象"是对象本身。 很多同事写 watch(state, handler) 期望监听 state.count,结果只有引用变化才触发。正确写法是传 getter:watch(() => state.count, handler)。这条规则在官方文档里是黑体加粗的存在,但几乎每批新人都会踩一次。
四、Provide/Inject:跨层通信的正确姿势
provide/inject 在 Vue3 里变得成熟,但我们一开始用错了场景:拿它当全局总线,到处 inject('xxx'),结果代码可读性比 Vue2 的 $bus 还差。我的经验是只在"祖先组件提供、后代组件消费"的强父子场景使用,并且必须用 Symbol 作为 key + 提供类型定义,避免魔法字符串:
// symbols.ts
export const INJECT_KEY = {
DashboardContext: Symbol('DashboardContext'),
} as const
// 父组件:大屏根容器
import { provide, ref } from 'vue'
import { INJECT_KEY } from './symbols'
export interface DashboardContext {
theme: 'light' | 'dark'
refreshInterval: number
refresh: () => Promise<void>
}
export default {
setup() {
const theme = ref<'light' | 'dark'>('dark')
const refreshInterval = ref(30)
provide<DashboardContext>(INJECT_KEY.DashboardContext, {
theme,
refreshInterval,
refresh: async () => { /* 拉取最新数据 */ },
})
// ...
},
}
// 孙组件:任意深度的面板卡片
import { inject } from 'vue'
import { INJECT_KEY } from '../symbols'
export default {
setup() {
// 类型安全,写错字段名立刻编译报错
const ctx = inject(INJECT_KEY.DashboardContext)
if (!ctx) throw new Error('DashboardContext 必须在根组件 provide')
return { ctx }
},
}
配合 readonly 约束注入值,后代组件只能调用 refresh 等公开方法,无法随意篡改祖先状态,这比 $bus 安全得多。
五、总结:我沉淀下来的五条核心经验
- 按业务领域拆分组合式函数,而不是按数据类型堆叠——
setup只做组装,这是组合式 API 价值的唯一正确打开方式; - 共享状态用模块级组合式函数,小型项目完全可以替代 Vuex/Pinia;
reactive实例必须在模块顶层创建,函数内创建等于状态丢失; - 基本类型一律
ref,对象优先reactive+toRefs解构;不要解构reactive原始对象,那是响应性最大的暗坑; watch传 getter 而不是对象本身,deep: true慎用(性能开销大),能用{ flush: 'post' }解决的 DOM 时序问题不要靠 hack;provide/inject用 Symbol + 类型约束 +readonly,只用于强父子场景,别当全局总线滥用——组合式函数 + 模块级状态已经覆盖了 90% 的通信需求。
一年下来,最大的感受是:组合式 API 把"逻辑复用"从口号变成了日常工作方式。它不要求你背 API,而是逼你想清楚每个业务领域应该暴露什么状态、提供什么能力——这种思考方式的转变,比迁移到 Vue3 本身更有价值。希望这篇文章能让你少踩几个我踩过的坑。