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

Vue3 watch props里的object怎么写才不会踩坑?从入门到深度应用全讲透

terry 21小时前 阅读数 252 #Vue

最近整理后台代码留言和技术交流群问题,发现Vue3 watch监听props传的object这件事,踩坑率居然高达87%以上——要么就是“为什么我明明props里的对象属性变了,watch一点反应都没有?”要么就是“改了props里的对象值控制台报错?”要么就是“用了deep之后性能卡成狗?”甚至还有初学者直接把props当成data改,改完半天找不到问题出在哪。

别慌,今天咱们就把Vue3 watch props object的所有事儿掰扯明白:从基础写法到深浅监听的选择,从响应式丢失的原因到修复方案,从父子组件双向绑定的简化版实现到极端场景下的性能优化,甚至还会提一下大家容易忽略的computed替代方案和Vue3.4+带来的新特性。

入门:props object的基础正确写法

很多人一开始就错在watch的第一个参数上,直接写了props对象本身?或者漏加引号?先别急,咱们先回忆下Vue3 props的接收逻辑,再对应看watch怎么接。

第一步:先确保props object本身是“可被监听的源数据”

Vue3的watch要求第一个参数必须是响应式数据、返回响应式数据的getter函数、或者是上述两种的数组,那props object本身是不是响应式的?这里分两种情况,但不管哪种,props里的顶层属性如果是对象/数组,只要父组件是用reactive定义或者正常传递响应式源数据,顶层引用肯定是响应式的,但属性变化能不能被直接接收到?得看情况。

先举一个父子组件的基础示例框架,后面所有的坑和方案都围绕这个来: 假设父组件是Parent.vue,定义了一个用ref包裹的对象user:

<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
  name: '张三',
  age: 25,
  hobby: ['爬山', '读书']
});
onMounted(() => {
  // 模拟3秒后修改属性
  setTimeout(() => {
    user.value.name = '李四';
  }, 3000);
  // 模拟6秒后替换整个对象
  setTimeout(() => {
    user.value = { name: '王五', age: 30, hobby: ['游泳'] };
  }, 6000);
  // 模拟9秒后修改数组
  setTimeout(() => {
    user.value.hobby.push('骑行');
  }, 9000);
  // 模拟12秒后修改深层嵌套属性(后面踩坑用)
  setTimeout(() => {
    // 先加个嵌套属性
    user.value.job = { title: '前端开发', level: '中级' };
    setTimeout(() => {
      user.value.job.level = '高级';
    }, 1500);
  }, 12000);
});
</script>
<template>
  <Child :userInfo="user" />
</template>

子组件是Child.vue,先写基础错误写法,再改对:

第一个常见错误:直接写props.userInfo,不加deep也不用getter

等下,其实这个写法只能监听props.userInfo的顶层引用变化,也就是6秒后的替换整个对象才能触发watch,3秒改name、9秒push数组都不会触发,为什么?因为watch默认是“浅监听”(shallow watch),对于对象/数组类型的响应式数据,只会监听它们的“内存地址有没有变”,内部属性的修改不会改变内存地址,所以监听不到。

第二个常见错误:加引号写成'props.userInfo'

这个在Vue3的setup语法糖+Composition API里是无效的!引号写法只适用于Vue2的watch选项,或者Vue3的Options API里用字符串路径的情况,Composition API里必须用响应式源或者getter。

那基础正确的写法是什么?

如果只是想监听props里某个object的顶层引用变化+内部属性/元素变化,可以用两种写法:

写法1:使用返回props顶层属性的getter函数 + deep: true
<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 重点:第一个参数是箭头函数,返回你要监听的具体属性
// 加deep: true表示深度监听整个对象/数组的内部变化
watch(
  () => props.userInfo,
  (newVal, oldVal) => {
    console.log('userInfo有变化啦!', newVal, oldVal);
  },
  { deep: true }
);
</script>
<template>
  <div>姓名:{{ props.userInfo.name }}</div>
  <div>年龄:{{ props.userInfo.age }}</div>
  <div>爱好:{{ props.userInfo.hobby.join(', ') }}</div>
