反模式是指那些看似能解决问题,但实际会引入隐患、降低可维护性或造成性能问题的写法。识别并避免这些反模式,是提升 Vue 代码质量的关键一步。
一、v-if 与 v-for 同时使用
这是 Vue 新手最容易踩的坑之一。在同一元素上同时使用 v-if 和 v-for 时,v-for 的优先级更高,意味着 v-if 会在每次循环中重复执行。
<!-- ❌ 错误:v-if 在每次迭代中都会执行 -->
<li v-for="item in list" v-if="item.isActive" :key="item.id">
{{ item.name }}
</li>
问题所在:
- 即使
list中只有少数几个isActive为true的项,Vue 仍然会遍历整个数组,对每一项执行v-if判断。 - 这种写法会让模板意图模糊,也容易忽略性能损耗(尤其列表很长时)。
正确做法:
- 先用计算属性过滤数据,再对过滤后的结果进行迭代。
- 如果需要控制整个列表是否展示,应把
v-if放在外层容器上。
<!-- ✅ 正确:用计算属性预先过滤 -->
<template v-if="activeItems.length">
<li v-for="item in activeItems" :key="item.id">
{{ item.name }}
</li>
</template>
const activeItems = computed(() => list.value.filter(item => item.isActive))
这样既保证了 v-if 只执行一次(在计算属性中),也让模板职责更清晰:循环只负责展示,不再承担过滤逻辑。
二、滥用 watch 导致逻辑混乱
watch 是监听数据变化的强大工具,但过度使用会让组件逻辑难以追踪,甚至造成数据流的“循环依赖”。
典型反模式 1:把 watch 当计算属性使
// ❌ 错误:用 watch 手动同步派生状态
const firstName = ref('')
const lastName = ref('')
const fullName = ref('')
watch([firstName, lastName], ([first, last]) => {
fullName.value = first + ' ' + last
})
这种写法不仅代码冗余,还额外维护了一个响应式变量 fullName。一旦在别处也修改了 fullName,就很难理清数据是从哪里“衍生的”。
✅ 正确:直接使用 computed:
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
computed 自带缓存,且逻辑单一、可阅读性强。凡是能从现有数据推导出来的值,都应该用 computed,而非 watch。
典型反模式 2:watch 中触发另一个 watch 的操作链
// ❌ 错误:watch 依赖链
watch(a, (newA) => { b.value = newA + 1 })
watch(b, (newB) => { c.value = newB * 2 })
当 a 变化时,b 会改变,接着 c 也改变。这种级联式的响应很容易变成“意料之外的副作用堆栈”,调试时根本摸不清起因。
✅ 解决思路:
- 尽量将依赖关系合并为
computed(c = computed(() => (a.value + 1) * 2))。 - 若确实需要命令式操作(如发请求),也应把逻辑收敛在一个
watch或一个方法中,避免触发链式反应。
典型反模式 3:watch 深度监听大对象,不加任何限定
// ❌ 可能引发性能问题
watch(someLargeObject, callback, { deep: true })
深度监听会递归遍历对象的所有嵌套属性,当对象结构复杂时,每次修改都可能触发大量的依赖收集和回调执行,性能开销肉眼可见。
✅ 改进:
- 尽可能精确监听具体路径:
watch(() => someLargeObject.a.b, callback) - 若必须使用
deep,考虑用shallowRef或markRaw避免深层响应。
三、内存泄漏的“三剑客”
在单页应用中,组件会频繁创建和销毁。如果某些资源在组件卸载时未被清理,就会持续占用内存,形成泄漏。
1. 忘记清除定时器 / setInterval
// ❌ 错误:组件销毁后定时器仍在运行
mounted() {
this.timer = setInterval(() => { ... }, 1000)
}
即使组件已从页面移除,这个 setInterval 仍然会在后台执行,其内部引用的组件实例也不会被回收。
✅ 正确:在 beforeUnmount(Vue 3)或 beforeDestroy(Vue 2)中清理:
const timer = ref(null)
onMounted(() => {
timer.value = setInterval(fetchData, 5000)
})
onBeforeUnmount(() => {
clearInterval(timer.value)
})
2. 全局事件监听未解绑
// ❌ 错误:addEventListener 后未在组件销毁时移除
mounted() {
window.addEventListener('resize', this.handleResize)
}
当组件被销毁时,handleResize 仍在监听全局事件,当事件触发时它会尝试更新一个已经不存在的组件状态,轻则控制台报错,重则内存泄漏。
✅ 正确:在 beforeUnmount 中移除:
onMounted(() => window.addEventListener('resize', handleResize))
onBeforeUnmount(() => window.removeEventListener('resize', handleResize))
3. 第三方库实例未销毁
使用了图表(ECharts)、编辑器(Quill)、轮播(Swiper)等第三方库时,如果只创建实例而忘记调用其销毁方法,结果和上面的定时器、事件监听一样。
// ❌ 错误:echarts 实例未销毁
let chart = null
onMounted(() => {
chart = echarts.init(container.value)
chart.setOption({...})
})
✅ 正确:主动调用 dispose,并置空引用:
onBeforeUnmount(() => {
chart?.dispose()
chart = null
})
四、其他值得警惕的写法
- 直接修改 props(破坏单向数据流):
props 是只读的,试图在子组件内部直接修改 prop 值会引发警告。需要用 emit 通知父组件修改,或在子组件内部用 ref 做临时副本。
- 把
reactive对象整体替换:
const state = reactive({ count: 1 }) 之后,如果执行 state = reactive({ count: 2 }),会丢失响应式(因为引用变了)。应替换为 Object.assign(state, { count: 2 }) 或使用 ref 包裹。
- 解构
reactive丢失响应式:
const { count } = reactive({ count: 1 }) 会让 count 变成普通数字。正确做法是使用 toRefs 或直接访问 state.count。
- 滥用
v-show在大量元素上:
v-show 只是切换 display,元素始终存在于 DOM 中。如果有数百个隐藏的复杂结点,会白白消耗内存和渲染资源。此类场景改用 v-if 才是合理的。
识别并规避这些反模式,能让你的 Vue 应用从“能跑”升级为“健壮、可维护”。 好的代码不仅满足功能需求,更经得起时间与团队的考验。