Vue3 组合式 API 最佳实践:我这一年踩过的坑

29 阅读3分钟

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 还难读。我建议遵循以下拆分原则:

  1. 一个业务领域 = 一个组合式函数,函数名以 use 开头,返回最小必要的状态和方法;
  2. 组合式函数之间可以互相调用,比如 useAlarmCenter 内部调用 useAudioPlayer 来播报提示音;
  3. 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 安全得多。

五、总结:我沉淀下来的五条核心经验

  1. 按业务领域拆分组合式函数,而不是按数据类型堆叠——setup 只做组装,这是组合式 API 价值的唯一正确打开方式;
  2. 共享状态用模块级组合式函数,小型项目完全可以替代 Vuex/Pinia;reactive 实例必须在模块顶层创建,函数内创建等于状态丢失;
  3. 基本类型一律 ref,对象优先 reactive + toRefs 解构;不要解构 reactive 原始对象,那是响应性最大的暗坑;
  4. watch 传 getter 而不是对象本身,deep: true 慎用(性能开销大),能用 { flush: 'post' } 解决的 DOM 时序问题不要靠 hack;
  5. provide/inject 用 Symbol + 类型约束 + readonly,只用于强父子场景,别当全局总线滥用——组合式函数 + 模块级状态已经覆盖了 90% 的通信需求。

一年下来,最大的感受是:组合式 API 把"逻辑复用"从口号变成了日常工作方式。它不要求你背 API,而是逼你想清楚每个业务领域应该暴露什么状态、提供什么能力——这种思考方式的转变,比迁移到 Vue3 本身更有价值。希望这篇文章能让你少踩几个我踩过的坑。