Vue 常见踩坑
大白话解释: Vue 的响应式系统很智能,但也有一些"盲区"。了解这些盲区,才能避免"数据变了页面没更新"的问题。
为什么会踩坑?
- 响应式有边界:Vue 不能监听所有操作,比如直接修改数组下标(Vue 2)、给对象添加新属性(Vue 2)
- 解构会丢失响应式:解构出来的值是普通值,不再是响应式的
- watch 默认浅监听:监听对象时,只监听引用变化,不监听内部属性变化
如何避免踩坑?
- 理解响应式原理:Vue 2 用
Object.defineProperty(只能劫持已有属性的读写),Vue 3 用Proxy(代理整个对象,可拦截所有操作) - 遵循 Vue 的最佳实践(用 computed 过滤、用 toRefs 解构)
- 遇到问题先检查是否触发了响应式更新
Vue 开发中高频遇到的问题和解决方案。
预防措施
- 理解响应式原理:Vue 2 用
Object.defineProperty(有边界限制),Vue 3 用Proxy(更全面) - 遵循单向数据流:子组件不直接修改 props,通过
emit通知父组件 - 合理使用 computed:过滤、排序等派生数据用 computed,避免在模板中写复杂逻辑
- 及时清理副作用:定时器、事件监听、请求在组件销毁时清理
- 避免 v-if 和 v-for 同时使用:用 computed 预处理数据,或用 template 包裹
1. v-if 和 v-for 不要一起用
Vue 2 / Vue 3 都受影响,但行为不同
vue
<!-- ❌ Vue 3 中 v-if 优先级高于 v-for,会导致 item 未定义的逻辑错误 -->
<!-- ❌ Vue 2 中 v-for 优先级高于 v-if,会导致性能浪费(每次渲染都先遍历再判断) -->
<li v-for="item in list" v-if="item.visible" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 用 template 包裹,或用 computed 过滤 -->
<template v-for="item in list" :key="item.id">
<li v-if="item.visible">{{ item.name }}</li>
</template>
<!-- ✅ 推荐:computed 过滤后再遍历 -->
<li v-for="item in visibleList" :key="item.id">
{{ item.name }}
</li>
<script setup>
const visibleList = computed(() => list.value.filter(item => item.visible))
</script>2. 响应式丢失(解构后不更新)
Vue 3 Composition API(reactive / ref)
js
// reactive 是 Vue 3 Composition API 的响应式 API,用于创建响应式对象
const state = reactive({ count: 0, name: '张三' })
// ❌ 解构后丢失响应式(解构相当于赋值,拿到的是原始值的副本)
const { count, name } = state // count 是普通数字,不会触发视图更新
// ✅ 用 toRefs 保持响应式(将 reactive 对象的每个属性转为 ref)
const { count, name } = toRefs(state) // count 现在是 ref,.value 访问
// ✅ 或直接访问属性(保持响应式)
console.log(state.count) // 通过 reactive 对象访问,始终是响应式的
// Vue 2 Options API 没有这个问题,因为 data 中的属性会挂到 this 上
// this.count 始终是响应式的(通过 Object.defineProperty 代理)reactive 不能替换整个对象
js
const state = reactive({ count: 0, name: '张三' })
// ❌ 直接赋值会丢失响应式(state 指向了新对象,原 reactive 代理失效)
state = { count: 1, name: '李四' }
// ✅ 用 Object.assign 合并属性(保持原对象引用)
Object.assign(state, { count: 1, name: '李四' })
// ✅ 或改用 ref(可以整体替换 .value)
const state = ref({ count: 0, name: '张三' })
state.value = { count: 1, name: '李四' } // ✅ 正确3. 数组下标赋值不触发更新
Vue 2 专属问题,Vue 3 已修复
js
// Vue 2:Object.defineProperty 只能劫持已有属性的读写,无法检测数组下标变化
this.items[0] = 'new' // ❌ 不触发视图更新
this.items.length = 0 // ❌ 不触发视图更新
// ✅ Vue 2 正确方式(使用 Vue 提供的数组变异方法)
this.$set(this.items, 0, 'new') // 用 $set 触发响应式更新
this.items.splice(0, 1, 'new') // 用 splice(Vue 2 劫持了数组变异方法)
this.items = this.items.filter(...) // 重新赋值整个数组(引用变化)
// Vue 3:Proxy 可以拦截对象的所有操作(包括数组下标赋值和 length 修改)
this.items[0] = 'new' // ✅ 正常触发更新
this.items.length = 0 // ✅ 正常触发更新4. watch 监听对象属性不触发
Vue 3 Composition API(watch)
js
const state = reactive({ user: { name: '张三' } })
// ❌ 监听整个 user 对象,内部属性变化时不触发
// 因为 watch 默认是 shallow(浅监听),只比较引用是否变化
watch(() => state.user, (newVal) => {
console.log(newVal) // user 引用不变,不触发
})
// ✅ 方式一:加 deep(深度监听,性能开销大)
watch(() => state.user, (newVal) => {
console.log(newVal)
}, { deep: true })
// ✅ 方式二:直接监听具体属性(推荐,精确高效)
watch(() => state.user.name, (newVal) => {
console.log(newVal)
})
// Vue 2 Options API 中 watch 默认就是 deep 的(对于对象类型)
// watch: {
// 'user.name'(newVal) { ... } // 监听具体属性
// }5. 组件销毁后操作 DOM / 发请求
Vue 2 / Vue 3 都会遇到,生命周期钩子名称不同
vue
<script setup>
// Vue 3 Composition API
// ❌ 异步回调中操作已销毁的组件
onMounted(async () => {
const res = await fetch('/api/data')
data.value = res.data // 组件可能已销毁,控制台警告
})
// ✅ 用 AbortController 取消请求
const controller = new AbortController()
onMounted(async () => {
const res = await fetch('/api/data', { signal: controller.signal })
data.value = res.data
})
onUnmounted(() => {
controller.abort() // 组件销毁时取消请求
})
// ✅ 定时器同理
const timer = setInterval(() => { /* ... */ }, 1000)
onUnmounted(() => clearInterval(timer))
</script>js
// Vue 2 Options API — 用 beforeDestroy(不是 onUnmounted)
export default {
mounted() {
this.timer = setInterval(() => { /* ... */ }, 1000)
},
beforeDestroy() { // Vue 2 用 beforeDestroy
clearInterval(this.timer)
}
}6. ref 获取 DOM 为 null
Vue 3 Composition API(模板 ref)
vue
<template>
<!-- Vue 3:ref 名称要和变量名一致 -->
<div ref="myDiv">内容</div>
</template>
<script setup>
const myDiv = ref(null) // 变量名 = 模板中的 ref 名称
onMounted(() => {
console.log(myDiv.value) // ✅ <div>(组件挂载后才有值)
})
onBeforeMount(() => {
console.log(myDiv.value) // ❌ null(还没挂载)
})
</script>js
// Vue 2 Options API — 用 this.$refs
export default {
mounted() {
console.log(this.$refs.myDiv) // ✅ <div>
}
}7. 路由跳转后页面不刷新
Vue 2 / Vue 3 都会遇到(Vue Router 3.x / 4.x)
js
// 问题:从 /user/1 跳转到 /user/2,组件复用,不触发重新创建
// ✅ 方式一:监听路由变化(推荐)
// Vue 3
watch(() => route.params.id, (newId) => {
fetchUser(newId)
})
// Vue 2
watch: {
'$route.params.id'(newId) {
this.fetchUser(newId)
}
}
// ✅ 方式二:给 router-view 加 key(强制重建,简单但性能差)
<router-view :key="$route.fullPath" />
// ✅ 方式三:导航守卫(Vue Router 4.x)
onBeforeRouteUpdate((to, from) => {
if (to.params.id !== from.params.id) {
fetchUser(to.params.id)
}
})8. nextTick 使用时机
Vue 2 / Vue 3 都适用,API 写法不同
js
// 修改数据后,DOM 不是立即更新的(Vue 是异步更新)
// Vue 3 Composition API
count.value = 1
console.log(document.getElementById('count').textContent) // 旧值
count.value = 1
await nextTick() // 等 DOM 更新
console.log(document.getElementById('count').textContent) // ✅ 新值
// Vue 2 Options API
this.count = 1
this.$nextTick(() => {
console.log(this.$el.textContent) // ✅ 新值
})9. props 单向数据流
Vue 2 / Vue 3 都适用,API 写法不同
vue
<script setup>
// Vue 3
// ❌ 直接修改 prop(控制台警告)
const props = defineProps(['modelValue'])
props.modelValue = 'new' // 警告
// ✅ 通过 emit 通知父组件修改
const emit = defineEmits(['update:modelValue'])
emit('update:modelValue', 'new')
</script>js
// Vue 2 Options API
export default {
props: ['value'],
methods: {
update() {
// ❌ this.value = 'new'
// ✅
this.$emit('input', 'new') // Vue 2 v-model
this.$emit('update:value', 'new') // .sync 修饰符
}
}
}10. 异步组件与 Suspense
什么是 Suspense? Suspense 是 Vue 3 的内置组件,用于处理异步组件的加载状态。 它提供 #default 和 #fallback 两个插槽:默认插槽放异步组件,fallback 插槽放加载中的占位内容。 当异步组件加载完成前显示 fallback,加载完成后切换到 default。
Vue 3 专属(Vue 2 不支持 Suspense)
vue
<!-- Vue 3 异步组件配合 Suspense -->
<template>
<suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>加载中...</div>
</template>
</suspense>
</template>
<script setup>
// 组件的 setup 可以是 async
const data = await fetch('/api/data').then(r => r.json())
</script>js
// Vue 2 异步组件写法(没有 Suspense)
const AsyncComponent = () => import('./MyComponent.vue')
// 或带 loading/error 状态
const AsyncComponent = () => ({
component: import('./MyComponent.vue'),
loading: LoadingSpinner,
error: ErrorDisplay,
delay: 200,
timeout: 3000
})真实场景
场景 1:数据变了但页面没更新
- 问题:修改了数组的某一项,但页面显示的还是旧值
- 原因:Vue 2 中直接通过下标修改数组无法触发响应式更新
- 解决:使用
this.$set()或splice()方法修改数组
场景 2:解构 props 后子组件不更新
- 问题:父组件传递的 props 变化了,但子组件显示的值没变
- 原因:使用解构
const { name } = props,解构后的name是普通值,失去响应式 - 解决:使用
toRefs解构const { name } = toRefs(props)
场景 3:切换路由后页面白屏
- 问题:从
/user/1跳转到/user/2,页面没刷新,显示的还是用户 1 的数据 - 原因:Vue Router 对相同组件会复用,不触发重新创建
- 解决:监听路由参数变化
watch(() => route.params.id, fetchUser)