Code前端首页关于Code前端联系我们

Vue3的watch为什么会监测到undefined?有哪些快速定位和解决的方法?

terry 4小时前 阅读数 45 #Vue

最近后台收到不少刚从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的源参数有两种合法写法:

  1. 直接传响应式对象/数组/ ref/ computed:这种情况下Vue会自动追踪整个源的变化(如果是对象数组的话默认浅追踪,需要加deep才会深追踪);
  2. 传一个返回值的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的情况,可以按这个顺序一步步来:

  1. 检查响应式目标有没有设置合理的初始值:不管是ref、reactive、prop还是computed,都要尽量有初始值;
  2. 检查源参数有没有写错:如果是getter函数,有没有用到响应式数据?有没有漏写ref的.value?有没有访问正确的reactive属性路径?
  3. 检查逻辑顺序有没有搞反:如果是watchEffect,有没有把初始化代码放在前面?如果是异步初始化,有没有加isInitialized判断?
  4. 检查prop的设置和父组件的传值:子组件有没有声明prop?有没有设置默认值?父组件有没有传?有没有一开始传undefined?
  5. 检查computed的getter函数:有没有明确的返回值?有没有加默认返回值?
  6. 检查有没有用浅层响应式API却监测深层属性:如果是,要么改用普通响应式API,要么修改监测方式或者手动触发。

如果大家还有其他Vue3 watch undefined的场景或者问题,欢迎在评论区留言讨论哦!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门