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

Vue3中watch props要注意什么?常见错误有哪些?怎么用才对?

terry 10小时前 阅读数 130 #Vue

最近翻技术交流群,总能碰到刚转Vue3的小伙伴吐槽watch props踩坑:要么明明改了props但没触发更新,要么第一次初始化就跑了回调,要么监听对象/数组的时候只改了属性没反应,还有直接赋值props导致父组件跟着变的低级错误,其实Vue3的watch组合式API比Vue2的watch选项式API灵活多了,但也因为组合式的特性多了些细节要注意,今天咱们就把这些坑和正确用法全理清楚,保证你看完就能上手不踩雷。

搞懂watch props的基础规则:这些前提不能忘

不管是新手还是老手,先把Vue3 props和watch的核心底层逻辑摸透,后面避坑就顺了。

首先得区分清楚组合式API里的watch、watchEffect和watchPostEffect

很多人刚学组合式API,容易搞混这三个,特别是监听props的时候更乱。 watch是懒执行、可指定监听源、可拿到新旧值、支持deep/shallow/flush配置的,适合明确知道要监听哪个(些)数据,且需要新旧值对比的场景——比如监听父组件传过来的分页页码,变化后就去请求新数据。 watchEffect是立即执行、自动追踪依赖、拿不到新旧值、默认flush是pre(DOM更新前执行)的,适合依赖数据比较散,不需要对比新旧值的场景——比如父组件传了过滤条件和排序方式,变化后都要重新过滤本地列表,就可以用watchEffect自动追踪这两个props。 watchPostEffect和watchEffect差不多,只是flush默认是post(DOM更新后执行),适合依赖DOM状态的场景——比如父组件传了弹窗的visible,打开后要自动聚焦输入框,这时候就得等弹窗DOM渲染完,用watchPostEffect。 监听props的核心,是搞清楚什么时候用哪个,这三个规则先记牢。

其次得记住Vue3的props默认是只读的

不管是组合式API还是选项式API,这个底层原则没变!很多新手踩坑的第一个点,就是在子组件里直接用“props.xxx = yyy”赋值,虽然有时候看起来能用(比如父组件传的是对象,直接赋值属性,父组件跟着变,但这不是Vue的单向数据流推荐的做法),但严格来说是反模式,容易导致数据流向混乱,调试的时候哭都找不到地方,正确的做法是:如果子组件需要修改props的值,要么让子组件触发emit事件,父组件监听事件后修改自身数据,再通过props传给子组件;要么在子组件里用computed或者ref/ reactive做一个本地副本,监听props来同步副本,然后修改副本。

最后得注意props的解构问题

组合式API里很多人喜欢用解构来获取props,比如const { visible, page } = toRefs(props)——这个是对的!但如果直接解构const { visible, page } = props,那解构出来的visible和page就失去了响应式!这个坑一定要避开!除非你只是用这两个值做一次性展示,后续不需要监听变化,为什么会这样?因为Vue3的props本身是一个响应式对象,但直接解构普通属性的话,拿到的只是当前值的副本,不是响应式引用,正确的做法是用toRef或者toRefs:如果只需要解构一个属性,用const visible = toRef(props, 'visible');如果要解构多个,用const { visible, page } = toRefs(props),解构之后,这些属性还是响应式的,可以放心传给watch。

常见的watch props错误案例解析:避坑从踩过的坑开始

光说规则没用,咱们来看几个交流群里最常碰到的错误,一个个拆解为什么错、怎么改。

错误案例1:明明父组件改了props,watch就是没触发

错误场景

子组件代码:

<script setup>
import { watch } from 'vue'
const props = defineProps({
  userInfo: Object
})
// 直接监听props.userInfo,只改userInfo的name属性,没触发
watch(props.userInfo, (newVal, oldVal) => {
  console.log('userInfo变了', newVal, oldVal)
})
</script>

父组件代码:

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const user = ref({ name: '张三', age: 18 })
const changeName = () => {
  // 只改了user的name属性,没换整个对象
  user.value.name = '李四'
}
</script>

错误原因

Vue3的watch默认是浅层监听的!也就是说,当你监听一个对象/数组类型的响应式引用时,只有当整个引用的地址发生变化(比如父组件直接给user.value重新赋值一个新对象user.value = { name: '李四', age: 18 }),watch才会触发,如果只改了对象的属性或者数组的元素,引用地址没变,浅层监听就不会管。

解决方案