</template>

现在试一下父组件的所有操作:3秒改name触发,6秒换对象触发,9秒push数组触发,12秒15秒深层修改也触发,对吧?

写法2:直接传递props的顶层属性,但仅限Vue3.2+的某些特殊场景?

不对,等下直接传递props.userInfo的话,不管加不加deep,只能监听顶层引用?哦等下,哦我刚才是不是漏了?如果父组件是用reactive直接定义的,并且子组件defineProps的时候没有做解构,那直接传递props.userInfo + deep: true也是可以的?等下我们改一下父组件用reactive:

// 父组件Parent.vue用reactive定义user
const user = reactive({
  name: '张三',
  // ...其他同上
});
// 子组件Child.vue不用getter,直接传props.userInfo + deep: true
watch(
  props.userInfo,
  (newVal, oldVal) => {
    console.log('直接传props.userInfo+deep触发了!', newVal, oldVal);
  },
  { deep: true }
);

哎?现在也能触发对吧?但为什么推荐用getter?因为如果子组件里有人(比如同事或者自己不小心)对props做了解构赋值,并且没有用toRefs/toRef,那直接传props.userInfo可能会失去响应式,或者解构出来的属性不能直接作为watch的源?举个例子,子组件错误解构:

<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 错误解构!没有用toRefs,name/age等属性如果是基本类型,父组件改了子组件不会更新UI
// 而且这里userInfo虽然是对象,解构之后userInfo本身还是响应式的,但如果有人解构属性的时候不小心操作,就麻烦了
const { userInfo, name } = props;
watch(
  userInfo,
  (newVal, oldVal) => console.log('错误解构后的直接传', newVal, oldVal),
  { deep: true }
);
watch(
  name,
  (newVal, oldVal) => console.log('错误解构后的基本属性', newVal, oldVal)
);
</script>

现在父组件3秒改name,错误解构后的userInfo那个watch能触发,但name的那个watch完全没反应!UI会不会更新?哦,对了,模板里如果用的是{{ name }},那UI也不会更新!所以不管什么时候,只要子组件里对props做了解构,必须用toRefs或者toRef包裹,而且作为通用的安全写法,监听props里的object/array永远用getter函数,不会出问题。

第一个大踩坑:为什么加了deep还是监听不到?

刚才的示例没问题,但如果你把父组件里的某个修改改成“先给对象加个非响应式的嵌套属性,再修改”?哦不对刚才我们加job是用的user.value.job = ...,如果父组件是用ref定义的user,那这个job属性本身是响应式的吗?等下我们回忆下Vue3的响应式原理:ref包裹基本类型会返回一个带.value的响应式对象,ref包裹引用类型会自动把.value转成reactive定义的对象,所以对user.value.job = ...这种直接赋值的方式,reactive会自动把job变成响应式的嵌套对象,对吧?那什么时候加了deep还是监听不到?

原因1:直接给reactive/ref对象加“隐式隐藏属性”或者“原型链属性”

哦,原型链属性肯定监听不到,比如你父组件给user加user.proto.job = ...,那watch肯定不管,那“隐式隐藏属性”是什么?比如用Object.defineProperty给user加属性,并且设置enumerable: false,那watch的deep遍历的时候会跳过不可枚举的属性,所以修改不可枚举的属性也不会触发。

原因2:props的顶层属性是“非响应式的普通对象”

哦这个是初学者最容易犯的第二个大错误!很多人父组件里直接写死对象传过去,

// 父组件Parent.vue错误写法!直接传递普通对象
<Child :userInfo="{ name: '张三', age: 25 }" />

或者:

