Vue3的watch为什么会监测到undefined?有哪些快速定位和解决的方法?
最近后台收到不少刚从Vue2转过来或者刚上手Vue3 Composition API的朋友的私信:明明代码写得和之前差不多,怎么打开控制台调试watch,回调里的新值旧值全是undefined?甚至有时候是旧值有、新值没有,反过来的情况也偶尔碰到。
其实这个问题不是Vue3的bug,而是Composition API和Options API在watch的触发机制、数据绑定前提上有一些本质的差异,再加上大家容易踩的几个小坑,才会导致undefined反复出现,今天就把我这两年踩过和帮同事解决过的所有Vue3 watch undefined相关的场景,整理成6个具体的问答模块,每个都有代码示例、原因分析和对应的1-2种解决办法,看完应该能覆盖90%以上的常见情况了。
监测的是没有设置初始值的ref/reactive属性?
可能很多朋友刚转过来的时候会犯懒,或者觉得Vue会自动处理初始值,就直接在setup里写const name = ref(); const user = reactive({});,然后马上watch(name, ...)或者watch(() => user.age, ...)。
原因分析
Composition API的watch和Options API的$watch在初始化触发上有个小区别吗?不对,更准确的是——不管有没有设置immediate,watch监测的响应式目标必须先有明确的定义和绑定关系,第一次触发的时候才会有值,哦对,还有!如果是watch的是ref的.value属性(虽然一般不用显式写,直接传ref就行),或者reactive对象里嵌套很深、但外层先赋值内层一开始没有的属性,Vue3的响应式系统在初始化阶段也可能“漏看”或者“认为这不是个有效的响应式依赖”,导致第一次回调参数全空。
举个真实踩过的例子给大家看:
// 错误示例1:ref没有初始值
import { ref, watch } from 'vue'
export default {
setup() {
const inputVal = ref() // 这里没有写inputVal.value = ''或者null
watch(inputVal, (newVal, oldVal) => {
console.log('inputVal变化了:', newVal, oldVal) // 第一次点击输入框输入内容,这里newVal会有值?不对不对,有些浏览器里甚至第一次也不会触发?哦对,要看是用了v-model还是手动赋值
})
return { inputVal }
}
}
// 错误示例2:reactive嵌套对象初始没有内层属性
import { reactive, watch } from 'vue'
export default {
setup() {
const form = reactive({
username: ''
// 这里没有password字段!
})
// 有些朋友会提前监测password,想着用户输入密码时触发
watch(() => form.password, (newVal, oldVal) => {
console.log('password变化了:', newVal, oldVal) // 除非你显式给form.password赋值过,否则第一次赋值之前的所有操作?不对,第一次显式赋值的时候,newVal会是你赋的值,但oldVal永远是undefined!因为在第一次赋值前,form.password根本不是reactive的依赖项,Vue3不会记录它的初始历史
})
return { form }
}
}
解决办法
给所有需要监测的响应式目标设置合理的初始值
如果是ref,不管是字符串、数字、布尔值还是对象数组,都要显式初始化;如果是reactive的嵌套对象,哪怕一开始是空字符串、0、false或者null,也要先把可能用到的属性全列出来,比如把刚才的两个错误示例改成这样:
// 正确示例1-1:ref设置初始空字符串
const inputVal = ref('')
watch(inputVal, (newVal, oldVal) => {
console.log('inputVal变化了:', newVal, oldVal) // 第一次输入时,newVal是第一个字符,oldVal是空字符串,没问题
})
// 正确示例1-2:ref设置初始null(适合暂时不知道具体类型或者初始为空的场景,比如后端数据)
const userInfo = ref(null)
watch(userInfo, (newVal, oldVal) => {
if (newVal) {
// 只有当userInfo有值的时候才处理,避免undefined报错
console.log('用户信息加载成功:', newVal.nickname)
}
})
// 正确示例1-3:reactive嵌套对象提前列全属性
const form = reactive({
username: '',
password: '',
confirmPassword: ''
})
watch(() => form.password, (newVal, oldVal) => {
console.log('password变化了:', newVal, oldVal) // 第一次输入时oldVal是空字符串,没问题
})
如果有些属性真的没办法提前确定(比如后端返回的嵌套JSON结构非常灵活),可以用Vue.set的替代方法——reactive对象的直接赋值或者Object.assign,或者给ref设置类型注解配合immediate?哦不对,类型注解只是TS的,JS不管用,还是直接用Object.assign或者给外层加个判断更稳妥。
比如JS里处理灵活的后端嵌套数据:
const dynamicData = reactive({})
watch(() => dynamicData, (newVal) => {
if (newVal.user && newVal.user.profile && newVal.user.profile.avatar) {
console.log('头像地址有了:', newVal.user.profile.avatar)
}
}, { deep: true }) // 这里必须加deep,因为dynamicData是个空对象初始化,后续加属性属于深层变化
// 后端接口返回数据后
fetch('/api/user/info')
.then(res => res.json())
.then(data => {
// 直接用Object.assign合并整个data到dynamicData里,这样Vue3会自动给新增的属性添加响应式
Object.assign(dynamicData, data)
})
监测的是普通函数返回的非响应式数据?
很多朋友写watch的第二个参数的时候(或者说watch的第一个“源”参数),会不小心写成一个普通函数,这个函数里没有用到任何ref的.value、reactive的属性或者computed的返回值——也就是说,这个源函数本身不是响应式依赖追踪的目标,那Vue3的watch当然不知道什么时候要触发,甚至有时候设置了immediate,源函数第一次调用就返回undefined,回调里的参数也就全是undefined了。
原因分析
Vue3的Composition API里,watch的源参数有两种合法写法:
- 直接传响应式对象/数组/ ref/ computed:这种情况下Vue会自动追踪整个源的变化(如果是对象数组的话默认浅追踪,需要加deep才会深追踪);
- 传一个返回值的getter函数:这种情况下Vue会自动执行这个getter函数,追踪函数内部用到的所有响应式数据,只有当这些被追踪的数据变化时,getter函数的返回值才会被重新计算,然后和上一次的返回值对比,决定要不要触发watch的回调。
如果你传的getter函数里没有用到任何响应式数据,那Vue3的依赖追踪器(effect)在第一次执行完getter之后,就不会再监听任何东西了——哪怕你手动在外面修改了getter里用到的变量,Vue也不知道,更不会触发watch;更惨的是,如果这个getter第一次调用就因为变量未定义或者逻辑问题返回了undefined,那不管加不加immediate,回调里的新值旧值全空。
再举个踩过的坑:
// 错误示例:源函数是普通函数,没有用到任何响应式数据
import { ref, watch } from 'vue'
export default {
setup() {
let localCount = 0 // 这是个普通的局部变量,不是ref也不是reactive的
const count = ref(0) // 哦对了,这里不小心写了个count ref,但源函数里没用到!
watch(
() => localCount, // 源函数里只有普通变量localCount,没有用到count.value!
(newVal, oldVal) => {
console.log('localCount变化了:', newVal, oldVal) // 哪怕你下面手动改localCount,这里永远不会触发!如果加了immediate,第一次会触发,但newVal和oldVal都是0或者undefined?哦看localCount的初始值,如果初始值是0,那immediate触发时是0和undefined
},
{ immediate: true }
)
// 模拟点击按钮修改localCount
const addLocal = () => {
localCount++
console.log('手动修改localCount后:', localCount) // 控制台这里会打印1、2、3...但watch那边永远没反应
}
return { addLocal }
}
}
解决办法
把源函数里用到的所有数据都改成响应式的
要么是ref,要么是reactive的属性,要么是computed的返回值,这样Vue的依赖追踪器才能工作,比如把刚才的错误示例改成这样:
// 正确示例2-1:把localCount改成ref
import { ref, watch } from 'vue'
export default {
setup() {
const localCount = ref(0) // 改成ref!
watch(
() => localCount.value, // 这里显式用了localCount.value,当然也可以直接传localCount
(newVal, oldVal) => {
console.log('localCount变化了:', newVal, oldVal) // 点击addLocal,这里会正常打印
},
{ immediate: true }
)
const addLocal = () => {
localCount.value++ // 这里也要记得加.value!
}
return { addLocal, localCount }
}
}
// 正确示例2-2:直接传ref,不用写getter函数
watch(
localCount, // 直接传localCount这个ref对象,Vue会自动追踪它的.value变化
(newVal, oldVal) => {
console.log('localCount变化了:', newVal, oldVal)
},
{ immediate: true }
)
检查一下源函数里是不是不小心漏写了ref的.value或者reactive的属性访问路径
很多时候大家转Vue3,特别是从Vue2的Options API转过来,习惯了在template里不用写ref的.value,但在setup的JS/TS逻辑里,不管是赋值、读取还是放在watch的源函数里,都必须显式写.value(除非是直接传整个ref对象给watch、computed或者v-model)。
比如刚才的错误示例里,如果源函数本来想用count.value,结果写成了count,那getter函数返回的是整个ref对象,而不是它的数值——整个ref对象的引用是不会变的(除非你重新赋值const count = ref(1),但setup里的变量一般不会重新赋值),所以watch也不会触发,哪怕加了immediate,如果ref对象没有初始值,回调里的newVal旧Val也可能有问题?哦不对,整个ref对象的引用是固定的,immediate触发时newVal和oldVal都是这个ref对象,不会是undefined,但这时候你要拿到数值的话,还得在回调里写newVal.value,反而麻烦,不如一开始就用对源参数。
用了watchEffect但逻辑顺序搞反了?
可能很多朋友觉得watchEffect比watch方便,不用写源参数,自动追踪所有用到的响应式数据,但如果逻辑顺序搞反了——先执行了watchEffect,再初始化或者赋值用到的响应式数据,那第一次执行watchEffect的时候,用到的响应式数据还是undefined,后续虽然会因为数据变化重新执行,但第一次的undefined可能会导致报错,或者有些业务逻辑只需要第一次有值的时候执行,就会出问题。
原因分析
watchEffect和watch的第一个大区别就是触发时机:
- watch:默认是懒执行的,也就是只有当源参数变化的时候才会触发第一次回调(除非加了immediate);
- watchEffect:默认是立即执行的,setup里一碰到watchEffect的代码就会马上执行一次,然后自动追踪执行过程中用到的所有响应式数据,后续这些数据变化时再重新执行。
如果你把用到的响应式数据的初始化或者赋值代码放在了watchEffect的后面,那第一次立即执行watchEffect的时候,这些数据要么是ref的初始undefined,要么是reactive里还没赋值的属性,自然会出问题。
举个真实的业务场景:比如需要监听用户的搜索关键词变化,然后调用后端接口获取数据,但搜索关键词的初始值是从localStorage里读取的,读取localStorage的代码放在了watchEffect的后面——
// 错误示例3:watchEffect逻辑顺序搞反了
import { ref, watchEffect } from 'vue'
export default {
setup() {
const searchKeyword = ref()
const searchResults = ref([])
const loading = ref(false)
// 先写了watchEffect
watchEffect(() => {
if (searchKeyword.value) {
loading.value = true
fetch(`/api/search?keyword=${searchKeyword.value}`)
.then(res => res.json())
.then(data => {
searchResults.value = data.list
loading.value = false
})
.catch(err => {
console.error('搜索失败:', err)
loading.value = false
})
}
})
// 读取localStorage的代码放在后面了!
const savedKeyword = localStorage.getItem('searchKeyword')
if (savedKeyword) {
searchKeyword.value = savedKeyword
}
return { searchKeyword, searchResults, loading }
}
}
哦这个例子其实还好,因为后面给searchKeyword赋值了,watchEffect会重新执行,但如果读取localStorage的代码是异步的呢?比如放在了一个setTimeout或者另一个异步接口的回调里——
// 更严重的错误示例3-1:读取localStorage是异步的,且放在watchEffect后面
import { ref, watchEffect } from 'vue'
export default {
setup() {
const searchKeyword = ref()
const searchResults = ref([])
const loading = ref(false)
watchEffect(() => {
if (searchKeyword.value) {
// 业务逻辑
} else {
// 清空搜索结果
searchResults.value = []
}
})
// 模拟异步读取配置(比如先从后端获取用户的默认搜索设置,再读localStorage)
setTimeout(() => {
const savedKeyword = localStorage.getItem('searchKeyword')
if (savedKeyword) {
searchKeyword.value = savedKeyword
}
}, 500)
return { searchKeyword, searchResults, loading }
}
}
这个例子里,前500毫秒searchKeyword.value都是undefined,watchEffect会立即执行一次,清空搜索结果;500毫秒后赋值成功,watchEffect重新执行业务逻辑——但如果有些业务逻辑是必须第一次就有值的,或者不希望一开始就清空什么东西,就会出问题;要是异步读取的时间更长,或者有些接口返回的数据失败导致searchKeyword.value一直是undefined,那watchEffect里的else分支会一直占着,业务逻辑永远不会执行。
解决办法
把用到的响应式数据的初始化代码(包括同步和异步的初始赋值逻辑,只要是能提前的就提前)放在watchEffect的前面
如果是同步的初始化,直接放在setup的最上面就行;如果是异步的初始赋值,比如从localStorage读取、从后端获取默认配置,也尽量提前触发(当然异步的不能完全保证在watchEffect第一次执行前完成,但可以配合其他办法,比如加个isInitialized的ref)。
比如把刚才的错误示例3改成同步初始化提前的版本:
// 正确示例3-1:同步初始化放在watchEffect前面
import { ref, watchEffect } from 'vue'
export default {
setup() {
// 1. 先同步读取localStorage,初始化searchKeyword
const savedKeyword = localStorage.getItem('searchKeyword') || '' // 这里加个|| '',避免初始值是undefined
const searchKeyword = ref(savedKeyword)
const searchResults = ref([])
const loading = ref(false)
// 2. 再写watchEffect
watchEffect(() => {
if (searchKeyword.value) {
loading.value = true
fetch(`/api/search?keyword=${searchKeyword.value}`)
.then(res => res.json())
.then(data => {
searchResults.value = data.list
loading.value = false
})
.catch(err => {
console.error('搜索失败:', err)
loading.value = false
})
} else {
searchResults.value = []
}
})
return { searchKeyword, searchResults, loading }
}
}
如果异步初始化的时间确实很长,或者不能确定什么时候完成,可以加一个isInitialized的ref,在watchEffect里先判断isInitialized是否为true,只有true的时候才执行业务逻辑
比如把刚才的错误示例3-1改成加isInitialized的版本:
// 正确示例3-2:加isInitialized判断异步初始化是否完成
import { ref, watchEffect } from 'vue'
export default {
setup() {
const searchKeyword = ref('') // 先给个空字符串的初始值,避免undefined
const searchResults = ref([])
const loading = ref(false)
const isInitialized = ref(false) // 加个初始化状态的ref
watchEffect(() => {
// 先判断是否初始化完成
if (!isInitialized.value) return
// 初始化完成后再执行业务逻辑
if (searchKeyword.value) {
loading.value = true
fetch(`/api/search?keyword=${searchKeyword.value}`)
.then(res => res.json())
.then(data => {
searchResults.value = data.list
loading.value = false
})
.catch(err => {
console.error('搜索失败:', err)
loading.value = false
})
} else {
searchResults.value = []
}
})
// 模拟异步读取配置
setTimeout(() => {
const savedKeyword = localStorage.getItem('searchKeyword') || ''
searchKeyword.value = savedKeyword
isInitialized.value = true // 初始化完成后把isInitialized设为true
}, 500)
return { searchKeyword, searchResults, loading }
}
}
监测的是prop,但父组件没有传或者传的是undefined?
哦这个场景其实Vue2也会有,但Vue3的Composition API里使用prop的方式和Options API有点不一样,有些朋友可能会搞错,导致监测到undefined的情况更频繁。
原因分析
不管是Vue2还是Vue3,prop的初始值取决于父组件有没有传、传的是什么——如果父组件没有传这个prop,也没有在子组件的props选项里设置默认值,那这个prop的初始值就是undefined;如果父组件一开始传的是undefined(比如父组件的某个响应式数据初始值是undefined,然后通过v-bind传给子组件),那子组件的prop初始值也是undefined。
Vue3的Composition API里,如果要在setup里使用prop,必须先在props选项里声明,然后通过setup的第一个参数props来访问——如果没有声明,直接从setup的上下文或者其他地方拿prop,那拿到的肯定是undefined;还有,如果在setup里直接解构props,那解构出来的变量会失去响应式,除非用toRefs或者toRef来包裹——哦这个是另一个常见的坑,但如果失去响应式的话,watch监测的是解构出来的普通变量,就会出现场景二的问题,不是场景四的问题,但大家也可以顺便注意一下。
举个场景四的例子:
// 子组件:没有设置prop的默认值,父组件可能没传
import { watch } from 'vue'
export default {
props: ['userId'], // 只声明了userId,没有设置默认值
setup(props) {
watch(
() => props.userId,
(newVal, oldVal) => {
console.log('userId变化了:', newVal, oldVal) // 如果父组件一开始没传userId,immediate触发时newVal和oldVal都是undefined;如果父组件后来传了,newVal有值,但oldVal永远是undefined
},
{ immediate: true }
)
return {}
}
}
// 父组件1:没有传userId
<template>
<ChildComponent />
</template>
// 父组件2:一开始传的是undefined,后来才赋值
<template>
<ChildComponent :userId="parentUserId" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentUserId = ref() // 初始值是undefined
onMounted(() => {
// 模拟异步获取当前登录用户的id
setTimeout(() => {
parentUserId.value = 123
}, 1000)
})
</script>
解决办法
在子组件的props选项里给每个prop设置合理的默认值
如果是字符串、数字、布尔值这些基本类型,直接设置默认值就行;如果是对象、数组这些引用类型,必须用函数返回默认值(这个Vue2和Vue3都是一样的,避免多个子组件共享同一个引用类型的默认值)。
比如把刚才的子组件改成设置默认值的版本:
// 子组件:设置prop的默认值
import { watch } from 'vue'
export default {
props: {
userId: {
type: [String, Number], // 可以设置多个类型
default: '' // 基本类型直接设置默认值
},
userInfo: {
type: Object,
default: () => ({}) // 引用类型必须用函数返回默认值
},
hobbies: {
type: Array,
default: () => [] // 数组也是引用类型,必须用函数返回
}
},
setup(props) {
watch(
() => props.userId,
(newVal, oldVal) => {
console.log('userId变化了:', newVal, oldVal) // 父组件1没传的话,immediate触发时newVal和oldVal都是'';父组件2一开始传的是undefined,但因为有默认值,immediate触发时newVal是'',后来父组件赋值123,newVal是123,oldVal是'',没问题
},
{ immediate: true }
)
return {}
}
}
如果有些prop确实不能设置默认值(比如必须由父组件传一个有效的值才能执行业务逻辑),可以在watch里加个判断,只有当newVal是有效的值的时候才处理,同时在父组件里尽量避免一开始传undefined,或者提前触发异步赋值逻辑。
比如子组件里加判断:
// 子组件:加有效值判断
import { watch } from 'vue'
export default {
props: {
userId: {
type: [String, Number],
required: true // 可以设置required为true,这样Vue开发环境会在父组件没传的时候报警告,但生产环境不会阻止,所以还是要加判断
}
},
setup(props) {
watch(
() => props.userId,
(newVal) => {
// 加有效值判断:不是undefined、不是null、不是空字符串(如果业务需要的话)
if (newVal !== undefined && newVal !== null && newVal !== '') {
console.log('开始执行业务逻辑,userId:', newVal)
}
},
{ immediate: true }
)
return {}
}
}
监测的是computed,但computed的getter函数返回了undefined?
这个场景其实和场景二有点像,但computed本身是响应式的,所以可能更容易被忽略。
原因分析
computed的getter函数和watch的源getter函数一样,必须有明确的返回值,且返回值不能依赖于未定义或者非响应式的数据——如果computed的getter函数第一次调用就返回undefined,那不管是直接用computed还是watch监测computed,都会拿到undefined;如果computed的getter函数里用到的响应式数据变化后,逻辑有问题导致返回undefined,那watch监测到的新值也会是undefined。
举个例子:
// 错误示例5:computed的getter函数逻辑有问题,返回undefined
import { ref, computed, watch } from 'vue'
export default {
setup() {
const userList = ref([])
const currentUserId = ref(123)
// 这个computed的作用是从userList里找到currentUserId对应的用户
const currentUser = computed(() => {
// 这里用了find方法,如果userList是空的,或者没有找到对应的id,find会返回undefined
return userList.value.find(user => user.id === currentUserId.value)
})
watch(
currentUser,
(newVal, oldVal) => {
console.log('currentUser变化了:', newVal, oldVal) // 第一次immediate触发时,userList是空的,newVal和oldVal都是undefined;后来如果userList加载成功但没有找到对应的id,newVal还是undefined
},
{ immediate: true }
)
// 模拟异步加载用户列表
setTimeout(() => {
userList.value = [
{ id: 456, name: '张三' },
{ id: 789, name: '李四' }
]
}, 1000)
return { currentUser }
}
}
解决办法
在computed的getter函数里加个默认返回值,避免find、filter、reduce这些数组方法返回undefined或者空数组之外的东西;如果是其他逻辑,也要确保每次调用都有明确的返回值。
比如把刚才的错误示例改成加默认返回值的版本:
// 正确示例5-1:computed加默认返回值
import { ref, computed, watch } from 'vue'
export default {
setup() {
const userList = ref([])
const currentUserId = ref(123)
const currentUser = computed(() => {
// 先加个判断,如果userList是空的,直接返回默认对象
if (!userList.value.length) {
return { id: null, name: '' }
}
// 用find方法找,找不到的话也返回默认对象
const foundUser = userList.value.find(user => user.id === currentUserId.value)
return foundUser || { id: null, name: '' }
})
watch(
currentUser,
(newVal, oldVal) => {
console.log('currentUser变化了:', newVal, oldVal) // 第一次immediate触发时,newVal和oldVal都是默认对象;后来userList加载成功但没找到,newVal还是默认对象;找到了的话,newVal是对应的用户对象,没问题
},
{ immediate: true, deep: true } // 如果要监测currentUser内部属性的变化,需要加deep
)
setTimeout(() => {
userList.value = [
{ id: 456, name: '张三' },
{ id: 789, name: '李四' }
]
}, 1000)
return { currentUser }
}
}
在watch监测computed的时候,也加个有效值判断,只有当newVal是有效的值的时候才处理业务逻辑,避免默认返回值也会触发不需要的逻辑。
比如刚才的例子里,如果只有当currentUser的id不为null的时候才执行业务逻辑,可以这样写:
// 正确示例5-2:watch加有效值判断
watch(
currentUser,
(newVal) => {
if (newVal.id !== null) {
console.log('找到了当前用户,开始执行业务逻辑:', newVal.name)
}
},
{ immediate: true, deep: true }
)
用了shallowRef或者shallowReactive,却监测的是深层属性?
这个场景是Vue3特有的,因为Vue2没有shallowRef和shallowReactive这些浅层响应式的API,很多朋友可能为了性能优化,会对一些大型的对象数组使用shallowRef或者shallowReactive,但如果不小心监测的是它们的深层属性,Vue3的watch可能不会触发,或者第一次触发后就没反应了,甚至有时候会拿到undefined。
原因分析
Vue3的浅层响应式API(shallowRef、shallowReactive、shallowReadonly、triggerRef)的工作原理是:
- shallowRef:只对ref的.value本身的引用变化敏感,不对.value内部的属性变化敏感(除非用triggerRef手动触发);
- shallowReactive:只对对象的第一层属性变化敏感,不对嵌套的深层属性变化敏感。
如果你用了shallowRef或者shallowReactive,却在watch的源参数里监测的是它们的深层属性,那Vue3的依赖追踪器只会追踪到第一次调用源函数时用到的浅层属性,后续深层属性变化时,Vue3不会发现,所以watch不会触发;更惨的是,如果第一次调用源函数时,深层属性还没赋值,那返回的就是undefined,后续虽然可以用triggerRef手动触发,但oldVal永远是undefined。
举个例子:
// 错误示例6:用shallowRef却监测深层属性
import { shallowRef, watch } from 'vue'
export default {
setup() {
// 用shallowRef包裹一个大型的用户对象
const bigUser = shallowRef({
id: 123,
name: '张三',
profile: {
avatar: '',
bio: ''
}
})
// 监测深层属性bigUser.value.profile.avatar
watch(
() => bigUser.value.profile.avatar,
(newVal, oldVal) => {
console.log('头像地址变化了:', newVal, oldVal) // 第一次immediate触发时,avatar是空字符串,没问题;但如果后来直接修改bigUser.value.profile.avatar = 'xxx.jpg',这里永远不会触发!除非用triggerRef(bigUser)手动触发,但手动触发的话,oldVal永远是上一次手动触发或者初始化时的值,不是自动追踪的
},
{ immediate: true }
)
// 模拟点击按钮修改头像地址
const changeAvatar = () => {
bigUser.value.profile.avatar = 'https://example.com/avatar.jpg'
console.log('手动修改头像地址后:', bigUser.value.profile.avatar) // 控制台这里会打印新地址,但watch那边没反应
}
return { bigUser, changeAvatar }
}
}
解决办法
如果确实需要监测深层属性的变化,就不要用浅层响应式API,改用普通的ref或者reactive
虽然普通的响应式API对大型对象数组的性能会有一点影响,但现在的浏览器和设备性能都很强,除非你的对象数组有几千几万条数据,每条数据又有几十上百个嵌套属性,否则普通的响应式API完全够用;如果真的有这么大的数据量,可以考虑用虚拟滚动、分页加载等其他优化方式,而不是用浅层响应式API然后手动处理深层变化,那样反而更麻烦。
比如把刚才的错误示例改成普通ref的版本:
// 正确示例6-1:改用普通ref
import { ref, watch } from 'vue'
export default {
setup() {
const bigUser = ref({
id: 123,
name: '张三',
profile: {
avatar: '',
bio: ''
}
})
watch(
() => bigUser.value.profile.avatar,
(newVal, oldVal) => {
console.log('头像地址变化了:', newVal, oldVal) // 点击changeAvatar,这里会正常打印
},
{ immediate: true }
)
const changeAvatar = () => {
bigUser.value.profile.avatar = 'https://example.com/avatar.jpg'
}
return { bigUser, changeAvatar }
}
}
如果确实需要用浅层响应式API来优化性能,要么监测整个ref的.value引用变化(也就是每次修改深层属性时,都要重新赋值整个.value对象),要么用triggerRef手动触发watch的回调,同时配合手动记录旧值
不过这两种方式都比较麻烦,尤其是手动记录旧值,所以一般不推荐,除非万不得已。
比如监测整个ref的.value引用变化的版本:
// 正确示例6-2:监测整个shallowRef的.value引用变化,每次修改深层属性时重新赋值
import { shallowRef, watch } from 'vue'
export default {
setup() {
const bigUser = shallowRef({
id: 123,
name: '张三',
profile: {
avatar: '',
bio: ''
}
})
watch(
bigUser, // 直接传整个shallowRef对象,监测.value的引用变化
(newVal, oldVal) => {
console.log('bigUser变化了:', newVal.profile.avatar, oldVal.profile.avatar) // 点击changeAvatar,这里会正常打印
},
{ immediate: true }
)
const changeAvatar = () => {
// 每次修改深层属性时,都要用展开运算符或者Object.assign重新赋值整个.value对象,改变引用
bigUser.value = {
...bigUser.value,
profile: {
...bigUser.value.profile,
avatar: 'https://example.com/avatar.jpg'
}
}
}
return { bigUser, changeAvatar }
}
}
好啦,以上就是我整理的6个Vue3 watch undefined的常见场景、原因分析和解决办法,应该能覆盖90%以上的问题了,最后再给大家总结几个通用的排查步骤,下次再碰到watch undefined的情况,可以按这个顺序一步步来:
- 检查响应式目标有没有设置合理的初始值:不管是ref、reactive、prop还是computed,都要尽量有初始值;
- 检查源参数有没有写错:如果是getter函数,有没有用到响应式数据?有没有漏写ref的.value?有没有访问正确的reactive属性路径?
- 检查逻辑顺序有没有搞反:如果是watchEffect,有没有把初始化代码放在前面?如果是异步初始化,有没有加isInitialized判断?
- 检查prop的设置和父组件的传值:子组件有没有声明prop?有没有设置默认值?父组件有没有传?有没有一开始传undefined?
- 检查computed的getter函数:有没有明确的返回值?有没有加默认返回值?
- 检查有没有用浅层响应式API却监测深层属性:如果是,要么改用普通响应式API,要么修改监测方式或者手动触发。
如果大家还有其他Vue3 watch undefined的场景或者问题,欢迎在评论区留言讨论哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