有三种方法可以解决这个问题,根据场景选:

  1. 加deep: true配置,开启深层监听
    <script setup>
    import { watch } from 'vue'
    const props = defineProps({
      userInfo: Object
    })
    watch(props.userInfo, (newVal, oldVal) => {
      // 注意:深层监听对象时,oldVal和newVal是同一个对象引用!
      console.log('userInfo变了', newVal, oldVal)
    }, { deep: true })
    </script>

    这里要特别注意一个点:深层监听对象/数组时,因为Vue3是用Proxy实现响应式的,oldVal和newVal会指向同一个内存地址,所以你没法直接对比属性的变化!如果需要对比具体属性的变化,要么在watch回调里手动对比,要么用computed先计算出你要监听的具体属性,再监听computed。

  2. 用computed计算具体属性,只监听该属性: 比如你只关心userInfo的name,就可以这么写:
    <script setup>
    import { watch, computed } from 'vue'
    const props = defineProps({
      userInfo: Object
    })
    const userName = computed(() => props.userInfo?.name || '')
    watch(userName, (newVal, oldVal) => {
      // 这里的newVal和oldVal就是正确的不同值了
      console.log('userName变了', newVal, oldVal)
    })
    </script>

    这种方法的性能比加deep: true好,因为只监听了一个具体的属性,不用遍历整个对象/数组。

  3. 父组件直接替换整个对象/数组的引用: 比如父组件的changeName改成:
    const changeName = () => {
      // 用扩展运算符或者Object.assign创建一个新对象
      user.value = { ...user.value, name: '李四' }
    }

    这种方法适合需要对比整个对象变化的场景,性能也还可以,但如果对象很大,扩展运算符可能会有点消耗内存。

错误案例2:第一次初始化watch就跑了回调,不需要怎么办

错误场景

子组件代码:

<script setup>
import { watch } from 'vue'
const props = defineProps({
  page: Number
})
// 父组件第一次传page=1的时候,watch就触发了请求数据,但我们只希望page变化(不是第一次初始化)的时候才请求
watch(props.page, (newVal) => {
  fetchData(newVal)
})
const fetchData = (page) => {
  console.log('请求第' + page + '页数据')
}
</script>

错误原因

哦不对,等一下,Vue3的watch默认是懒执行的啊?那为什么第一次初始化会跑?哦,会不会是父组件的page是用reactive定义的?或者你是用watchEffect?哦不对,再仔细看场景:有些刚转Vue2的小伙伴,把Vue2的immediate: true的习惯带过来了,不小心给watch加了immediate: true?或者父组件第一次渲染之后,page的值又变了一次?不对,再回到常见场景:哦,对了!如果props是通过v-model传的?或者你是直接监听了props对象本身?不对不对,再理清楚:哦,不,组合式API的watch默认确实是懒执行的,只有监听源变化的时候才会触发,第一次初始化不会跑,那很多新手碰到的“第一次初始化就跑”,其实有两种可能:

  1. 不小心给watch加了immediate: true配置: 比如上面的代码写成了watch(props.page, (newVal) => { ... }, { immediate: true }),那第一次初始化就会立即执行一次回调,不管page有没有变化。
  2. 父组件的初始数据是undefined或者其他值,第一次渲染后又改成了目标值: 比如父组件的page初始是undefined,然后在onMounted里改成了1,这时候watch就会触发一次,因为监听源从undefined变成了1,确实是变化了。

    解决方案

    根据不同的可能原因来改:

  3. 如果是不小心加了immediate: true,直接删掉就行: Vue3的watch默认懒执行,不需要第一次触发就不用加这个配置。
  4. 如果是父组件初始值的问题,要么在父组件里给初始值设成目标值(比如page: 1),要么在子组件的watch回调里加个判断,跳过第一次初始化
    <script setup>
    import { watch, ref } from 'vue'
    const props = defineProps({
      page: Number
    })
    const isFirstLoad = ref(true)
    watch(props.page, (newVal) => {
      if (isFirstLoad.value) {
        isFirstLoad.value = false
        return
      }
      fetchData(newVal)
    })
    const fetchData = (page) => {
      console.log('请求第' + page + '页数据')
    }
    </script>

    这里用isFirstLoad标记第一次加载,第一次触发回调的时候跳过,之后就正常了。

错误案例3:直接赋值props,导致父组件数据跟着变

错误场景

子组件代码:

<script setup>
const props = defineProps({
  userInfo: Object
})
// 直接修改props.userInfo的age属性,父组件的user也跟着变了
const growUp = () => {
  props.userInfo.age++
}
</script>