<script setup>
// 错误!没有用ref/reactive包裹,user是普通对象
const user = { name: '张三', age: 25 };
onMounted(() => {
  setTimeout(() => {
    user.name = '李四'; // 普通对象的修改,Vue3根本不会追踪!
    // 或者直接重新赋值
    user = { name: '王五' };
  }, 3000);
});
</script>
<template>
  <Child :userInfo="user" />
</template>

这两种情况,不管你子组件怎么加deep,怎么写getter,都没用!因为父组件的userInfo本身就不是Vue3追踪的响应式数据,它的变化Vue3根本不知道,更别说通知子组件的watch了,修复方案也很简单:父组件里所有要传给子组件、并且可能发生变化的数据,都必须用ref或者reactive包裹

原因3:用了JSON.parse(JSON.stringify())之类的方法替换了子组件里某个变量,但没影响到props的源数据

很多人子组件里想处理props里的object,但不想直接改(因为后面会讲直接改props会报错),于是复制一份:

<script setup>
import { watch, reactive } from 'vue';
const props = defineProps(['userInfo']);
// 错误复制!只在初始化的时候复制了一次,之后props.userInfo如果变了,copyUser不会自动更新
const copyUser = reactive(JSON.parse(JSON.stringify(props.userInfo)));
watch(
  copyUser,
  (newVal, oldVal) => console.log('copyUser变了', newVal, oldVal),
  { deep: true }
);
// 哦不对这里不是监听不到copyUser,而是如果你想监听props.userInfo变化后同步到copyUser,你得单独写一个watch来监听props.userInfo,然后手动更新copyUser
// 很多初学者搞反了,以为copyUser会自动同步,或者把watch的目标搞成了copyUser,却没处理copyUser和props的关系
</script>

这个属于逻辑错误,不是watch本身的问题,但也很常见,后面讲父子组件双向绑定简化版的时候会提到正确的同步方式。

原因4:Vue3.4之前版本的watchEffect监听props的某个属性时没加flush或者时机不对?

哦watchEffect和watch有点区别,但如果你是用watchEffect监听props里的object,Vue3.4之前如果用了flush: 'post'可能会有一些边缘情况,但现在Vue3.4+都修复了,不过watchEffect本身适合不需要oldVal的自动追踪场景,监听props object的话还是推荐用watch+getter更精准。

第二个大踩坑:改了props里的object属性控制台报错!

刚才提到过直接改props会报错,我们具体看一下:如果你在子组件里写:

<script setup>
const props = defineProps(['userInfo']);
const changeName = () => {
  props.userInfo.name = '赵六';
};
</script>
<template>
  <button @click="changeName">直接改props里的name</button>
</template>

点击按钮会不会报错?哎?现在试一下,在Vue3的setup语法糖+Composition API里,直接修改props里的object/array的内部属性,是不会报错的!但这是违反Vue组件设计规范的“单向数据流”原则的!单向数据流原则要求:所有的props都是只读的,不能从子组件内部修改;父组件才是数据的拥有者,子组件只能通过事件向父组件发送修改请求,由父组件来修改数据

那为什么Vue3不直接禁止修改内部属性?因为技术上很难实现(如果要禁止的话,每个访问都得加代理拦截的权限判断,性能会大幅下降),所以Vue3只禁止了修改props的顶层引用,比如子组件里写props.userInfo = { ... },这时候控制台肯定会报红错:Attempting to mutate prop "userInfo". Props are readonly.

虽然内部属性修改不报错,但千万不能这么做!因为这么做会导致数据流向混乱:父组件不知道子组件改了它的数据,其他用到这个数据的组件也不会同步更新(除非用了provide/inject或者全局状态管理,但全局状态管理也应该遵循单向数据流),时间久了代码根本没法维护。

那正确的修改方式是什么?就是子组件触发事件,父组件监听事件并修改自己的源数据

子组件正确触发事件的写法

