Skip to content

Vue 常见踩坑

大白话解释: Vue 的响应式系统很智能,但也有一些"盲区"。了解这些盲区,才能避免"数据变了页面没更新"的问题。

为什么会踩坑?

  • 响应式有边界:Vue 不能监听所有操作,比如直接修改数组下标(Vue 2)、给对象添加新属性(Vue 2)
  • 解构会丢失响应式:解构出来的值是普通值,不再是响应式的
  • watch 默认浅监听:监听对象时,只监听引用变化,不监听内部属性变化

如何避免踩坑?

  • 理解响应式原理:Vue 2 用 Object.defineProperty(只能劫持已有属性的读写),Vue 3 用 Proxy(代理整个对象,可拦截所有操作)
  • 遵循 Vue 的最佳实践(用 computed 过滤、用 toRefs 解构)
  • 遇到问题先检查是否触发了响应式更新

Vue 开发中高频遇到的问题和解决方案。


预防措施

  1. 理解响应式原理:Vue 2 用 Object.defineProperty(有边界限制),Vue 3 用 Proxy(更全面)
  2. 遵循单向数据流:子组件不直接修改 props,通过 emit 通知父组件
  3. 合理使用 computed:过滤、排序等派生数据用 computed,避免在模板中写复杂逻辑
  4. 及时清理副作用:定时器、事件监听、请求在组件销毁时清理
  5. 避免 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)

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准