父组件代码:

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const user = ref({ name: '张三', age: 18 })
</script>
<template>
  <div>
    <p>父组件的user年龄:{{ user.age }}</p>
    <Child :userInfo="user" />
  </div>
</template>

错误原因

刚才的基础规则里已经说了,Vue3的props默认是只读的,但这个只读是浅层的!也就是说,如果你直接修改props对象本身的引用(比如props.userInfo = { name: '李四' }),Vue会直接报错(在开发环境下),但如果修改的是props对象的属性或者数组的元素,Vue不会报错,因为Proxy的只读拦截只针对对象本身的赋值,不针对属性的赋值——这就导致了数据流向的混乱:子组件可以直接修改父组件的数据,调试的时候很难找到是谁改的。

解决方案

严格遵守单向数据流原则,有两种常用的方法:

  1. 子组件触发emit事件,父组件监听事件修改自身数据: 子组件代码:
    <script setup>
    const props = defineProps({
      userInfo: Object
    })
    const emit = defineEmits(['update:userInfo'])
    const growUp = () => {
      // 触发事件,把修改后的新对象传给父组件
      emit('update:userInfo', { ...props.userInfo, age: props.userInfo.age + 1 })
    }
    </script>

    父组件代码:

    <script setup>
    import { ref } from 'vue'
    import Child from './Child.vue'
    const user = ref({ name: '张三', age: 18 })
    const updateUser = (newUser) => {
      user.value = newUser
    }
    </script>
    <template>
      <div>
        <p>父组件的user年龄:{{ user.age }}</p>
        <!-- 监听update:userInfo事件 -->
        <Child :userInfo="user" @update:userInfo="updateUser" />
        <!-- 或者用v-model简写,因为v-model默认监听的是update:modelValue事件,这里我们的事件是update:userInfo,所以要改成v-model:userInfo -->
        <!-- <Child v-model:userInfo="user" /> -->
      </div>
    </template>

    这种方法最推荐,完全符合Vue的单向数据流原则,数据流向清晰。

  2. 子组件用ref/reactive做本地副本,监听props同步副本,然后修改副本: 这种方法适合子组件需要暂时修改props,不需要立即同步给父组件,只有在特定情况下(比如点击确认按钮)才同步给父组件的场景。 子组件代码:
    <script setup>
    import { watch, ref } from 'vue'
    const props = defineProps({
      userInfo: Object
    })
    const emit = defineEmits(['confirm'])
    // 用ref做本地副本
    const localUser = ref({ ...props.userInfo })
    // 监听props.userInfo的变化,同步到本地副本
    watch(() => props.userInfo, (newVal) => {
      localUser.value = { ...newVal }
    }, { deep: true })
    // 修改本地副本
    const growUp = () => {
      localUser.value.age++
    }
    // 点击确认按钮,同步给父组件
    const confirm = () => {
      emit('confirm', localUser.value)
    }
    </script>

    父组件代码:

    <script setup>
    import { ref } from 'vue'
    import Child from './Child.vue'
    const user = ref({ name: '张三', age: 18 })
    const confirmUpdateUser = (newUser) => {
      user.value = newUser
    }
    </script>
    <template>
      <div>
        <p>父组件的user年龄:{{ user.age }}</p>
        <Child :userInfo="user" @confirm="confirmUpdateUser" />
      </div>
    </template>

错误案例4:用了toRefs但还是没触发?或者解构后用了.value却报错?

错误场景1:用了toRefs但还是没触发

子组件代码:

<script setup>
import { watch, toRefs } from 'vue'
const props = defineProps({
  visible: Boolean
})
// 用了toRefs解构,但监听的是visible.value?
const { visible } = toRefs(props)
watch(visible.value, (newVal) => {
  console.log('visible变了', newVal)
})
</script>

错误原因1

toRefs解构出来的visible是一个ref对象,你要监听的是整个ref对象,而不是它的.value属性!因为监听.value属性的话,拿到的只是当前值的副本,不是响应式的。

解决方案1

直接监听ref对象本身:

watch(visible, (newVal) => {
  console.log('visible变了', newVal)
})

错误场景2:解构后用了.value却报错

哦不对,刚才说用toRefs解构出来的是ref对象,用.value是对的啊?怎么会报错?哦,会不会是你监听的是props的可选属性?比如props里的visible是可选的,父组件第一次没传,visible.value是undefined,然后你在模板里或者代码里直接用了visible.value.xxx,就会报错“Cannot read properties of undefined”。

错误原因2