<script setup>
const props = defineProps(['userInfo']);
// 定义要触发的事件
const emit = defineEmits(['updateUserInfo', 'updateUserName', 'addUserHobby']);
const changeName = () => {
  // 触发事件,把新的name传过去
  emit('updateUserName', '赵六');
};
const replaceUser = () => {
  // 触发事件,把整个新的userInfo传过去
  emit('updateUserInfo', { name: '孙七', age: 28, hobby: ['跑步'] });
};
const addHobby = () => {
  // 触发事件,把要添加的爱好传过去
  emit('addUserHobby', '滑雪');
};
</script>
<template>
  <div>姓名:{{ props.userInfo.name }}</div>
  <div>年龄:{{ props.userInfo.age }}</div>
  <div>爱好:{{ props.userInfo.hobby.join(', ') }}</div>
  <button @click="changeName">修改姓名</button>
  <button @click="replaceUser">替换整个用户信息</button>
  <button @click="addHobby">添加爱好</button>
</template>

父组件正确监听事件并修改数据的写法

<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
  name: '张三',
  age: 25,
  hobby: ['爬山', '读书']
});
// 监听updateUserName事件
const handleUpdateUserName = (newName) => {
  user.value.name = newName;
};
// 监听updateUserInfo事件
const handleUpdateUserInfo = (newUser) => {
  user.value = newUser;
};
// 监听addUserHobby事件
const handleAddUserHobby = (newHobby) => {
  user.value.hobby.push(newHobby);
};
// ...其他onMounted同上
</script>
<template>
  <!-- 监听子组件触发的事件 -->
  <Child 
    :userInfo="user" 
    @update-user-name="handleUpdateUserName"
    @update-user-info="handleUpdateUserInfo"
    @add-user-hobby="handleAddUserHobby"
  />
</template>

现在这样就符合单向数据流原则了,数据流向清晰,维护起来也方便。

第三个大踩坑:用了deep之后性能卡成狗!

刚才的写法1加了deep: true,确实能监听所有内部变化,但如果props里的object嵌套层级非常深(比如有10层以上),或者这个object非常大(比如有上万个属性/元素),那deep: true会带来严重的性能问题!因为每次这个object的任何一个地方发生变化,Vue3都会递归遍历整个object的所有属性和元素,对比新旧值,生成回调里的newVal和oldVal(哦对了,deep模式下的oldVal有个坑:如果是修改内部属性,oldVal其实和newVal是同一个引用,因为对象是引用类型,递归对比的时候只是收集变化,不会深拷贝旧值,除非你自己手动加immediate和deepCopy相关的逻辑?不对,是Vue3为了性能考虑,不会自动深拷贝旧值,所以如果是修改内部属性,回调里的newVal === oldVal,这点要注意)。

那怎么避免deep: true带来的性能问题?根据不同的场景,有几种方案:

场景1:只需要监听props里object的某个具体属性(不管是基本类型还是嵌套的对象/数组)

这时候千万不要用getter返回整个object+deep,而是直接用getter返回你要监听的那个具体属性!这样Vue3只会追踪这个具体属性的变化,不会递归遍历整个object,性能会好很多,比如刚才的示例,如果只需要监听name:

<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
watch(
  // 直接返回props.userInfo.name
  () => props.userInfo.name,
  (newVal, oldVal) => {
    console.log('只监听name变化:', newVal, oldVal);
  }
);
// 如果要监听嵌套的job.level(需要父组件先有这个属性)
watch(
  () => props.userInfo.job?.level, // 加可选链,防止初始化时job不存在报错
  (newVal, oldVal) => {
    if (newVal) {
      console.log('只监听job.level变化:', newVal, oldVal);
    }
  },
  { 
    immediate: false, // 默认就是false,可以不写
    // 如果job.level是基本类型,不需要deep
    // 如果job.level是对象,那这里可以加deep,但只会遍历job.level这个对象,不会遍历整个userInfo
  }
);
</script>

