Vue3 实战避坑:10 个 Composition API 高频错误及修复方案
1) reactive 解构导致失去响应性
- 现象:对
reactive 返回的对象做 ES 解构后,解构出来的变量不再响应更新。
- 原因:解构会拿到原始值的快照,脱离代理。
- 修复:使用
toRefs/toRef 保持引用与代理,或避免直接解构。
ts
复制代码
import { reactive, toRefs, toRef } from 'vue'
const state = reactive({ count: 0, user: { name: 'A' } })
// 错误
const { count } = state
// 正确
const { count: countRef } = toRefs(state)
const nameRef = toRef(state.user, 'name')
2) reactive 顶层替换失效 vs 用 ref 持有整体
- 现象:
reactive 对象整体重新赋值不生效。
- 原因:
reactive 返回的是固定代理,替换变量仅更改引用。
- 修复:整体替换用
ref 持有对象并改 .value;需要局部更新用 Object.assign。
ts
复制代码
import { reactive, ref } from 'vue'
const state = reactive({ list: [1] })
// 错误:不会触发
// state = { list: [2] }
// 正确:
Object.assign(state, { list: [2] })
// 或整体替换:
const holder = ref({ list: [1] })
holder.value = { list: [2] }
3) ref 对象的使用误区(直接改 .value vs 深层属性)
- 现象:
ref({}) 后对 .value 直接替换有效,但对深层属性忘记加 .value。
- 修复:对象整体用
.value = 新对象;深层属性通过 .value.xxx = 修改。
ts
复制代码
import { ref } from 'vue'
const profile = ref({ name: 'A', age: 18 })
profile.value.age = 19
profile.value = { ...profile.value, name: 'B' }
4) watch 源写法错误(未指定 getter 导致性能或触发异常)
- 现象:
watch(state, ...) 监听到大量无关变更或无法精准到字段更新。
- 修复:监听具体 getter 或
toRef,避免无谓触发;需要深度时设置 { deep: true }。
ts
复制代码
import { reactive, toRef, watch } from 'vue'
const state = reactive({ query: '', items: [] })
watch(() => state.query, (nv, ov) => {/*...*/})
watch(toRef(state, 'items'), (nv) => {/*...*/}, { deep: true })
5) watchEffect 误用副作用(无法控制依赖与时机)
- 现象:在
watchEffect 中做网络请求或写入副作用,导致频繁触发。
- 修复:副作用用
watch + 明确源;watchEffect 适合派生计算与轻量任务。
ts
复制代码
import { ref, watch, watchEffect } from 'vue'
const id = ref('1')
watch(id, async (nv) => {/* 请求 */}, { flush: 'post' })
watchEffect(() => {/* 轻量派生 */})
6) computed 执行副作用或异步
- 现象:在
computed 中发请求或修改其他状态,导致不可预测。
- 修复:
computed 必须纯函数且仅依赖响应源;副作用转移到 watch。
ts
复制代码
import { computed, ref, watch } from 'vue'
const a = ref(1), b = ref(2)
const sum = computed(() => a.value + b.value)
watch(sum, (v) => {/* 根据值变化触发副作用 */})
7) 更新时机错位(flush 与 nextTick)
- 现象:
watch 中读取 DOM 未更新,或需要在 DOM 更新后执行逻辑。
- 修复:使用
{ flush: 'post' } 或 await nextTick()。
ts
复制代码
import { ref, watch, nextTick } from 'vue'
const show = ref(false)
watch(show, async () => { await nextTick(); /* 此时 DOM 已更新 */ }, { flush: 'post' })
8) v-model 子组件协议不一致
- 现象:父组件使用
v-model 无效或无法双向绑定。
- 原因:未遵循
modelValue 与 update:modelValue 事件约定。
- 修复:子组件
defineProps/defineEmits 对齐协议。
vue
复制代码
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
function onInput(v: string) { emit('update:modelValue', v) }
</script>
9) provide/inject 失去响应性
- 现象:注入后值不随提供者更新;或注入时解构导致丢响应。
- 修复:提供
ref/reactive,注入后使用原引用或 toRef;避免解构原始值。
ts
复制代码
// provider
import { provide, ref } from 'vue'
const theme = ref('light')
provide('theme', theme)
// consumer
import { inject } from 'vue'
const theme = inject('theme') as typeof theme
10) 清理副作用遗漏(定时器、事件监听、watch 停止)
- 现象:路由切换或组件销毁后仍触发副作用,出现内存泄漏或异常。
- 修复:在
onUnmounted 清理;watch 返回停止函数或在回调返回清理函数。
ts
复制代码
import { onUnmounted, watch, ref } from 'vue'
const timer = setInterval(() => {}, 1000)
onUnmounted(() => clearInterval(timer))
const x = ref(0)
const stop = watch(x, (v, o, onCleanup) => { const ctrl = new AbortController(); onCleanup(() => ctrl.abort()) })
额外建议与工具
toRef/toRefs:在组合函数中向外暴露字段时保持响应引用。
effectScope:将一组副作用统一管理,避免泄漏。
- 组合式函数规范:输入为响应源或普通值,输出尽量为
ref/computed,副作用内部管理并暴露停止函数。
进阶避坑与修复
shallowReactive/shallowRef 的误用
- 现象:以为浅响应能自动追踪深层属性,结果更新不触发视图。
- 修复:深层数据使用
reactive/ref;浅响应配合 triggerRef 手动触发。
ts
复制代码
import { shallowRef, triggerRef } from 'vue'
const data = shallowRef({ list: [] })
data.value.list.push(1)
triggerRef(data)
模板中解构导致失去响应性
- 现象:在
<script setup> 中对 reactive 解构并直接在模板使用,视图不更新。
- 修复:使用
toRefs 暴露到模板。
ts
复制代码
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
生命周期钩子时机错误
- 现象:在
onMounted 前读取 DOM 或在 onUpdated 中引入副作用导致频繁触发。
- 修复:需要 DOM 完成后用
nextTick;副作用搬到 watch 并限制触发源。
ts
复制代码
import { onMounted, nextTick, ref } from 'vue'
const ready = ref(false)
onMounted(async () => { ready.value = true; await nextTick() })
组合式函数的隐式外部依赖
- 现象:组合式函数内部直接访问组件外部变量,复用失败或不可预测。
- 修复:所有依赖通过参数传入;输出统一为
ref/computed 与停止句柄。
ts
复制代码
export function usePagination(total: number) {
const page = ref(1)
const size = ref(10)
const pages = computed(() => Math.ceil(total / size.value))
return { page, size, pages }
}
异步竞态与中断
- 现象:快速切换参数导致旧请求后返回覆盖新数据。
- 修复:在
watch 使用 onCleanup 中断,或引入请求层中断控制。
ts
复制代码
import { ref, watch } from 'vue'
const id = ref('1')
watch(id, async (nv, _, onCleanup) => {
const ctrl = new AbortController()
onCleanup(() => ctrl.abort())
await fetch(`/api/${nv}`, { signal: ctrl.signal })
})
事件监听与路由切换泄漏
- 现象:在组合函数或组件中绑定窗口事件,路由切换后仍触发。
- 修复:返回停止函数或在
onUnmounted 清理。
ts
复制代码
import { onMounted, onUnmounted } from 'vue'
function useResize() {
const handler = () => {}
onMounted(() => window.addEventListener('resize', handler))
onUnmounted(() => window.removeEventListener('resize', handler))
}
测试与验证清单
- 响应性:对
reactive/ref 的字段更新是否触发视图与计算值更新。
- 副作用:
watch/watchEffect 是否仅针对预期源触发,是否有清理。
- 时机:DOM 依赖逻辑是否在
post 阶段或 nextTick 后执行。
- 组合函数:输入输出是否纯净、是否暴露停止函数、内部是否自清理。
- 路由:切换后计时器/事件监听是否停止,内存增长是否可控。
性能与稳定性建议
- 优先使用
computed 做派生,减少 watch;对昂贵计算使用缓存与拆分。
- 列表与表单场景避免深度
watch;精确监听源或使用局部 ref。
- 合理使用懒加载与分包;避免在
setup 中做沉重同步工作。
组合式函数设计准则
- 输入明确与参数化,输出统一为响应引用;副作用内部管理并可停止。
- 不读组件外部全局状态,统一从参数或依赖注入获取。
- 文档化使用方式与限制,提供最小复用单元与扩展点。