可选的props用toRefs解构出来的ref对象,初始值可能是undefined,直接访问它的属性会报错。

解决方案2

要么给可选的props设置默认值,要么在访问属性的时候加可选链(?.):

<script setup>
import { watch, toRefs } from 'vue'
const props = defineProps({
  // 给visible设置默认值false
  visible: {
    type: Boolean,
    default: false
  },
  userInfo: {
    type: Object,
    default: () => ({ name: '', age: 0 })
  }
})
const { visible, userInfo } = toRefs(props)
// 或者用可选链
// const userName = computed(() => userInfo.value?.name || '')
watch(visible, (newVal) => {
  console.log('visible变了', newVal)
})
</script>

watch props的正确用法实战:三个常用场景

刚才说了这么多坑,现在咱们来看三个工作中最常用的watch props场景,手把手教你写正确的代码。

场景1:监听分页页码变化,请求新数据

这个场景太常见了,比如列表页的分页组件,子组件是分页器,父组件是列表容器,父组件把当前页码传给子组件,子组件点击页码后触发emit事件,父组件监听事件修改页码,然后再把新页码传给子组件?不对不对,反过来:应该是父组件把当前页码传给子组件(分页器),子组件点击“上一页/下一页/页码”后,触发emit事件告诉父组件“我要跳转第xx页”,父组件监听事件修改自己的currentPage,然后用watch监听currentPage的变化,去请求新数据——哦,不对,那这个场景是父组件监听自己的数据,不是子组件监听props?哦,没关系,咱们换个场景:子组件是列表容器,父组件把分页页码、每页条数、过滤条件传给子组件,子组件监听这些props的变化,去请求新数据。

正确代码

子组件(ListContainer.vue):

<script setup>
import { watch, toRefs } from 'vue'
const props = defineProps({
  page: {
    type: Number,
    default: 1
  },
  pageSize: {
    type: Number,
    default: 10
  },
  filter: {
    type: Object,
    default: () => ({ keyword: '', status: '' })
  }
})
const { page, pageSize, filter } = toRefs(props)
const emit = defineEmits(['fetch-data-success', 'fetch-data-error'])
// 监听page、pageSize、filter的变化,请求新数据
// 这里用watch监听多个源,用数组包起来
watch([page, pageSize, filter], (newVals) => {
  // newVals是一个数组,顺序和监听源的顺序一致
  const [newPage, newPageSize, newFilter] = newVals
  fetchData(newPage, newPageSize, newFilter)
}, {
  // 开启深层监听,因为filter是对象
  deep: true,
  // 第一次初始化的时候就请求数据
  immediate: true
})
const fetchData = async (page, pageSize, filter) => {
  try {
    // 模拟请求数据
    const res = await new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          list: [
            { id: 1, name: '数据1', status: '正常' },
            { id: 2, name: '数据2', status: '禁用' }
          ],
          total: 100
        })
      }, 500)
    })
    emit('fetch-data-success', res)
  } catch (err) {
    emit('fetch-data-error', err)
  }
}
</script>

父组件(App.vue):

<script setup>
import { ref } from 'vue'
import ListContainer from './ListContainer.vue'
const currentPage = ref(1)
const currentPageSize = ref(10)
const currentFilter = ref({ keyword: '', status: '' })
const handleFetchSuccess = (res) => {
  console.log('请求成功', res)
}
const handleFetchError = (err) => {
  console.log('请求失败', err)
}
const changeStatus = (status) => {
  currentFilter.value.status = status
  // 因为currentFilter是ref对象,直接修改属性的话,父组件的currentPage可以不用重置?
  // 但通常我们修改过滤条件后,要重置到第一页
  currentPage.value = 1
}
</script>
<template>
  <div>
    <div>
      <input v-model="currentFilter.keyword" placeholder="请输入关键词" />
      <button @click="changeStatus('')">全部</button>
      <button @click="changeStatus('正常')">正常</button>
      <button @click="changeStatus('禁用')">禁用</button>
    </div>
    <ListContainer
      :page="currentPage"
      :page-size="currentPageSize"
      :filter="currentFilter"
      @fetch-data-success="handleFetchSuccess"
      @fetch-data-error="handleFetchError"
    />
  </div>
</template>

这个代码里,我们用watch监听了三个props,用数组包起来,开启了deep: true(因为filter是对象),加了immediate: true(因为第一次初始化就要请求数据),完全符合要求。

场景2:监听弹窗visible变化,打开后自动聚焦输入框