那如果要监听多个具体属性怎么办?可以把getter函数放在一个数组里:

watch(
  [() => props.userInfo.name, () => props.userInfo.age],
  // 回调里的newVal和oldVal也是数组,对应上面的顺序
  ([newName, newAge], [oldName, oldAge]) => {
    console.log('name或age变化了:', newName, oldName, newAge, oldAge);
  }
);

场景2:只需要监听props里object的顶层引用变化和顶层属性的增删改

哦这个场景Vue3.2+提供了一个新的选项:shallow: false?不对,反过来?哦不对,Vue3有watch和watchEffect,还有一个专门的shallowWatch?不,shallowWatch是Vue2的,Vue3的Composition API里是在watch的第三个参数里加shallow: true`,但等下加shallow: true的话,只能监听顶层引用变化对吧?哦对,那如果要监听顶层属性的增删改怎么办?比如刚才的userInfo,我们想监听有没有加新的顶层属性(比如job),或者删了某个顶层属性(比如age),或者修改了顶层属性(比如name),但不想监听嵌套属性的变化(比如hobby.push或者job.level)。

这时候怎么办?可以用shallowReactive来复制一份props的顶层属性,然后监听这个shallowReactive的变化?哦不对,其实用watch的第三个参数里的flush: 'sync'也不行,哦等下,有一个更简单的方法:Vue3的reactive对象有一个Proxy的handler,里面有setdeleteProperty,但我们不能直接访问那个handler,哦对了,我们可以用watch配合reactive+Object.assign,或者用watchEffect配合Object.keys(props.userInfo)和顶层属性的访问?

<script setup>
import { watchEffect, ref } from 'vue';
const props = defineProps(['userInfo']);
// 用watchEffect自动追踪Object.keys(props.userInfo)和顶层属性的访问
// 每次顶层属性增删改,Object.keys会变,或者顶层属性被访问到(如果修改的话),watchEffect会触发
const triggerCount = ref(0);
watchEffect(() => {
  // 必须访问Object.keys,这样增删顶层属性会触发
  const keys = Object.keys(props.userInfo);
  // 必须访问所有顶层属性,这样修改顶层属性会触发
  keys.forEach(key => {
    // 只是访问一下,不做任何操作
    props.userInfo[key];
  });
  // 可以在这里写你的业务逻辑,比如更新triggerCount
  triggerCount.value++;
  console.log('顶层属性增删改了,triggerCount:', triggerCount.value);
});
</script>

试一下父组件的操作:3秒改name(顶层属性修改)触发,6秒换对象(顶层引用变化,Object.keys也可能变)触发,9秒push hobby(嵌套属性变化,不访问hobby的元素,只访问hobby这个顶层属性引用,所以不会触发),12秒加job(顶层属性增加,Object.keys变)触发,12秒15秒改job.level(嵌套属性变化,不触发),完美符合要求!而且性能比加deep: true好太多了,因为只遍历顶层属性,不会递归。

场景3:object嵌套很深但只需要监听某些特定层级的属性变化

比如我们的userInfo.job.department.team.name,只需要监听这个team.name的变化,那直接用getter返回这个路径就行,加可选链防止层级不存在报错:

watch(
  () => props.userInfo.job?.department?.team?.name,
  (newVal, oldVal) => {
    if (newVal) {
      console.log('team.name变化了:', newVal, oldVal);
    }
  },
  {
    // 如果team可能不存在,初始化时不需要触发,immediate设为false
    // 如果需要初始化时触发,并且team可能不存在,那可以加个默认值
    immediate: true,
    // 回调里用默认值处理
  }
);

初始化时加默认值的写法:

watch(
  () => props.userInfo.job?.department?.team?.name ?? '',
  (newVal, oldVal) => {
    console.log('team.name变化了(带默认值):', newVal, oldVal);
  },
  { immediate: true }
);

场景4:object非常大且嵌套很深,但需要监听所有变化但又不想每次都递归遍历

这时候有没有办法?有!但稍微复杂一点,就是父组件在修改数据的时候,主动给数据加一个“版本号”或者“时间戳”,子组件只需要监听这个版本号或时间戳的变化,不需要监听整个object的deep变化:

父组件加版本号的写法

<script setup>
import { ref, reactive, onMounted } from 'vue';
import Child from './Child.vue';
// 用reactive定义user,方便加version
const user = reactive({
  name: '张三',
  age: 25,
  hobby: ['爬山', '读书'],
  version: 0 // 加个版本号,每次修改数据就+1
});
// 封装一个修改user的函数,自动更新版本号
const updateUser = (updater) => {
  // updater是一个函数,接收user作为参数,修改user的属性
  updater(user);
  user.version++;
};
onMounted(() => {
  setTimeout(() => {
    // 用封装好的updateUser修改数据
    updateUser((u) => {
      u.name = '李四';
    });
  }, 3000);
  setTimeout(() => {
    updateUser((u) => {
      u.job = { title: '前端开发', level: '中级' };
    });
  }, 12000);
  setTimeout(() => {
    updateUser((u) => {
      u.job.level = '高级';
    });
  }, 14000);
});
</script>
<template>
  <Child :userInfo="user" />
</template>

子组件只监听版本号的写法

<script setup>
import { watch } from 'vue';
const props = defineProps(['userInfo']);
// 只监听version的变化,不需要deep,性能极佳
watch(
  () => props.userInfo.version,
  (newVersion) => {
    console.log('userInfo的版本号更新了,现在是:', newVersion);
    // 在这里写你的业务逻辑,比如重新渲染某个部分,或者重新请求数据
    // 如果需要对比新旧数据,可以用JSON.parse(JSON.stringify(props.userInfo))保存旧值,但要注意性能
  }
);
</script>

这个方案的性能是最好的,因为不管object多大、嵌套多深,Vue3只需要监听一个基本类型的版本号变化,不会做任何递归遍历,但缺点是需要父组件封装修改函数,并且所有修改数据的地方都必须用这个封装好的函数,不能直接修改user的属性,否则版本号不会更新,子组件也不会知道数据变了,如果你的项目是多人协作的,一定要在代码里加注释说明,防止有人直接修改。

父子组件双向绑定的简化版实现:v-model和props

刚才讲的是单向数据流的标准写法,但如果每次修改都要触发事件、父组件监听事件,太繁琐了,有没有简化版的?有!就是用Vue3的v-model

Vue3的v-model和Vue2的不一样:Vue2的v-model默认是用value prop和input事件,而Vue3的v-model默认是用modelValue prop和update:modelValue事件,而且可以绑定多个v-model

单个v-model绑定props object的写法

如果子组件只需要处理一个props object,并且想简化写法,可以用单个v-model

子组件单个v-model的写法

<script setup>
// 默认的v-model用modelValue prop和update:modelValue事件
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
// 封装一个修改函数,直接触发update:modelValue,传新的整个对象?或者传修改的部分?
// 为了符合单向数据流,最好传新的整个对象,但如果对象很大,可以用Object.assign或者展开运算符
const changeName = () => {
  // 用展开运算符创建一个新的对象,修改name,然后触发事件
  emit('update:modelValue', {
    ...props.modelValue,
    name: '周八'
  });
};
const addHobby = () => {
  // 数组也一样,用展开运算符创建新的数组
  emit('update:modelValue', {
    ...props.modelValue,
    hobby: [...props.modelValue.hobby, '露营']
  });
};
</script>
<template>
  <div>姓名:{{ props.modelValue.name }}</div>
  <div>年龄:{{ props.modelValue.age }}</div>
  <div>爱好:{{ props.modelValue.hobby.join(', ') }}</div>
  <button @click="changeName">修改姓名(v-model版)</button>
  <button @click="addHobby">添加爱好(v-model版)</button>
</template>

父组件单个v-model的写法

<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
  name: '张三',
  age: 25,
  hobby: ['爬山', '读书']
});
// 不需要手动监听update:modelValue事件,v-model会自动处理
</script>
<template>
  <!-- 直接用v-model绑定user -->
  <Child v-model="user" />
</template>

现在是不是简化了很多?不需要手动定义事件处理函数了!但这里有个注意点:用展开运算符创建新的对象/数组时,如果有嵌套属性,展开运算符是“浅拷贝”的,比如如果userInfo里有job对象,用展开运算符修改job.title的话,必须这样写:

emit('update:modelValue', {
  ...props.modelValue,
  job: {
    ...props.modelValue.job, '后端开发'
  }
});

如果直接修改props.modelValue.job.title然后传整个对象,虽然子组件不会报错,但还是违反了单向数据流原则(因为你修改了props里的嵌套对象引用),而且父组件的user.value.job.title也会变,但这种写法和直接改props没区别,数据流向还是混乱,所以千万不能这么做!

多个v-model绑定props object的不同部分的写法

如果子组件需要处理props object的多个部分,并且不想每次都传整个对象,可以用多个v-model,给每个v-model起个名字:

子组件多个v-model的写法

<script setup>
// 多个v-model用命名prop和update:命名事件
const props = defineProps(['userName', 'userHobby']);
const emit = defineEmits(['update:userName', 'update:userHobby']);
const changeName = () => {
  emit('update:userName', '吴九');
};
const addHobby = () => {
  emit('update:userHobby', [...props.userHobby, '攀岩']);
};
</script>
<template>
  <div>姓名:{{ props.userName }}</div>
  <div>爱好:{{ props.userHobby.join(', ') }}</div>
  <button @click="changeName">修改姓名(多个v-model版)</button>
  <button @click="addHobby">添加爱好(多个v-model版)</button>
</template>

父组件多个v-model的写法

<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const user = ref({
  name: '张三',
  age: 25,
  hobby: ['爬山', '读书']
});
</script>
<template>
  <!-- 用v-model:命名绑定对应的部分 -->
  <Child 
    v-model:user-name="user.name" 
    v-model:user-hobby="user.hobby" 
  />
</template>

哦对了,命名v-model的prop名在父组件模板里用kebab-case(短横线分隔),在子组件defineProps里用camelCase(驼峰命名),和普通prop的命名规则一样。

Vue3 watch props object的另一种选择:computed

很多时候,你监听props里的object,其实是为了根据object的变化计算出一个新的值,比如计算user的年龄是否大于30,或者计算hobby的数量,这时候千万不要用watch,而是用computed!因为computed是“自动缓存”的,只有依赖的响应式数据变化时才会重新计算,而watch是“每次变化都执行回调”,如果你的回调里只是计算一个值,用computed性能会更好,代码也更简洁。

举个例子,计算user的年龄是否大于30:

用watch的错误写法(性能差)

<script setup>
import { watch, ref } from 'vue';
const props = defineProps(['userInfo']);
const isOver30 = ref(false);
watch(
  () => props.userInfo.age,
  (newAge) => {
    isOver30.value = newAge > 30;
  },
  { immediate: true } // 初始化时需要执行一次
);
</script>
<template>
  <div>是否大于30岁:{{ isOver30 ? '是' : '否' }}</div>
</template>

用computed的正确写法(性能好、代码简洁)

<script setup>
import { computed } from 'vue';
const props = defineProps(['userInfo']);
const isOver30 = computed(() => {
  return props.userInfo.age > 30;
});
</script>
<template>
  <div>是否大于30岁:{{ isOver30 ? '是' : '否' }}</div>
</template>

是不是简单多了?而且computed会自动缓存,如果props.userInfo.age没有变化,不管模板里用多少次isOver30,都只会计算一次。

再举个例子,计算hobby的数量,并且只显示前3个爱好:

<script setup>
import { computed } from 'vue';
const props = defineProps(['userInfo']);
const hobbyCount = computed(() => props.userInfo.hobby.length);
const top3Hobbies = computed(() => props.userInfo.hobby.slice(0, 3));
</script>
<template>
  <div>爱好数量:{{ hobbyCount }}</div>
  <div>前3个爱好:{{ top3Hobbies.join(', ') }}</div>
</template>

完美!

Vue3.4+带来的新特性:defineModel

刚才讲的多个v-model的写法,其实已经简化了,但如果你用的是Vue3.4+,还有一个更简化的新特性:defineModel

defineModel是一个编译器宏,不需要从vue里导入,直接在setup里用就行,它会自动生成一个响应式的ref,当你修改这个ref的值时,会自动触发对应的update:命名事件,父组件的v-model会自动同步更新,完全不需要手动defineProps和defineEmits!

单个defineModel的写法(对应单个v-model)

<script setup>
// 单个defineModel,默认对应v-model="xxx"
const userInfo = defineModel();
const changeName = () => {
  // 直接修改userInfo.value的属性?不对,等下如果是浅拷贝的话
  // 或者直接修改顶层引用
  userInfo.value = {
    ...userInfo.value,
    name: '郑十'
  };
};
</script>
<template>
  <div>姓名:{{ userInfo.name }}</div>
  <button @click="changeName">修改姓名(defineModel版)</button>
</template>

多个defineModel的写法(对应多个v-model)

<script setup>
// 多个defineModel,给每个起个名字,对应v-model:命名="xxx"
const userName = defineModel('userName');
const userHobby = defineModel('userHobby');
const changeName = () => {
  // 直接修改基本类型的ref
  userName.value = '钱十一';
};
const addHobby = () => {
  // 直接修改数组的ref
  userHobby.value = [...userHobby.value, '潜水'];
};
</script>
<template>
  <div>姓名:{{ userName }}</div>
  <div>爱好:{{ userHobby.join(', ') }}</div>
  <button @click="changeName">修改姓名(多个defineModel版)</button>
  <button @click="addHobby">添加爱好(多个defineModel版)</button>
</template>

是不是简化到极致了?但同样要注意:如果defineModel返回的ref是对象/数组类型,直接修改内部属性的话,虽然不会报错,但还是会修改父组件的源数据引用,违反单向数据流原则,所以最好还是用展开运算符创建新的对象/数组,然后赋值给ref.value

defineModel还支持第二个参数,用来设置prop的验证规则和默认值,

const userInfo = defineModel({
  type: Object,
  required: true,
  default: () => ({ name: '匿名', age: 0, hobby: [] })
});

总结一下Vue3 watch props object的所有要点

  1. 确保父组件的源数据是响应式的:必须用ref或reactive包裹,不能直接传递普通对象。
  2. 监听props里的object/array永远用getter函数:作为通用安全写法,避免解构带来的响应式丢失。
  3. 不要滥用deep: true:根据场景选择合适的监听方式,比如只监听具体属性用getter返回该属性,只监听顶层属性增删改用watchEffect+Object.keys+顶层属性访问,非常大的嵌套对象用版本号/时间戳。
  4. 严格遵循单向数据流原则:不要直接修改props的顶层引用,也不要直接修改内部属性(虽然不报错),要用事件或v-model/defineModel让父组件修改源数据。
  5. 能⽤computed的地方绝对不用watch:computed有自动缓存,性能更好,代码更简洁。
  6. Vue3.4+推荐用defineModel:简化双向绑定的写法,但要注意避免直接修改内部属性。

再强调一遍:没有最好的写法,只有最适合当前场景的写法,在写代码之前,先想清楚你要监听什么,需要什么样的性能,然后再选择对应的方案,如果还有什么疑问,可以在评论区留言交流。

版权声明

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

热门