Files
2026-07-13 21:37:07 +08:00

9.8 KiB
Raw Permalink Blame History

name, description, metadata
name description metadata
vue-common-gotchas-edge-cases Vue 常见陷阱与边界情况
type
reference

Vue 常见陷阱与边界情况

会导致静默失败或难以调试问题的关键 Vue 3 陷阱。

基于 vuejs-ai/skills 的 vue-best-practices。如需全面覆盖(200+ 条规则),请参见上游仓库。

响应式

在脚本中访问 ref() 时始终使用 .value

影响:高——忘记 .value 会导致静默失败。

const count = ref(0)

// 错误
count++           // 试图对 ref 对象自增
count = 5         // 重新赋值变量,丢失响应性
items.push(4)     // 错误:push 不是函数

// 正确
count.value++
count.value = 5
items.value.push(4)

// 在模板中——不需要 .value(Vue 会自动解包)
// {{ count }} 可以工作,而不是 {{ count.value }}

切勿直接解构 reactive() 对象

影响:高——解构会破坏响应连接。

const state = reactive({ count: 0, name: 'Vue' })

// 错误——解构后的变量失去响应性
const { count, name } = state
state.count++
console.log(count)  // 仍然是 0

// 正确——使用 toRefs()
const { count, name } = toRefs(state)
state.count++
console.log(count.value)  // 1

// 最佳实践——直接用 ref() 替代 reactive()
const count = ref(0)
const name = ref('Vue')

reactive() 的代理标识符风险

const raw = {}
const proxy = reactive(raw)

// 错误——比较不同的对象
console.log(proxy === raw) // false

// 错误——创建多个代理
const a = reactive({})
const b = reactive(a)  // 返回同一个代理
console.log(a === b) // true(同一个对象)

// 陷阱——嵌套对象也会被代理
const nested = reactive({ obj: {} })
console.log(nested.obj === nested.obj) // true(同一个代理)

计算属性

计算属性的 getter 中不能有副作用

影响:高——副作用会破坏响应式模型。

// 错误——修改状态
const doubled = computed(() => {
  count.value++  // 副作用!
  return count.value * 2
})

// 错误——异步操作
const data = computed(async () => {
  return await fetch('/api')  // 副作用!
})

// 正确——只做纯计算
const doubled = computed(() => count.value * 2)

// 副作用应使用 watch
watch(count, (newVal) => {
  document.title = `Count: ${newVal}`
})

计算属性的返回值是只读的

const fullName = computed(() => `${first.value} ${last.value}`)

// 错误——计算属性的值是只读的
fullName.value = 'John Doe'  // 报错!

// 正确——使用可写计算属性
const fullName = computed({
  get: () => `${first.value} ${last.value}`,
  set: (val) => {
    const [f, l] = val.split(' ')
    first.value = f
    last.value = l
  }
})

侦听器

清理异步操作以防止竞态条件

影响:高——过期的请求可能覆盖新数据。

const query = ref('')
const results = ref([])

// 错误——竞态条件
watch(query, async (q) => {
  const res = await fetch(`/api?q=${q}`)
  results.value = await res.json()  // 可能覆盖更新的结果!
})

// 正确——使用 onWatcherCleanupVue 3.5+
watch(query, async (q) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())

  try {
    const res = await fetch(`/api?q=${q}`, { signal: controller.signal })
    results.value = await res.json()
  } catch (e) {
    if (e.name !== 'AbortError') throw e
  }
})

// 或使用 onCleanup 参数
watch(query, async (q, oldQ, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  // ... 与上面相同
})

深度侦听返回同一对象引用

const obj = reactive({ nested: { count: 0 } })

// 陷阱——深度侦听时 oldValue === newValue
watch(obj, (newVal, oldVal) => {
  console.log(newVal === oldVal)  // true!同一个对象
}, { deep: true })

// 如果需要旧值,先克隆:
watch(
  () => structuredClone(obj),
  (newVal, oldVal) => { /* 现在不同了 */ }
)

Props

Props 是只读的——切勿修改

影响:高——破坏单向数据流。

const props = defineProps<{ count: number; user: User }>()

// 错误——直接修改
props.count++  // Vue 警告
props.user.name = 'New'  // 没有警告但仍然是错误的!

// 正确——向父组件 emit
const emit = defineEmits(['update:count', 'update-user'])
emit('update:count', props.count + 1)
emit('update-user', { ...props.user, name: 'New' })

// 或者创建本地副本
const localUser = ref({ ...props.user })

解构后的 Props 不会触发侦听器(3.5 之前)

// 错误(Vue < 3.5
const { count } = defineProps<{ count: number }>()
watch(count, () => {})  // 不会触发!

// 正确——使用 getter
const props = defineProps<{ count: number }>()
watch(() => props.count, () => {})