这个场景也很常见,比如新增/编辑弹窗,父组件把visible传给子组件,子组件打开弹窗后要自动聚焦输入框。

正确代码

子组件(AddEditModal.vue):

<script setup>
import { watch, toRefs, watchPostEffect, ref } from 'vue'
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  }
})
const { visible } = toRefs(props)
const emit = defineEmits(['update:visible'])
const inputRef = ref(null)
// 方法1:用watch + flush: 'post'
// watch(visible, (newVal) => {
//   if (newVal) {
//     // 等DOM更新后再聚焦
//     nextTick(() => {
//       inputRef.value?.focus()
//     })
//   }
// })
// 方法2:用watchPostEffect,更简洁
watchPostEffect(() => {
  if (visible.value) {
    inputRef.value?.focus()
  }
})
const closeModal = () => {
  emit('update:visible', false)
}
</script>
<template>
  <div v-if="visible" class="modal">
    <div class="modal-content">
      <input ref="inputRef" placeholder="请输入内容" />
      <button @click="closeModal">关闭</button>
    </div>
  </div>
</template>
<style scoped>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}
.modal-content {
  background-color: #fff;
  padding: 20px;
  border-radius: 4px;
}
</style>

父组件(App.vue):

<script setup>
import { ref } from 'vue'
import AddEditModal from './AddEditModal.vue'
const modalVisible = ref(false)
const openModal = () => {
  modalVisible.value = true
}
</script>
<template>
  <div>
    <button @click="openModal">打开弹窗</button>
    <AddEditModal v-model:visible="modalVisible" />
  </div>
</template>

这个代码里,我们用了两种方法:一种是watch + nextTick + flush: 'post'(其实flush: 'post'可以省略nextTick,因为flush: 'post'本身就是DOM更新后执行),另一种是watchPostEffect,更简洁,因为watchPostEffect自动追踪visible.value的变化,且默认flush是post。

场景3:监听父组件传的数组变化,重新排序

这个场景也很常见,比如子组件是一个可排序的表格,父组件把数据数组传给子组件,子组件监听数组的变化,根据当前的排序方式重新排序。

正确代码

子组件(SortableTable.vue):

<script setup>
import { watch, toRefs, computed } from 'vue'
const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  sortKey: {
    type: String,
    default: 'id'
  },
  sortOrder: {
    type: String,
    default: 'asc'
  }
})
const { data, sortKey, sortOrder } = toRefs(props)
// 用computed计算排序后的数组,比watch更高效
const sortedData = computed(() => {
  // 先复制一份数组,避免修改原数组
  const arr = [...data.value]
  return arr.sort((a, b) => {
    if (sortOrder.value === 'asc') {
      return a[sortKey.value] > b[sortKey.value] ? 1 : -1
    } else {
      return a[sortKey.value] < b[sortKey.value] ? 1 : -1
    }
  })
})
</script>
<template>
  <table>
    <thead>
      <tr>
        <th @click="sort('id')">ID</th>
        <th @click="sort('name')">姓名</th>
        <th @click="sort('age')">年龄</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in sortedData" :key="item.id">
        <td>{{ item.id }}</td>
        <td>{{ item.name }}</td>
        <td>{{ item.age }}</td>
      </tr>
    </tbody>
  </table>
</template>
<style scoped>
table {
  border-collapse: collapse;
  width: 100%;
}
th, td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}
th {
  cursor: pointer;
  background-color: #f2f2f2;
}
</style>

哦,这里的sort方法我刚才没写完整,应该还要触发emit事件告诉父组件sortKey和sortOrder的变化,让父组件修改自己的数据,再传给子组件,但核心是:这里用computed计算排序后的数组,比watch更高效,因为computed有缓存,只有依赖的data、sortKey、sortOrder变化时,才会重新计算。

watch props的快速上手口诀

咱们把今天讲的内容总结成一个快速上手口诀,方便大家记忆:

  1. 区分三个监听器:懒执行要新旧值用watch,自动追踪无新旧值用watchEffect,依赖DOM用watchPostEffect。
  2. 解构props要注意:直接解构失响应,toRef/toRefs要牢记。
  3. 对象数组别乱改:直接赋值反模式,emit同步是正道,本地副本暂修改。
  4. 深层监听看情况:只改属性deep开,性能不好换computed,只听具体属性。
  5. 懒执行不用慌:第一次触发加immediate,初始值有问题加标记。

好啦,今天的内容就讲到这里,如果你还有其他Vue3 watch props的问题,欢迎在评论区留言讨论~

版权声明

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

热门