9.8 KiB
9.8 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| vue-common-gotchas-edge-cases | Vue 常见陷阱与边界情况 |
|
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() // 可能覆盖更新的结果!
})
// 正确——使用 onWatcherCleanup(Vue 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 3:v-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()
})
水合不匹配的原因
常见原因:
- 仅浏览器可用的 API(
window、localStorage) - 不同的时间戳
- 随机值
- 与 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)))
参考资料
- vuejs-ai/skills vue-best-practices —— 完整的 200+ 条规则
- Vue 风格指南
- Vue 3 迁移指南