// Vue 3.5+——解构在响应式 props 中可用
const { count } = defineProps<{ count: number }>()
watch(() => count, () => {})  // 3.5+ 中可用

生命周期钩子

在 setup 期间同步注册钩子

影响:高——异步钩子会静默失败。

// 错误——在 await 之后注册钩子
async setup() {
  const data = await fetchData()
  onMounted(() => {})  // 永远不会执行!
}

// 错误——在 setTimeout 中注册钩子
setup() {
  setTimeout(() => {
    onMounted(() => {})  // 永远不会执行!
  }, 100)
}

// 正确——同步注册,异步逻辑放在内部
setup() {
  onMounted(async () => {
    const data = await fetchData()
  })
}

模板

切勿在同一元素上同时使用 v-if 与 v-for

影响:高——Vue 2/3 优先级不同。

<!-- 错误——优先级不明确 -->
<li v-for="user in users" v-if="user.active" :key="user.id">

<!-- Vue 3v-if 先执行'user' 未定义 -->

<!-- 正确——使用计算属性过滤 -->
<li v-for="user in activeUsers" :key="user.id">

<script setup>
const activeUsers = computed(() => users.filter(u => u.active))
</script>

<!-- 正确——使用 template 包装 -->
<template v-for="user in users" :key="user.id">
  <li v-if="user.active">{{ user.name }}</li>
</template>

使用 v-if 时模板引用为 null

const inputRef = ref<HTMLInputElement | null>(null)

// 陷阱——元素隐藏时 ref 为 null
<input v-if="show" ref="inputRef" />

// 错误——可能为 null
inputRef.value.focus()  // 如果 !show 则报错

// 正确——判空检查
inputRef.value?.focus()

// 或使用带 flush: 'post' 的 watchEffect
watchEffect(() => {
  inputRef.value?.focus()
}, { flush: 'post' })

defineModel

对象修改不会触发 emit

const model = defineModel<{ name: string }>()

// 错误——修改不会通知父组件
model.value.name = 'New'  // 父组件不会知道!

// 正确——替换整个对象
model.value = { ...model.value, name: 'New' }

更新后的值需要 nextTick

const model = defineModel<string>()

// 错误——值尚未更新
model.value = 'new'
console.log(model.value)  // 仍然是旧值!

// 正确——等待 nextTick
model.value = 'new'
await nextTick()
console.log(model.value)  // 现在为 'new'

组件事件

未声明的事件可能触发两次

// 错误——缺少事件声明导致重复触发
const emit = defineEmits([])  // 'click' 未声明
<button @click="emit('click')">  // 触发两次!

// 正确——声明所有自定义事件
const emit = defineEmits(['click'])

事件不会跨组件冒泡

<!-- 父组件无法直接监听孙组件的事件 -->
<Grandparent>
  <Parent>
    <Child @custom="handler" />  <!-- 只有 Parent 可以监听 -->
  </Parent>
</Grandparent>

<!-- 解决方案重新 emit 或使用 provide/inject -->

Provide/Inject

响应性不是自动的

// 提供方
const count = ref(0)
provide('count', count)  // 传入 ref,而不是 .value

// 消费方
const count = inject('count')  // 接收 ref
console.log(count.value)  // 具有响应性!

// 错误——失去响应性
provide('count', count.value)  // 只传入了一个数字

必须同步调用 Provide

// 错误——在异步之后 provide
async setup() {
  await fetchData()
  provide('key', value)  // 静默失败!
}

// 正确
setup() {
  provide('key', value)  // 同步
  onMounted(async () => {
    await fetchData()
  })
}

SSR

生命周期钩子在服务器端不会执行

// onMounted、onUpdated、onUnmounted——仅在客户端执行
onMounted(() => {
  // 仅在浏览器中运行
  window.addEventListener('resize', handler)
})

// SSR 中,数据获取应使用 onServerPrefetch
onServerPrefetch(async () => {
  data.value = await fetchData()
})

水合不匹配的原因

常见原因:

  • 仅浏览器可用的 APIwindowlocalStorage
  • 不同的时间戳
  • 随机值
  • 与 User-Agent 相关的渲染
// 错误
const width = ref(window.innerWidth)  // 在服务端为 undefined

// 正确
const width = ref(0)
onMounted(() => {
  width.value = window.innerWidth
})

性能

对大型非响应式数据使用 shallowRef

// 错误——深度响应式的开销
const hugeList = ref(thousandsOfItems)

// 正确——只跟踪 .value 的赋值
const hugeList = shallowRef(thousandsOfItems)

// 通过替换整个数组来触发更新
hugeList.value = [...hugeList.value, newItem]

对非响应式对象使用 markRaw

// 错误——Chart.js 实例变为响应式(会破坏它)
const chart = ref(new Chart(ctx, config))

// 正确——标记为非响应式
const chart = ref(markRaw(new Chart(ctx, config)))

参考资料