<?xml version="1.0" encoding="utf-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"><channel><title>code前端网</title><link>https://codeqd.com/</link><description>学技术，来code前端开发网</description><item><title>Vue3里怎么同时监听多个数据源？不同监听方式有啥区别？踩过哪些坑？</title><link>https://codeqd.com/post/20260521810.html</link><description>&lt;p&gt;平时做Vue3项目开发,经常会遇到这种情况：表单提交前要同时检查用户名、密码、邮箱是否都不为空，或者筛选列表页要监听关键词、分类、排序方式三个变量同时触发数据请求，再或者状态管理里要拿pinia/vuex的两个state来计算某个临时值的变化，这些都得用到多源监听，很多刚从Vue2转过来或者刚学Vue3的朋友，可能只会一股脑把多个变量塞数组里，但其实Vue3里多源监听有好几种写法，每种都有适用场景，稍不注意还会踩坑，今天就把这些事儿说透。&lt;/p&gt;
&lt;h2&gt;先搞懂：Vue3 watch支持的“源”到底有哪些？&lt;/h2&gt;
&lt;p&gt;在讲多源监听之前,得先单拎出来watch的“源”类型说清楚——因为不是所有东西都能直接丢进去给watch当监听对象的，不同类型的源在多源场景下的表现也不一样，这是基础中的基础。
ref和reactive的属性肯定是最常用的，比如&lt;code&gt;const username = ref(&#039;&#039;)&lt;/code&gt;或者&lt;code&gt;const form = reactive({ password: &#039;&#039; })&lt;/code&gt;，这时候直接写&lt;code&gt;username&lt;/code&gt;或者&lt;code&gt;() =&amp;gt; form.password&lt;/code&gt;就能当源；然后是getter函数，也就是有返回值的箭头函数，比如&lt;code&gt;() =&amp;gt; [username.value, form.password]&lt;/code&gt;这种；还有computed属性，不管是带get的还是有get有set的，都可以；最后是一个数组，里面可以混合放前面说的所有合法源，这就是我们常说的“数组写法”多源监听。&lt;/p&gt;
&lt;h2&gt;第一种多源监听方式：数组写法（最基础、最常用）&lt;/h2&gt;
&lt;p&gt;很多朋友第一次接触Vue3多源监听,用的都是这个方法：把要监听的所有ref、computed或者getter函数丢进watch的第一个参数数组里，第二个参数是回调函数，回调函数的第一个参数是新值组成的数组，顺序和第一个参数数组的源顺序一致，第二个参数是旧值组成的数组，顺序也一样。
举个最常见的筛选列表的例子吧：
假设我们有三个筛选条件的ref，分别是&lt;code&gt;keyword&lt;/code&gt;（关键词）、&lt;code&gt;categoryId&lt;/code&gt;（分类ID）、&lt;code&gt;sortType&lt;/code&gt;（排序方式，price_asc&#039;、&#039;time_desc&#039;），然后想只要这三个里有一个变，就重新请求商品列表，这时候就可以这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
const categoryId = ref(0)
const sortType = ref(&amp;#39;time_desc&amp;#39;)
const getProductList = async () =&amp;gt; {
  // 这里是请求接口的逻辑，比如用axios
  // const res = await axios.get(&amp;#39;/api/products&amp;#39;, { 
  //   params: { keyword, categoryId, sortType } 
  // })
  console.log(&amp;#39;触发了商品列表请求，当前条件：&amp;#39;, [keyword.value, categoryId.value, sortType.value])
}
// 数组写法多源监听
watch([keyword, categoryId, sortType], ([newKey, newCate, newSort], [oldKey, oldCate, oldSort]) =&amp;gt; {
  // 这里可以处理新旧值对比，比如分类变了但关键词没变，要不要清空页码？
  if (newCate !== oldCate) {
    console.log(&amp;#39;分类变了，清空页码&amp;#39;)
    // page.value = 1
  }
  getProductList()
})&lt;/pre&gt;
&lt;p&gt;这种写法的优点很明显：直观，一眼就能看出来监听了哪些源；新旧值按顺序返回，对比的时候很方便；如果需要单独给某个源加deep或者immediate这些配置？不行哦——数组写法里配置是全局的，整个数组的源要么都deep要么都不deep，要么都immediate要么都不immediate，这是它的第一个小局限。
还有第二个小细节：如果数组里的某个源是&lt;strong&gt;reactive对象本身&lt;/strong&gt;，不是它的属性或者getter函数，那默认就是deep监听的，不管有没有加deep: true配置，而且旧值会和新值一样——因为reactive是响应式对象的引用，Vue3不会保留它的历史快照，除非你手动深拷贝一下旧值，但这样性能可能会有问题，所以尽量不要直接监听reactive对象本身，不管是单源还是多源。
比如你直接写&lt;code&gt;watch([form], ([newForm], [oldForm]) =&amp;gt; {})&lt;/code&gt;，这里的newForm和oldForm永远是同一个对象引用，对比不了；但如果写成&lt;code&gt;watch([() =&amp;gt; form], ([newForm], [oldForm]) =&amp;gt; {})&lt;/code&gt;，旧值就可以保留，但如果form内部嵌套很深，这时候又要记得加deep: true，不然只有form的顶层属性（比如直接给form赋值一个新对象{}）才会触发监听，form内部的password变了不会触发。&lt;/p&gt;
&lt;h2&gt;第二种多源监听方式：用getter函数返回一个对象/数组（自定义新旧值触发规则的利器）&lt;/h2&gt;
&lt;p&gt;刚才说数组写法的配置是全局的,那如果我想监听三个源，但只有其中的keyword和categoryId变了才触发请求，sortType变了不触发？或者只有三个源都不为空才触发？或者想把新旧值组织成一个对象，不用按顺序记？这时候就可以用getter函数当watch的第一个源参数，返回一个包含多个源的数组或者对象，这其实也算是“多源监听”，因为getter函数的返回值依赖了多个响应式数据嘛。&lt;/p&gt;
&lt;h3&gt;场景1：自定义触发条件（比如只监听部分源，或者只在特定值变化时触发）&lt;/h3&gt;
&lt;p&gt;还是刚才的筛选列表例子,如果现在产品经理说：“sortType变了的话，我们前端直接用现有数据排就行，不用重新请求接口，节省服务器资源”，那用数组写法就没办法了，因为一变化就会触发，但用getter函数就可以：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch, computed } from &amp;#39;vue&amp;#39;
// 前面的keyword、categoryId、sortType定义不变
// 方法一：在getter里只返回需要触发请求的源，这样sortType变了getter的返回值不变，watch就不会触发
watch(() =&amp;gt; [keyword.value, categoryId.value], ([newKey, newCate]) =&amp;gt; {
  // 对比逻辑不变
  if (newCate !== oldKey？不对，是和旧Cate比，第二个参数的旧值数组顺序和返回的一致
  // 哦对，重新写回调参数
  ([newKey, newCate], [oldKey, oldCate]) =&amp;gt; {
    if (newCate !== oldCate) {
      console.log(&amp;#39;分类变了，清空页码&amp;#39;)
    }
    getProductList()
  }
)
// 那如果还有更复杂的触发条件呢？比如只有keyword长度大于等于2，或者categoryId大于0，这两个条件同时满足至少一个变化才触发？
// 可以用computed先包装一个“是否需要请求”的条件，不过也可以直接在getter的返回值里做判断，或者用watch的第三个参数里的flush和immediate之外的配置？不对，Vue3 watch有一个配置项叫`watchOptions`里的`...`哦不对，Vue3原生watch没有直接的`filter`配置，不过可以在getter里返回一个“触发标识”，或者在回调里加判断，但更优雅的是用computed先处理成需要监听的依赖：
const shouldRequestTrigger = computed(() =&amp;gt; {
  // 这里返回一个依赖了keyword和categoryId的值，sortType完全不依赖，所以sortType变了不会更新这个computed
  return {
    key: keyword.value.length &amp;gt;= 2 ? keyword.value : &amp;#39;&amp;#39;, // 长度不够的话key不变，相当于不触发关键词的监听
    cate: categoryId.value
  }
})
watch(shouldRequestTrigger, (newVal, oldVal) =&amp;gt; {
  // 现在只有shouldRequestTrigger的返回值真的变了才会触发，比如keyword从1变成2（触发key变化），或者categoryId从0变成1（触发cate变化），或者两个同时变
  console.log(&amp;#39;触发了有效条件的商品列表请求，当前有效条件：&amp;#39;, newVal)
  // 对比逻辑
  if (newVal.cate !== oldVal.cate) {
    console.log(&amp;#39;分类变了，清空页码&amp;#39;)
  }
  getProductList()
}, {
  // 这里要加deep吗？如果shouldRequestTrigger返回的是对象，默认是浅监听，也就是只有newVal和oldVal的引用变了才触发，但shouldRequestTrigger每次依赖更新都会返回一个新对象（因为{}是字面量），所以默认不加deep也会触发
  // 但如果是返回数组也是一样的，[]每次都是新的
  // 不过如果返回的是一个原始值组成的数组或者单个原始值，当然不用deep
})&lt;/pre&gt;
&lt;h3&gt;场景2：把新旧值组织成对象，不用按顺序记&lt;/h3&gt;
&lt;p&gt;刚才数组写法的回调参数是按源的顺序排列的,有时候源多了（比如5、6个），很容易搞混哪个新值对应哪个旧值，用getter返回对象的话，回调参数的newVal和oldVal就是有键名的对象，用起来就舒服多了：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const username = ref(&amp;#39;&amp;#39;)
const password = ref(&amp;#39;&amp;#39;)
const email = ref(&amp;#39;&amp;#39;)
const phone = ref(&amp;#39;&amp;#39;)
watch(() =&amp;gt; ({
  username: username.value,
  password: password.value,
  email: email.value,
  phone: phone.value
}), (newForm, oldForm) =&amp;gt; {
  console.log(&amp;#39;新的表单值：&amp;#39;, newForm)
  console.log(&amp;#39;旧的表单值：&amp;#39;, oldForm)
  // 直接用键名，不用记顺序，比如只检查email的变化：
  if (newForm.email !== oldForm.email) {
    console.log(&amp;#39;邮箱变了，重置验证码&amp;#39;)
    // code.value = &amp;#39;&amp;#39;
  }
}, {
  deep: true, // 这里的deep其实加不加都行？因为返回的是字面量对象，每次依赖更新都是新引用，浅监听也能触发；但如果返回的是reactive的某个嵌套属性组成的子对象？不对，getter里是.value或者() =&amp;gt; form.userInfo这种嵌套属性的话，如果返回的是form.userInfo这个reactive子对象本身，那又要加deep了，而且旧值和新值引用一样，所以最好还是把嵌套属性拆解成原始值或者getter里的属性值返回
})&lt;/pre&gt;
&lt;p&gt;这里再提醒一下刚才说的reactive源的问题：如果getter里返回的是&lt;code&gt;() =&amp;gt; form&lt;/code&gt;（整个reactive对象），那必须加deep才能监听内部属性变化，但旧值和新值还是同一个引用；如果返回的是&lt;code&gt;() =&amp;gt; ({ ...form })&lt;/code&gt;（浅拷贝整个reactive对象），那不加deep也能监听顶层属性变化，内部嵌套属性变化还是要加deep，而且旧值的顶层属性是可以对比的，嵌套属性还是引用；如果想要完全对比新旧嵌套对象的属性，那只能在回调里手动深拷贝旧值，或者在getter里返回深拷贝后的对象，但深拷贝如果是大对象的话，性能会很差，所以尽量不要监听整个大的嵌套reactive对象，而是拆分成小的ref或者getter监听具体的属性。&lt;/p&gt;
&lt;h2&gt;第三种多源监听方式：watchEffect（“自动收集依赖”的懒人写法）&lt;/h2&gt;
&lt;p&gt;刚才的数组写法和getter写法都是“显式指定依赖”，也就是你要手动把所有要监听的源丢进去，或者写在getter函数的返回值里；而watchEffect是“自动收集依赖”，你不用指定要监听谁，只要在watchEffect的回调函数里用到了哪些响应式数据，它就会自动监听这些数据，只要其中一个变了，就会重新执行回调函数——这其实也是多源监听的一种，而且代码更短，适合那种不需要对比新旧值，只要依赖变了就执行的场景。
还是拿筛选列表请求的例子，不过这次我们不需要对比新旧分类清空页码（虽然也可以在watchEffect里自己存旧值对比，但稍微麻烦一点），只要三个筛选条件变了就请求：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watchEffect } from &amp;#39;vue&amp;#39;
// 前面的keyword、categoryId、sortType定义不变
watchEffect(() =&amp;gt; {
  // 只要在回调里用到了这三个ref的.value，watchEffect就会自动监听它们
  console.log(&amp;#39;watchEffect触发了商品列表请求，当前条件：&amp;#39;, [keyword.value, categoryId.value, sortType.value])
  getProductList()
})&lt;/pre&gt;
&lt;p&gt;你看,代码是不是比数组写法短多了？而且不用担心漏加源，只要用到了就会监听，那它有什么缺点呢？
刚才说的，&lt;strong&gt;默认没有新旧值对比&lt;/strong&gt;，如果需要对比的话，得自己手动存旧值；&lt;strong&gt;默认是立即执行的&lt;/strong&gt;（也就是immediate: true），如果你不想页面刚加载就执行，得用watchEffect的另一个兄弟API——watchPostEffect或者watchSyncEffect？不对，这三个的区别是执行时机，watchEffect默认是flush: &#039;pre&#039;，在组件更新前执行；watchPostEffect是flush: &#039;post&#039;，在组件更新后执行；watchSyncEffect是flush: &#039;sync&#039;，同步执行（性能最差，尽量不用）；但这三个默认都是立即执行的，没有immediate: false的配置，如果想要延迟执行，得自己加个flag，&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watchEffect, onMounted } from &amp;#39;vue&amp;#39;
const isMounted = ref(false)
onMounted(() =&amp;gt; {
  isMounted.value = true
})
watchEffect(() =&amp;gt; {
  if (!isMounted.value) return // 页面没挂载完不执行
  console.log(&amp;#39;延迟到挂载后才执行的watchEffect&amp;#39;)
  getProductList()
})&lt;/pre&gt;
&lt;p&gt;第三个缺点,&lt;strong&gt;没有显式的依赖列表&lt;/strong&gt;，代码可读性稍微差一点——尤其是当watchEffect的回调函数很长的时候，你得扫一遍整个回调才知道它监听了哪些源，而数组写法和getter写法一眼就能看出来；第四个缺点，&lt;strong&gt;如果不小心在回调里用到了不该监听的响应式数据，就会导致不必要的执行&lt;/strong&gt;——比如你在回调里不小心写了个&lt;code&gt;console.log(loading.value)&lt;/code&gt;，而loading是另一个控制加载动画的ref，那loading变了也会触发watchEffect重新请求接口，这肯定不是我们想要的，所以用watchEffect的时候要特别注意，回调里只放和监听逻辑相关的响应式数据。
那什么时候用watchEffect呢？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;页面刚加载就要执行,而且不需要对比新旧值的场景，比如请求初始化数据（当然也可以用数组写法加immediate: true，不过watchEffect更短）；&lt;/li&gt;
&lt;li&gt;依赖的源很多,而且容易漏加的场景，比如表单的自动保存，只要表单里的任何一个输入框变了，就自动保存到本地存储localStorage里，这时候用watchEffect自动收集所有表单输入框的依赖，比手动把所有输入框的ref丢数组里方便多了；&lt;/li&gt;
&lt;li&gt;需要根据多个响应式数据动态生成DOM或者其他副作用,但不需要对比新旧值的场景。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;补充：多个watch vs 单个多源watch，该选哪个？&lt;/h2&gt;
&lt;p&gt;刚才讲的都是单个watch监听多个源,那有没有必要拆成多个单个源的watch呢？比如刚才的表单例子，监听username变了检查是否重复，监听password变了检查强度，监听email变了检查格式，这时候肯定是拆成多个单个源的watch更好，因为每个watch的逻辑是独立的，互不干扰，代码可读性和可维护性都更高；但如果是刚才的筛选列表例子，多个源变了要执行同一个逻辑（请求接口），那肯定是用单个多源watch更好，代码更简洁，而且不会因为多个源同时变化而触发多次执行（比如用户同时快速修改了keyword和categoryId，多个单个源的watch会触发两次请求接口，而单个多源watch只会触发一次——这里要注意，Vue3的响应式系统有批量更新机制，多个源在同一个事件循环里变化的话，不管是单个多源watch还是多个单个源的watch，都只会批量触发一次，但如果是不同的事件循环里的变化，比如先改keyword，过100ms再改categoryId，那多个单个源的watch就会触发两次，而单个多源watch也会触发两次，但至少逻辑是统一在一个回调里的，不用写重复的请求接口代码）。
总结一下选择原则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果多个源变了要执行&lt;strong&gt;同一个、统一的逻辑&lt;/strong&gt;，而且不需要单独控制每个源的配置（比如有的要deep有的不要），就用&lt;strong&gt;单个多源watch&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;如果多个源变了要执行&lt;strong&gt;不同的、独立的逻辑&lt;/strong&gt;，或者需要&lt;strong&gt;单独控制每个源的配置&lt;/strong&gt;，就用&lt;strong&gt;多个单个源的watch&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;如果多个源变了要执行&lt;strong&gt;同一个逻辑，但需要延迟执行或者对比部分新旧值&lt;/strong&gt;，可以用&lt;strong&gt;单个多源watch的getter写法&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;如果多个源变了要执行&lt;strong&gt;同一个逻辑，不需要对比新旧值，而且页面刚加载就要执行&lt;/strong&gt;，可以用&lt;strong&gt;watchEffect&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;常见踩坑指南，别再犯了！&lt;/h2&gt;
&lt;p&gt;刚才在讲每种写法的时候都提到了一些小细节,现在把它们整理成常见的踩坑指南，都是我自己和身边同事在开发中遇到过的：&lt;/p&gt;
&lt;h3&gt;坑1：直接监听reactive对象本身，导致旧值和新值一样，而且默认deep&lt;/h3&gt;
&lt;p&gt;这个刚才说了好几次了,再强调一遍：尽量不要直接监听reactive对象本身，不管是单源还是多源！如果要监听整个reactive对象的所有属性变化，就在getter里返回它的浅拷贝或者深拷贝（看需求），或者拆分成小的ref监听具体的属性。&lt;/p&gt;
&lt;h3&gt;坑2：数组写法里混合了reactive对象本身和其他源，导致全局deep生效&lt;/h3&gt;
&lt;p&gt;比如你写&lt;code&gt;watch([username, form], ...)&lt;/code&gt;，这里form是reactive对象本身，那默认整个watch的配置就是deep: true，哪怕你不加，而且username的旧值是正常的（因为是ref），但form的旧值和新值引用一样，如果你不需要监听form的内部属性，只需要监听form的顶层引用变化（比如直接给form赋值{}），那应该把form改成&lt;code&gt;() =&amp;gt; form&lt;/code&gt;的getter写法，这样全局deep就不会默认生效了。&lt;/p&gt;
&lt;h3&gt;坑3：watchEffect里用到了不该监听的响应式数据，导致不必要的执行&lt;/h3&gt;
&lt;p&gt;比如刚才说的在watchEffect里不小心写了&lt;code&gt;console.log(loading.value)&lt;/code&gt;，那loading变了也会触发执行，解决办法是：要么把loading的.value改成loading（但这样就不是响应式的了），要么用&lt;code&gt;watchEffect的onCleanup&lt;/code&gt;函数？不对，onCleanup是用来清理副作用的，不是用来取消依赖的；要么就把不需要监听的响应式数据放在watchEffect外面，或者拆分成显式指定依赖的watch。&lt;/p&gt;
&lt;h3&gt;坑4：getter写法返回的是reactive的嵌套属性组成的子对象，没有加deep，导致内部属性变化不触发&lt;/h3&gt;
&lt;p&gt;比如你写&lt;code&gt;const form = reactive({ userInfo: { name: &#039;&#039;, age: 0 } })&lt;/code&gt;，然后&lt;code&gt;watch(() =&amp;gt; form.userInfo, ...)&lt;/code&gt;，这里form.userInfo是reactive的子对象，默认是浅监听，所以只有直接给form.userInfo赋值{}才会触发，form.userInfo.name变了不会触发，解决办法是要么加deep: true（但旧值和新值引用一样），要么把getter改成&lt;code&gt;() =&amp;gt; ({ name: form.userInfo.name, age: form.userInfo.age })&lt;/code&gt;（这样返回的是字面量对象，每次属性变化都是新引用，不加deep也能触发，而且旧值的属性可以对比）。&lt;/p&gt;
&lt;h3&gt;坑5：数组写法里的回调参数顺序搞混&lt;/h3&gt;
&lt;p&gt;这个虽然不是技术问题,但很容易犯，尤其是源多了的时候，解决办法是要么用getter返回对象的写法，要么在回调参数里加注释，&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;watch([keyword, categoryId, sortType], (
  [newKeyword, newCategoryId, newSortType], // 新值顺序：关键词、分类ID、排序方式
  [oldKeyword, oldCategoryId, oldSortType]  // 旧值顺序同上
) =&amp;gt; {
  // 逻辑
})&lt;/pre&gt;
&lt;h3&gt;坑6：watchEffect的执行时机不对，导致获取不到DOM&lt;/h3&gt;
&lt;p&gt;比如你在watchEffect里想要获取某个DOM元素的高度,但watchEffect默认是flush: &#039;pre&#039;，在组件更新前执行，这时候DOM可能还没渲染完或者更新完，获取到的高度是旧的或者undefined，解决办法是用watchPostEffect（flush: &#039;post&#039;），它会在组件更新后执行，这时候DOM已经渲染完或者更新完了。&lt;/p&gt;
&lt;h2&gt;实战案例：用Vue3多源监听做一个带实时验证的注册表单&lt;/h2&gt;
&lt;p&gt;现在把刚才讲的所有知识点整合起来,做一个带实时验证的注册表单，包含以下功能：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;监听username（ref）：长度在3-20之间，且不能包含特殊字符；&lt;/li&gt;
&lt;li&gt;监听password（ref）：长度在6-20之间，且必须包含字母和数字；&lt;/li&gt;
&lt;li&gt;监听confirmPassword（ref）：必须和password一致；&lt;/li&gt;
&lt;li&gt;监听email（ref）：必须符合邮箱格式；&lt;/li&gt;
&lt;li&gt;监听以上四个源的所有有效变化（比如username长度从2变成3才算有效变化）：只有四个都通过验证，才显示“注册”按钮，否则显示“请完善信息”提示。
这个案例里，既有多个单个源的watch（分别做每个输入框的验证），又有单个多源watch（监听所有验证结果，控制注册按钮的显示），非常适合练手。
准备HTML模板：&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
&amp;lt;div class=&amp;quot;register-form&amp;quot;&amp;gt;
 &amp;lt;h2&amp;gt;用户注册&amp;lt;/h2&amp;gt;
 &amp;lt;div class=&amp;quot;form-item&amp;quot;&amp;gt;
   &amp;lt;label&amp;gt;用户名：&amp;lt;/label&amp;gt;
   &amp;lt;input v-model=&amp;quot;username&amp;quot; type=&amp;quot;text&amp;quot; placeholder=&amp;quot;请输入3-20位字母、数字或下划线&amp;quot; /&amp;gt;
   &amp;lt;span v-if=&amp;quot;usernameError&amp;quot; class=&amp;quot;error&amp;quot;&amp;gt;{{ usernameError }}&amp;lt;/span&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;div class=&amp;quot;form-item&amp;quot;&amp;gt;
   &amp;lt;label&amp;gt;密码：&amp;lt;/label&amp;gt;
   &amp;lt;input v-model=&amp;quot;password&amp;quot; type=&amp;quot;password&amp;quot; placeholder=&amp;quot;请输入6-20位包含字母和数字的密码&amp;quot; /&amp;gt;
   &amp;lt;span v-if=&amp;quot;passwordError&amp;quot; class=&amp;quot;error&amp;quot;&amp;gt;{{ passwordError }}&amp;lt;/span&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;div class=&amp;quot;form-item&amp;quot;&amp;gt;
   &amp;lt;label&amp;gt;确认密码：&amp;lt;/label&amp;gt;
   &amp;lt;input v-model=&amp;quot;confirmPassword&amp;quot; type=&amp;quot;password&amp;quot; placeholder=&amp;quot;请再次输入密码&amp;quot; /&amp;gt;
   &amp;lt;span v-if=&amp;quot;confirmPasswordError&amp;quot; class=&amp;quot;error&amp;quot;&amp;gt;{{ confirmPasswordError }}&amp;lt;/span&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;div class=&amp;quot;form-item&amp;quot;&amp;gt;
   &amp;lt;label&amp;gt;邮箱：&amp;lt;/label&amp;gt;
   &amp;lt;input v-model=&amp;quot;email&amp;quot; type=&amp;quot;email&amp;quot; placeholder=&amp;quot;请输入有效邮箱&amp;quot; /&amp;gt;
   &amp;lt;span v-if=&amp;quot;emailError&amp;quot; class=&amp;quot;error&amp;quot;&amp;gt;{{ emailError }}&amp;lt;/span&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;div class=&amp;quot;form-tip&amp;quot;&amp;gt;
   &amp;lt;span v-if=&amp;quot;!canRegister&amp;quot; class=&amp;quot;tip-error&amp;quot;&amp;gt;请完善信息&amp;lt;/span&amp;gt;
   &amp;lt;span v-else class=&amp;quot;tip-success&amp;quot;&amp;gt;信息填写完整，可以注册&amp;lt;/span&amp;gt;
 &amp;lt;/div&amp;gt;
 &amp;lt;button :disabled=&amp;quot;!canRegister&amp;quot; class=&amp;quot;register-btn&amp;quot;&amp;gt;注册&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;准备JavaScript逻辑：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;
import { ref, watch, computed } from &amp;#39;vue&amp;#39;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;export default {
name: &#039;RegisterForm&#039;,
setup() {
// 1. 定义响应式数据源
const username = ref(&#039;&#039;)
const password = ref(&#039;&#039;)
const confirmPassword = ref(&#039;&#039;)
const email = ref(&#039;&#039;)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 2. 定义验证错误的ref
const usernameError = ref(&#039;&#039;)
const passwordError = ref(&#039;&#039;)
const confirmPasswordError = ref(&#039;&#039;)
const emailError = ref(&#039;&#039;)
// 3. 定义多个单个源的watch，分别做验证
// 验证用户名
const validateUsername = (val) =&amp;gt; {
  if (!val) {
    usernameError.value = &#039;&#039;
    return false
  }
  if (val.length &amp;lt; 3 || val.length &amp;gt; 20) {
    usernameError.value = &#039;用户名长度必须在3-20之间&#039;
    return false
  }
  const reg = /^[a-zA-Z0-9_]+$/
  if (!reg.test(val)) {
    usernameError.value = &#039;用户名只能包含字母、数字或下划线&#039;
    return false
  }
  usernameError.value = &#039;&#039;
  return true
}
watch(username, validateUsername, { immediate: true }) // immediate: true，页面刚加载就验证
// 验证密码
const validatePassword = (val) =&amp;gt; {
  if (!val) {
    passwordError.value = &#039;&#039;
    return false
  }
  if (val.length &amp;lt; 6 || val.length &amp;gt; 20) {
    passwordError.value = &#039;密码长度必须在6-20之间&#039;
    return false
  }
  const reg = /^(?=.*[a-zA-Z])(?=.*\d).+$/
  if (!reg.test(val)) {
    passwordError.value = &#039;密码必须包含字母和数字&#039;
    return false
  }
  passwordError.value = &#039;&#039;
  return true
}
watch(password, validatePassword, { immediate: true })
// 验证确认密码（这里要同时监听password和confirmPassword，所以用多源数组写法）
const validateConfirmPassword = (newVal) =&amp;gt; {
  // newVal是[newPassword, newConfirmPassword]，但这里我们不需要旧值，也不需要新密码，只要确认密码和当前密码对比就行
  const currentPwd = password.value
  const currentConfirmPwd = confirmPassword.value
  if (!currentConfirmPwd) {
    confirmPasswordError.value = &#039;&#039;
    return false
  }
  if (currentConfirmPwd !== currentPwd) {
    confirmPasswordError.value = &#039;两次输入的密码不一致&#039;
    return false
  }
  confirmPasswordError.value = &#039;&#039;
  return true
}
watch([password, confirmPassword], validateConfirmPassword, { immediate: true })
// 验证邮箱
const validateEmail = (val) =&amp;gt; {
  if (!val) {
    emailError.value = &#039;&#039;
    return false
  }
  const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!reg.test(val)) {
    emailError.value = &#039;请输入有效邮箱&#039;
    return false
  }
  emailError.value = &#039;&#039;
  return true
}
watch(email, validateEmail, { immediate: true })
// 4. 定义单个多源watch或者computed，监听所有验证结果，控制注册按钮的显示
// 这里用computed更合适，因为canRegister只是一个计算属性，没有副作用，不需要watch
const canRegister = computed(() =&amp;gt; {
  // 直接调用刚才的验证函数，确保每次计算都是最新的验证结果
  const isUsernameValid = validateUsername(username.value)
  const isPasswordValid = validatePassword(password.value)
  const isConfirmPasswordValid = validateConfirmPassword()
  const isEmailValid = validateEmail(email.value)
  return isUsernameValid &amp;amp;&amp;amp; isPasswordValid &amp;amp;&amp;amp; isConfirmPasswordValid &amp;amp;&amp;amp; isEmailValid
})
// 5. 可以在这里加注册按钮的点击事件，不过不是今天的重点，就省略了
return {
  username,
  password,
  confirmPassword,
  email,
  usernameError,
  passwordError,
  confirmPasswordError,
  emailError,
  canRegister
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;准备一点简单的CSS样式，让页面好看一点：
```css
.register-form {
  width: 400px;
  margin: 50px auto;
  padding: 30px;
  border: 1px solid #eee;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.register-form h2 {
  text-align: center;
  margin-bottom: 30px;
  color: #333;
}
.form-item {
  margin-bottom: 20px;
}
.form-item label {
  display: inline-block;
  width: 80px;
  text-align: right;
  margin-right: 10px;
  color: #666;
}
.form-item input {
  width: 280px;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}
.form-item input:focus {
  outline: none;
  border-color: #409eff;
}
.error {
  display: block;
  margin-top: 5px;
  margin-left: 90px;
  font-size: 12px;
  color: #f56c6c;
}
.form-tip {
  margin-bottom: 20px;
  text-align: center;
  font-size: 14px;
}
.tip-error {
  color: #f56c6c;
}
.tip-success {
  color: #67c23a;
}
.register-btn {
  display: block;
  width: 100%;
  padding: 10px 0;
  background-color: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
}
.register-btn:disabled {
  background-color: #a0cfff;
  cursor: not-allowed;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个案例里,我们用到了单个源的watch、多源数组写法的watch、computed（其实computed也是自动收集依赖的，和watchEffect类似，不过它是有返回值的，用来做计算属性，没有副作用），还避开了刚才说的所有坑，大家可以自己复制到Vue3项目里试试，效果应该还不错。&lt;/p&gt;
&lt;h2&gt;最后总结一下&lt;/h2&gt;
&lt;p&gt;Vue3里同时监听多个数据源的方式主要有三种：数组写法（显式指定依赖，全局配置，新旧值按顺序返回）、getter返回对象/数组写法（显式指定依赖，自定义触发规则和新旧值组织方式）、watchEffect（自动收集依赖，立即执行，代码简洁），还要根据需求选择多个单个源的watch还是单个多源watch，以及避开常见的坑。
希望这篇文章能帮到大家，要是还有什么不懂的地方，或者遇到了其他Vue3多源监听的问题，欢迎在评论区留言讨论！&lt;/p&gt;</description><pubDate>Wed, 06 May 2026 14:02:36 +0800</pubDate></item><item><title>如何在Vue3里同时监听多个ref数据？有没有什么坑要避开？</title><link>https://codeqd.com/post/20260521809.html</link><description>&lt;p&gt;前阵子帮朋友改小商城的购物车模块，遇到个棘手的事儿：要同步判断“商品选择状态是否全选”和“计算实时总价”，两个逻辑都依赖多个商品的&lt;code&gt;selected&lt;/code&gt;和&lt;code&gt;count&lt;/code&gt;这俩ref数组——直接分开写watch会有重复遍历的问题，效率太低，而且有时候数据更新不同步会导致全选框和总价飘，后来翻了Vue官方的文档再实际踩了踩坑，终于搞明白几种好用的监听方式，还有容易忽略的细节，今天就整理出来,分享给同样在做Vue3项目的小伙伴。&lt;/p&gt;
&lt;h2&gt;把多个ref打包成数组传给watch&lt;/h2&gt;
&lt;p&gt;这应该是最常用、最容易想到的方法了，就像你把需要同步检查的快递单子叠成一摞给快递员，他会一次性看完所有单子，有一个更新就喊你，具体写法很简单,把要监听的ref变量放到一个普通数组里作为watch的第一个参数就行。&lt;/p&gt;
&lt;p&gt;举个刚才说的购物车小例子吧，假设我们有三个ref：&lt;code&gt;cartList&lt;/code&gt;（商品列表）、&lt;code&gt;useCoupon&lt;/code&gt;（是否用优惠券，布尔值）、&lt;code&gt;couponValue&lt;/code&gt;（优惠券金额）,需要这三个里任意一个变了都重新算总价：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const cartList = ref([
  { id: 1, name: &amp;#39;无线耳机&amp;#39;, price: 299, count: 2, selected: true },
  { id: 2, name: &amp;#39;手机壳&amp;#39;, price: 39, count: 1, selected: false }
])
const useCoupon = ref(false)
const couponValue = ref(50)
const totalPrice = ref(0)
// 打包成数组监听
watch([cartList, useCoupon, couponValue], () =&amp;gt; {
  let sum = 0
  cartList.value.forEach(item =&amp;gt; {
    if (item.selected) sum += item.price * item.count
  })
  if (useCoupon.value &amp;amp;&amp;amp; sum &amp;gt; 100) {
    sum = Math.max(0, sum - couponValue.value)
  }
  totalPrice.value = sum
}, { immediate: true }) // 加上immediate初始化也触发，不然页面刚加载总价是0哦&lt;/pre&gt;
&lt;p&gt;这里要注意的是，数组里的每个ref，不管是基本类型还是对象/数组类型，默认都是“浅监听”——比如&lt;code&gt;cartList&lt;/code&gt;里如果只改了某个商品的&lt;code&gt;count&lt;/code&gt;属性，Vue3默认是不会触发监听的，除非你手动开启&lt;code&gt;deep: true&lt;/code&gt;，不过刚才的例子里我把&lt;code&gt;cartList&lt;/code&gt;整个对象数组放进去，如果要监听内部属性变化，必须加第三个配置对象里的&lt;code&gt;deep: true&lt;/code&gt;,对吧？&lt;/p&gt;
&lt;p&gt;对了，开启&lt;code&gt;deep&lt;/code&gt;之后的回调函数里，第一个参数是&lt;strong&gt;监听数组里每个数据变化后的值组成的新数组&lt;/strong&gt;，第二个参数是&lt;strong&gt;变化前的旧值组成的旧数组&lt;/strong&gt;——不过旧数组里如果是对象/数组类型的ref，旧值其实和新值引用的是同一个堆内存地址，没用deep对比的话根本拿不到真正的历史状态,这个后面讲坑的时候会再提。&lt;/p&gt;
&lt;h2&gt;用computed先把多个ref关联起来再监听&lt;/h2&gt;
&lt;p&gt;这种方式适合&lt;strong&gt;需要依赖多个ref先做一步简单筛选/计算&lt;/strong&gt;，然后只对筛选后的结果变化做响应的场景，比如购物车全选框，我们其实只关心“已选商品数”和“总商品数”是否相等，不用每次商品的名字、没选中的count变了都触发判断。&lt;/p&gt;
&lt;p&gt;那怎么写呢？先写一个computed属性把已选商品数和总商品数关联起来，比如直接返回&lt;code&gt;已选数 === 总商品数&lt;/code&gt;这个布尔值,然后watch这个computed就行。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, computed, watch } from &amp;#39;vue&amp;#39;
// 复用上面的cartList
const isAllSelected = ref(false)
// 先computed筛选出需要的关联逻辑
const allSelectedCheck = computed(() =&amp;gt; {
  if (cartList.value.length === 0) return false
  return cartList.value.every(item =&amp;gt; item.selected)
})
// 只监听computed的变化，效率更高
watch(allSelectedCheck, (newVal) =&amp;gt; {
  isAllSelected.value = newVal
}, { immediate: true })&lt;/pre&gt;
&lt;p&gt;这种写法比直接监听整个&lt;code&gt;cartList&lt;/code&gt;数组要省资源，因为computed本身有缓存机制，只有它内部依赖的那些ref（这里是每个item的&lt;code&gt;selected&lt;/code&gt;）变了，computed才会重新计算，然后才会触发watch——刚才说的“商品名字、没选中的count变了”的情况，&lt;code&gt;allSelectedCheck&lt;/code&gt;根本不会动，watch也就白嫖似的不干活,项目大的时候这点优化还是挺有用的。&lt;/p&gt;
&lt;h2&gt;单独设置每个ref的监听逻辑，再用防抖/节流避免重复触发？&lt;/h2&gt;
&lt;p&gt;其实不太推荐这种方式，但如果是那种&lt;strong&gt;多个ref更新逻辑完全独立，只有极个别场景需要临时触发同一个回调&lt;/strong&gt;的情况，可以试试，不过刚才说的购物车场景肯定不适合，分开写的话，比如用户快速连点两次“全选”，又改了一次“某个商品的count”，可能会连续三次重新计算总价,体验不好。&lt;/p&gt;
&lt;p&gt;不过为了覆盖全面，还是提一下大概的思路：比如可以定义一个公共的&lt;code&gt;updateTotal&lt;/code&gt;函数，然后每个ref单独写watch的时候都调用它，再给这个函数加个lodash的debounce或者throttle，但真的不如前两种方式优雅，而且要额外引入工具库（虽然VueUse也有现成的，但还是没必要）。&lt;/p&gt;
&lt;h2&gt;踩过的三个大坑，一定要记牢&lt;/h2&gt;
&lt;p&gt;刚才说过要讲坑，这三个都是我实际写代码或者帮别人debug遇到的，特别是第一个,新手特别容易踩。&lt;/p&gt;
&lt;h3&gt;第一个坑：对象/数组类型的ref，浅监听没用，深监听旧值拿不到&lt;/h3&gt;
&lt;p&gt;刚才第一种方式里提过，数组里的对象/数组类型ref，默认是“浅监听”——Vue3只会检查这个ref的引用地址有没有变，比如你直接把&lt;code&gt;cartList.value = []&lt;/code&gt;或者&lt;code&gt;cartList.value.push(...)&lt;/code&gt;（哦不对，Vue3里ref包裹的数组，push、pop这种方法会自动触发响应式更新，引用地址其实没变？不对不对，等下我再确认一下刚才的代码逻辑——哦对，刚才的购物车例子里，如果只改&lt;code&gt;cartList.value[0].count = 3&lt;/code&gt;，因为引用地址没变，默认的watch是不会触发的，必须加&lt;code&gt;deep: true&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但是加了&lt;code&gt;deep: true&lt;/code&gt;之后，回调函数的第二个参数（旧值数组里的对象/数组项）和第一个参数（新值数组里的）引用地址是一样的，所以你直接打印新旧值对比，会发现它们是一样的！那怎么拿到真正的历史状态呢？&lt;/p&gt;
&lt;p&gt;可以用computed+JSON.parse(JSON.stringify())的方式浅拷贝一下再监听,比如刚才的cartList：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const cartListCopy = computed(() =&amp;gt; JSON.parse(JSON.stringify(cartList.value)))
watch([cartListCopy, useCoupon, couponValue], (newVals, oldVals) =&amp;gt; {
  console.log(&amp;#39;旧的购物车:&amp;#39;, oldVals[0]) // 现在能拿到真正的旧值了
  // 计算总价的逻辑不变
}, { immediate: true })&lt;/pre&gt;
&lt;p&gt;不过要注意，JSON深拷贝有局限性，比如不能拷贝函数、正则表达式、Symbol这些特殊类型，如果你的数据里有这些,就得用lodash的cloneDeep或者自己写递归深拷贝了。&lt;/p&gt;
&lt;h3&gt;第二个坑：immediate和deep的组合使用要注意时机&lt;/h3&gt;
&lt;p&gt;比如刚才的&lt;code&gt;allSelectedCheck&lt;/code&gt;例子，如果同时加了&lt;code&gt;immediate: true&lt;/code&gt;和&lt;code&gt;deep: true&lt;/code&gt;（虽然这个例子不需要deep），初始化的时候会触发一次watch，这个没问题，但如果你的初始化逻辑依赖于DOM渲染后的状态，比如要修改某个DOM元素的样式，这时候immediate触发的时候DOM可能还没挂载，得用&lt;code&gt;nextTick&lt;/code&gt;包一下初始化的逻辑。&lt;/p&gt;
&lt;h3&gt;第三个坑：监听多个基本类型ref的时候，不要随便用箭头函数的简写&lt;/h3&gt;
&lt;p&gt;哦这个是语法坑，不是Vue的坑，但新手也容易写错，比如刚才的例子里，如果把第一个参数写成&lt;code&gt;() =&amp;gt; [cartList.value, useCoupon.value, couponValue.value]&lt;/code&gt;，这其实是对的，相当于把ref解包后的值打包成一个新的数组传给watch，这时候Vue会监听这个函数返回值的变化——不管是基本类型还是对象类型，都相当于“自定义监听源”，但如果是直接写&lt;code&gt;[cartList, useCoupon, couponValue]&lt;/code&gt;，Vue会自动解包ref，不需要.value，两种写法都是可以的，但要注意不要混用，比如写成&lt;code&gt;[cartList.value, useCoupon, couponValue]&lt;/code&gt;，那第一个项就变成了普通的基本类型/对象值，不是响应式的了，后面两个useCoupon和couponValue虽然是ref，但Vue可能会检测出第一个项的问题,导致监听失效或者报错。&lt;/p&gt;
&lt;h2&gt;总结一下三种方式的适用场景&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;打包成数组直接watch&lt;/strong&gt;：适合所有需要同步监听多个ref的场景，不管有没有依赖逻辑，但如果依赖逻辑比较复杂或者只需要监听部分属性变化,效率不如第二种。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;computed关联后再watch&lt;/strong&gt;：适合需要先对多个ref做一步筛选/计算，只对结果变化做响应的场景，效率最高,推荐优先使用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单独watch加防抖/节流&lt;/strong&gt;：不推荐常规使用,只有极个别独立更新但需要临时同步的场景才用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后再给大家留个小作业吧：如果你的项目里有一个表单，包含姓名、手机号、邮箱三个ref，需要三个都填写正确（正则验证通过）之后才能点击提交按钮，你会用哪种方式呢？欢迎在评论区留言讨论哦！&lt;/p&gt;</description><pubDate>Wed, 06 May 2026 08:00:46 +0800</pubDate></item><item><title>Vue3 watch Map怎么监听才能生效？注意事项有哪些？</title><link>https://codeqd.com/post/20260521808.html</link><description>&lt;p&gt;做前端开发的小伙伴最近肯定都有感觉,Vue3的响应式系统虽然比Vue2强，但碰到复杂数据类型比如Map还是容易踩雷——明明代码改了Map里的值，watch却纹丝不动，页面也不会更新；要么就是watch了但监听到的是同一对象，没法做值对比；还有想只监听某个key的变化，又不知道怎么精准触发，这些问题我之前写后台管理系统时全踩过，折腾了好几天，才摸出一套既实用又靠谱的方案。&lt;/p&gt;
&lt;h2&gt;Vue3 watch 直接监听Map实例为啥经常没用？&lt;/h2&gt;
&lt;p&gt;刚开始遇到问题时,我第一反应是Vue3的Proxy是不是出bug了，后来翻了响应式原理的资料才明白，根本不是这么回事：Vue3的watch默认只会监听引用数据类型的引用变化，Map作为典型的引用类型，只有整个实例被重新赋值时（比如&lt;code&gt;map.value = new Map([[1,2]])&lt;/code&gt;），才会触发默认的浅监听；而我们平时用Map的set、delete、clear这些原生方法改内容，引用地址一点没变，watch自然就抓不到信号。&lt;/p&gt;
&lt;p&gt;那Proxy为啥没拦截这些方法呢？其实拦截了！Vue3给响应式Map（也就是ref或者reactive包裹过的Map，建议用ref包裹更方便赋值判断）加的Proxy处理器里，专门重写了get、set、deleteProperty这些核心方法，修改内容时会触发内部的响应式更新，页面其实是会跟着变的——只不过默认的watch没有开启深层监听，看不到这些“内部的小变动”而已。&lt;/p&gt;
&lt;p&gt;比如这段新手常写的代码,控制台只会打印一次“初始化监听”，不管怎么点按钮修改age，都不会再触发回调：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const userMap = ref(new Map([
  [&amp;#39;name&amp;#39;, &amp;#39;小明&amp;#39;],
  [&amp;#39;age&amp;#39;, 18]
]))
// 直接监听userMap.value或者userMap，都不会抓内部方法的修改
watch(userMap, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;userMap变了！&amp;#39;, newVal, oldVal)
}, {
  immediate: true
})
const updateAge = () =&amp;gt; {
  userMap.value.set(&amp;#39;age&amp;#39;, userMap.value.get(&amp;#39;age&amp;#39;) + 1)
}
&amp;lt;/script&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;姓名：{{ userMap.get(&amp;#39;name&amp;#39;) }}&amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;年龄：{{ userMap.get(&amp;#39;age&amp;#39;) }}&amp;lt;/div&amp;gt;
  &amp;lt;button @click=&amp;quot;updateAge&amp;quot;&amp;gt;加一岁&amp;lt;/button&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;不信你可以复制这段代码跑一下,页面上的年龄肯定会跳，但控制台就只有初始化那一行。&lt;/p&gt;
&lt;h2&gt;Vue3 watch Map生效的3种常用方法&lt;/h2&gt;
&lt;p&gt;既然知道了问题出在“深层监听”或者“精准追踪依赖”上，解决办法就有针对性了，我整理了3种常用且实用的，覆盖了日常开发的绝大部分场景，每种都有优缺点和适用场景，大家可以按需选。&lt;/p&gt;
&lt;h3&gt;开启deep: true深层监听&lt;/h3&gt;
&lt;p&gt;这是最直接的办法,给watch加个&lt;code&gt;{ deep: true }&lt;/code&gt;配置项就行，不管是用ref还是reactive包裹的Map，内部的set、delete、clear、键值修改全都会触发回调，不过要注意，开启deep之后，Vue3会递归遍历整个响应式对象/Map/Set，性能会有一定损耗——如果你的Map里有成千上万个键值对，或者键对应的值又是多层嵌套的对象数组，频繁触发深层监听会影响页面流畅度，这时候就得考虑后面两种方法了。&lt;/p&gt;
&lt;p&gt;刚才那段代码加个deep就能跑通：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watch(userMap, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;userMap变了！&amp;#39;, newVal, oldVal)
}, {
  immediate: true,
  deep: true // 开启深层监听
})&lt;/pre&gt;
&lt;p&gt;不过这里还有个小坑：开启deep之后，newVal和oldVal会是同一个引用！因为Proxy拦截的是内部操作，没有重新生成新的Map实例，如果你需要对比修改前后的完整内容，得自己手动深拷贝一份oldVal或者newVal，比如用JSON序列化，但要注意JSON序列化会丢失Date、RegExp、Function这些特殊类型，最好用Vue3内置的&lt;code&gt;structuredClone&lt;/code&gt;（Vue3.3+才完全兼容，旧版本可以用lodash的cloneDeep）。&lt;/p&gt;
&lt;h3&gt;返回一个函数，精准追踪需要监听的键&lt;/h3&gt;
&lt;p&gt;如果你的Map只有少数几个键需要监听,或者键值对很多但不想开启深层监听，这个方法绝对是首选！给watch的第一个参数传一个箭头函数，函数里专门调用你要监听的键对应的get方法，这样Vue3的响应式系统只会追踪这些get操作产生的依赖，不会遍历整个Map，性能损耗几乎为零，而且newVal和oldVal也能正确区分，不用手动深拷贝。&lt;/p&gt;
&lt;p&gt;比如只想监听userMap里的age键,代码可以写成这样：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watch(
  () =&amp;gt; userMap.value.get(&amp;#39;age&amp;#39;), // 只追踪age的get依赖
  (newAge, oldAge) =&amp;gt; {
    console.log(&amp;#39;年龄变了！&amp;#39;, newAge, oldAge)
  },
  { immediate: true }
)&lt;/pre&gt;
&lt;p&gt;这段代码不仅性能好,而且控制台打印的oldAge和newAge是正确的数值，不会出现引用相同的问题，如果要监听多个键，可以在箭头函数里返回一个数组，把所有需要追踪的键值放进去：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watch(
  () =&amp;gt; [userMap.value.get(&amp;#39;name&amp;#39;), userMap.value.get(&amp;#39;age&amp;#39;)],
  ([newName, newAge], [oldName, oldAge]) =&amp;gt; {
    console.log(&amp;#39;姓名或年龄变了！&amp;#39;, { newName, newAge, oldName, oldAge })
  },
  { immediate: true }
)&lt;/pre&gt;
&lt;p&gt;这里还有个进阶用法：如果Map的键是动态生成的，可以把键也放在数组里一起返回吗？其实不用，只要动态键存在的时候你调用过对应的get方法，响应式系统就能追踪到——比如有个input框输入动态键，按钮点击后给这个键赋值，只要你在watch的箭头函数里遍历一遍Map的所有键调用get？不对，遍历调用get反而又有点像深层监听了，性能会下降，更好的办法是用computed缓存动态键的状态，或者直接把动态键的生成逻辑和监听get逻辑绑定在一起。&lt;/p&gt;
&lt;h3&gt;监听Map的size属性&lt;/h3&gt;
&lt;p&gt;如果你只需要知道Map里有没有新增或者删除键（不管具体是哪个键，也不管键对应的值有没有变），监听size属性是最快的！因为set会让size加1（如果之前没有这个键），delete减1，clear减到0，修改已有键的value不会改变size，刚好适合做“数量变化”的场景，比如后台管理系统里的购物车商品数量提示、已选项目数量变化触发批量操作按钮的启用禁用等等。&lt;/p&gt;
&lt;p&gt;监听size属性和精准监听键的方法一样,用箭头函数返回size就行：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watch(
  () =&amp;gt; userMap.value.size,
  (newSize, oldSize) =&amp;gt; {
    console.log(&amp;#39;Map的数量变了！&amp;#39;, newSize, oldSize)
  },
  { immediate: true }
)&lt;/pre&gt;
&lt;p&gt;这段代码的性能几乎可以忽略不计,因为size是Map的一个普通属性，Proxy拦截起来非常快。&lt;/p&gt;
&lt;h2&gt;Vue3 watch Map还有哪些容易忽略的细节？&lt;/h2&gt;
&lt;p&gt;刚才说的3种方法已经能解决大部分问题,但还有几个细节容易踩雷，我整理了4个最常见的，大家一定要注意：&lt;/p&gt;
&lt;h3&gt;watch和watchEffect的区别&lt;/h3&gt;
&lt;p&gt;很多新手会分不清watch和watchEffect,其实用在Map上区别挺大的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;watch需要明确指定依赖（比如ref实例、带deep的ref实例、返回键值的箭头函数、返回size的箭头函数），只有依赖变化时才会触发回调，有newVal和oldVal；&lt;/li&gt;
&lt;li&gt;watchEffect会自动收集依赖（只要在回调函数里调用了响应式数据的任何属性或方法，都会被收集），初始化时会自动执行一次，没有oldVal，只有当前的newVal。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如用watchEffect监听Map的age：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watchEffect(() =&amp;gt; {
  console.log(&amp;#39;年龄现在是：&amp;#39;, userMap.value.get(&amp;#39;age&amp;#39;))
})&lt;/pre&gt;
&lt;p&gt;这段代码初始化会打印一次,每次修改age也会打印，但不会打印oldAge，如果你的需求不需要oldVal，而且依赖很多不想一个个写在watch的第一个参数里，watchEffect会更方便，但要注意不要在watchEffect里写太多无关的响应式数据操作，否则会频繁触发回调。&lt;/p&gt;
&lt;h3&gt;ref包裹Map和reactive包裹Map的区别&lt;/h3&gt;
&lt;p&gt;虽然两者都能让Map变成响应式,但用法上有一些小差异：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ref包裹Map时,访问和修改都需要加&lt;code&gt;.value&lt;/code&gt;，比如&lt;code&gt;userMap.value.set(&#039;a&#039;,1)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;reactive包裹Map时,不需要加&lt;code&gt;.value&lt;/code&gt;，直接访问和修改就行，比如&lt;code&gt;userMap.set(&#039;a&#039;,1)&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;用watch监听时,不管是ref还是reactive，带deep的直接传实例；精准监听键或者size时，ref要加&lt;code&gt;.value&lt;/code&gt;，reactive不用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;那到底用ref还是reactive呢？其实官方推荐用ref包裹引用数据类型，因为ref可以直接判断是否为响应式（用isRef），而且以后如果需要把整个Map实例替换掉，ref更方便——reactive替换掉整个实例的话，原来的响应式绑定就会失效，必须用Object.assign之类的方法把新内容合并进去。&lt;/p&gt;
&lt;h3&gt;Map里的嵌套对象/数组修改时的处理&lt;/h3&gt;
&lt;p&gt;如果Map里的键对应的值是嵌套的对象或数组,&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;const userMap = ref(new Map([
  [&amp;#39;name&amp;#39;, &amp;#39;小明&amp;#39;],
  [&amp;#39;hobbies&amp;#39;, [&amp;#39;打篮球&amp;#39;, &amp;#39;踢足球&amp;#39;]]
]))&lt;/pre&gt;
&lt;p&gt;这时候修改hobbies数组的内容（比如push、pop），用deep:true可以监听到，用精准监听数组的方法（比如&lt;code&gt;() =&amp;gt; [...userMap.value.get(&#039;hobbies&#039;)]&lt;/code&gt;）也可以监听到，但要注意用精准监听数组的话，最好是返回数组的浅拷贝或者JSON序列化后的结果，否则如果只是修改数组的某个索引值（比如&lt;code&gt;userMap.value.get(&#039;hobbies&#039;)[0] = &#039;游泳&#039;&lt;/code&gt;），返回原数组的话引用地址没变，watch不会触发。&lt;/p&gt;
&lt;h3&gt;Vue2转Vue3时要注意的旧写法&lt;/h3&gt;
&lt;p&gt;有些从Vue2转过来的小伙伴会习惯用&lt;code&gt;$watch&lt;/code&gt;，但Vue3的组合式API里没有$watch，要用watch或者watchEffect；还有Vue2里用&lt;code&gt;Vue.set&lt;/code&gt;给对象加新属性才能响应式，但Vue3里不管是ref还是reactive包裹的Map，用原生的set、delete、clear都是响应式的，不需要任何额外的方法。&lt;/p&gt;
&lt;p&gt;Vue3 watch Map生效的核心逻辑其实就是利用Proxy的特性，要么开启deep:true让watch递归追踪内部变化（适合数据量小的场景），要么用返回键值/返回size的箭头函数精准追踪依赖（适合数据量大的场景），还要注意newVal和oldVal的引用问题、ref和reactive的用法区别、嵌套数据的处理方式。&lt;/p&gt;
&lt;p&gt;我之前踩坑的时候还写了个小demo测试了这3种方法的性能,在Map里放了10000个键值对，每个键对应一个有3层嵌套的对象，开启deep:true修改其中一个键值，大概需要0.2-0.5毫秒；用返回单个键值的箭头函数修改同一个键值，大概只需要0.005-0.01毫秒，差距其实挺明显的，所以大家在开发的时候一定要根据场景选对方法，不要图省事直接开deep。&lt;/p&gt;
&lt;p&gt;希望这篇文章能帮到正在踩坑的小伙伴,如果还有其他关于Vue3响应式系统的问题，欢迎在评论区留言讨论！&lt;/p&gt;</description><pubDate>Tue, 05 May 2026 20:01:16 +0800</pubDate></item><item><title>Vue3中监听v-model绑定值变化，watch到底怎么写才靠谱？</title><link>https://codeqd.com/post/20260521807.html</link><description>&lt;p&gt;咱们平时写Vue3项目,不管是表单输入、组件嵌套还是状态联动，v-model肯定是天天碰的玩意儿，但一旦要监听它的变化，很多人要么就是把旧写法Vue2那套直接搬过来报错，要么就是能跑但不知道原理踩坑（比如深层对象/数组只变属性没触发、初始值要不要处理、嵌套组件v-model双向绑不对传参监听），今天就把这事儿掰碎了说，从基础写法到进阶避坑再到最佳实践，一步一步来。&lt;/p&gt;
&lt;h2&gt;为什么Vue2的watch写法搬Vue3会出问题？&lt;/h2&gt;
&lt;p&gt;先回忆下Vue2的操作：要监听data里的model值，直接&lt;code&gt;watch: { userName(newVal, oldVal) { // do something } }&lt;/code&gt;，深层对象加个&lt;code&gt;deep: true&lt;/code&gt;，初始加载触发加&lt;code&gt;immediate: true&lt;/code&gt;，那为啥到Vue3里，用setup的&lt;code&gt;watch&lt;/code&gt;或者&lt;code&gt;watchEffect&lt;/code&gt;照搬data里的变量就报错？甚至把ref/reactive的东西直接传进去也不生效？&lt;/p&gt;
&lt;p&gt;根本原因是Vue3的响应式系统彻底换了底层逻辑：Vue2是Object.defineProperty，直接拦截data对象的属性；Vue3是Proxy，整个ref的.value包裹、reactive的代理对象才是真正的响应式载体，举个最常见的反例：如果你在setup里写&lt;code&gt;const userName = &#039;张三&#039;; watch(userName, () =&amp;gt; { alert(&#039;变了&#039;) })&lt;/code&gt;，那这个userName是个普通字符串，Proxy根本没包裹它，watch自然抓不到变化。&lt;/p&gt;
&lt;h2&gt;不同场景下，watch监听v-model值的正确打开方式&lt;/h2&gt;
&lt;p&gt;既然底层逻辑变了,咱们得先理清楚v-model在Vue3里到底绑定的是什么——绑定的一定是ref的.value、reactive的属性、或者经过computed处理的响应式值，那分三种高频场景说：&lt;/p&gt;
&lt;h3&gt;场景1：监听单个ref绑定的原生表单v-model&lt;/h3&gt;
&lt;p&gt;原生表单是最基础的,比如input、select、textarea，v-model默认绑定的就是ref.value，这时候的写法最简单，举个例子：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
export default {
  setup() {
    const userPhone = ref(&amp;#39;&amp;#39;)
    // 写法1：直接传ref变量本身，别带.value！
    watch(userPhone, (newVal, oldVal) =&amp;gt; {
      console.log(&amp;#39;手机号从&amp;#39;, oldVal, &amp;#39;变成了&amp;#39;, newVal)
      // 这里可以做手机号校验、防抖请求接口啥的
    })
    return { userPhone }
  }
}&lt;/pre&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;tel&amp;quot; v-model=&amp;quot;userPhone&amp;quot; placeholder=&amp;quot;请输入手机号&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里一定要注意：watch的第一个参数如果是ref，直接写变量名，不能写&lt;code&gt;userPhone.value&lt;/code&gt;！因为&lt;code&gt;userPhone.value&lt;/code&gt;是个普通值（或者是深层对象的话也是普通引用路径的最后一步），传进去的那一刻就固定了，后续Proxy的变化根本传不进去watch的回调。&lt;/p&gt;
&lt;p&gt;那如果我非要带.value行不行？也不是不行，但得包个箭头函数当 getter：&lt;code&gt;watch(() =&amp;gt; userPhone.value, ...)&lt;/code&gt;，不过这种写法对单个ref来说完全没必要，直接传变量更简洁，性能也没差多少（getter函数的开销很小，但单个ref是内部优化过的监听路径）。&lt;/p&gt;
&lt;h3&gt;场景2：监听reactive对象/数组绑定的原生/自定义表单v-model&lt;/h3&gt;
&lt;p&gt;自定义表单或者复杂表单组,通常会用reactive包裹整个对象或者数组，这时候v-model要么绑定&lt;code&gt;formData.phone&lt;/code&gt;这种reactive的属性，要么绑定&lt;code&gt;formData.addresses[0].city&lt;/code&gt;这种深层嵌套的属性，写法得分两种情况：&lt;/p&gt;
&lt;h4&gt;情况A：只监听reactive对象的单个属性&lt;/h4&gt;
&lt;p&gt;不管属性深不深,都必须用&lt;strong&gt;箭头函数getter&lt;/strong&gt;的写法，和单个ref带.value的包法一样，但原理不同——因为reactive对象本身的属性访问是经过Proxy拦截的，但单个属性如果是基本类型（string/number/boolean等），单独取出来就是普通值；如果是引用类型（子对象/子数组），单独取出来如果没被单独Proxy处理（reactive深层属性会自动被嵌套代理，这个是优化点），其实也能监听，但用getter更稳妥，是官方推荐的写法。&lt;/p&gt;
&lt;p&gt;比如监听单个基本类型属性：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watch } from &amp;#39;vue&amp;#39;
export default {
  setup() {
    const formData = reactive({
      userName: &amp;#39;&amp;#39;,
      userAge: 0,
      addresses: [{ city: &amp;#39;北京&amp;#39;, district: &amp;#39;朝阳区&amp;#39; }]
    })
    // 监听单个基本类型
    watch(() =&amp;gt; formData.userName, (newVal) =&amp;gt; {
      console.log(&amp;#39;用户名变了:&amp;#39;, newVal)
    })
    // 监听单个深层引用类型的属性
    watch(() =&amp;gt; formData.addresses[0].district, (newVal) =&amp;gt; {
      console.log(&amp;#39;朝阳区还是海淀区？变了:&amp;#39;, newVal)
    })
    return { formData }
  }
}&lt;/pre&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;input v-model=&amp;quot;formData.userName&amp;quot; placeholder=&amp;quot;用户名&amp;quot; /&amp;gt;
    &amp;lt;input type=&amp;quot;number&amp;quot; v-model.number=&amp;quot;formData.userAge&amp;quot; placeholder=&amp;quot;年龄&amp;quot; /&amp;gt;
    &amp;lt;div v-for=&amp;quot;(addr, index) in formData.addresses&amp;quot; :key=&amp;quot;index&amp;quot;&amp;gt;
      &amp;lt;input v-model=&amp;quot;addr.city&amp;quot; placeholder=&amp;quot;城市&amp;quot; /&amp;gt;
      &amp;lt;input v-model=&amp;quot;addr.district&amp;quot; placeholder=&amp;quot;区县&amp;quot; /&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里如果不包getter,直接写&lt;code&gt;formData.userName&lt;/code&gt;，肯定不生效；直接写&lt;code&gt;formData&lt;/code&gt;，则会监听整个对象的&lt;strong&gt;任何&lt;/strong&gt;属性变化，包括子对象的子属性，开销会很大，除非你真的需要监听整个表单的所有变动。&lt;/p&gt;
&lt;h4&gt;情况B：监听整个reactive对象/数组的所有变动&lt;/h4&gt;
&lt;p&gt;这时候可以直接传reactive对象本身,但一定要加&lt;code&gt;deep: true&lt;/code&gt;吗？等下，官方文档里说过：如果直接传reactive对象给watch，默认就是开启&lt;code&gt;deep: true&lt;/code&gt;的！不信你试试把&lt;code&gt;deep: true&lt;/code&gt;去掉，修改子对象的子属性，回调照样触发，这是Vue3对reactive的特殊优化，因为Proxy本身就能拦截深层属性的访问和修改，所以不需要手动加deep，但开销还是存在的——如果对象非常大（比如几百上千条数据的数组），每次有属性变动都触发回调，可能会影响页面性能。&lt;/p&gt;
&lt;p&gt;什么时候才需要手动加deep？如果你的getter返回的是reactive对象的子对象/子数组，那这时候默认是不开启deep的，&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 监听地址数组的变化，只监听数组长度、元素增删，不监听元素内部的属性
watch(() =&amp;gt; formData.addresses, (newAddrs, oldAddrs) =&amp;gt; {
  console.log(&amp;#39;地址数组的结构变了&amp;#39;)
})
// 监听地址数组的**任何**变化，包括元素内部的属性
watch(() =&amp;gt; formData.addresses, (newAddrs, oldAddrs) =&amp;gt; {
  console.log(&amp;#39;地址数组有变动，不管是结构还是内部属性&amp;#39;)
}, { deep: true })&lt;/pre&gt;
&lt;p&gt;这个区别很重要,很多新手分不清，导致要么监听不到想要的，要么监听的太泛浪费性能。&lt;/p&gt;
&lt;h3&gt;场景3：监听嵌套组件双向绑定的v-model值&lt;/h3&gt;
&lt;p&gt;Vue3里嵌套组件的v-model写法也变了——不再是默认的&lt;code&gt;value&lt;/code&gt; prop和&lt;code&gt;input&lt;/code&gt; event，而是默认的&lt;code&gt;modelValue&lt;/code&gt; prop和&lt;code&gt;update:modelValue&lt;/code&gt; event，而且支持同时绑定多个v-model（比如&lt;code&gt;&amp;lt;Child v-model:name=&quot;userName&quot; v-model:age=&quot;userAge&quot; /&amp;gt;&lt;/code&gt;），那在父组件里监听这些绑定值，和前面两种场景完全一样，因为绑定的还是父组件的ref/reactive响应式值；但如果在子组件内部监听父组件传过来的modelValue呢？&lt;/p&gt;
&lt;p&gt;在子组件内部监听的话,首先要注意：prop是只读的，不能直接修改，必须通过emit &lt;code&gt;update:modelValue&lt;/code&gt;来同步父组件的值，监听prop的话，prop本身如果是父组件传的ref.value或者reactive的属性，直接传prop给watch（或者用getter包）是可以的，因为子组件接收的prop如果是响应式的，Vue会自动保持响应式（前提是父组件没有用&lt;code&gt;.prop&lt;/code&gt;或者&lt;code&gt;.attr&lt;/code&gt;这种非响应式的绑定修饰符）。&lt;/p&gt;
&lt;p&gt;举个例子,写一个子组件NumberInput，父组件传一个modelValue（number类型），子组件监听它的变化，并且自己维护一个本地的输入框字符串ref（避免输入非数字字符的时候直接触发父组件校验报错）：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// Child.vue 子组件
import { ref, watch } from &amp;#39;vue&amp;#39;
export default {
  props: {
    modelValue: {
      type: Number,
      default: 0
    }
  },
  emits: [&amp;#39;update:modelValue&amp;#39;],
  setup(props, { emit }) {
    // 本地输入框ref，存字符串，方便过滤非数字
    const localInput = ref(String(props.modelValue))
    // 监听父组件传过来的modelValue变化，同步到本地输入框
    watch(props, (newProps) =&amp;gt; {
      localInput.value = String(newProps.modelValue)
    })
    // 监听本地输入框的变化，过滤非数字，再同步给父组件
    watch(localInput, (newVal) =&amp;gt; {
      const num = Number(newVal.replace(/[^\d]/g, &amp;#39;&amp;#39;))
      emit(&amp;#39;update:modelValue&amp;#39;, isNaN(num) ? 0 : num)
    })
    return { localInput }
  }
}&lt;/pre&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;localInput&amp;quot; placeholder=&amp;quot;请输入数字&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// Parent.vue 父组件
import { ref, watch } from &amp;#39;vue&amp;#39;
import Child from &amp;#39;./Child.vue&amp;#39;
export default {
  components: { Child },
  setup() {
    const userScore = ref(0)
    // 父组件监听嵌套组件的v-model:modelValue值
    watch(userScore, (newScore) =&amp;gt; {
      console.log(&amp;#39;用户分数变了:&amp;#39;, newScore)
    })
    return { userScore }
  }
}&lt;/pre&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h3&amp;gt;当前分数：{{ userScore }}&amp;lt;/h3&amp;gt;
    &amp;lt;Child v-model=&amp;quot;userScore&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里子组件里直接传&lt;code&gt;props&lt;/code&gt;给watch是可以的，会监听所有prop的变化；如果只想监听modelValue，就用&lt;code&gt;watch(() =&amp;gt; props.modelValue, ...)&lt;/code&gt;，更精准。&lt;/p&gt;
&lt;h2&gt;进阶避坑：那些容易踩的watch监听v-model的坑&lt;/h2&gt;
&lt;p&gt;刚才说了基础写法,现在说几个开发中90%的人都会踩的坑：&lt;/p&gt;
&lt;h3&gt;坑1：深层对象/数组的newVal和oldVal一样&lt;/h3&gt;
&lt;p&gt;这个坑不管是Vue2还是Vue3都有,但Vue3里更常见，因为直接传reactive对象默认开启deep，为什么会一样？因为Proxy返回的是同一个代理对象的引用，不管你怎么修改内部属性，代理对象本身的引用地址是不变的，所以watch拿到的newVal和oldVal都是同一个东西。&lt;/p&gt;
&lt;p&gt;怎么解决？如果需要对比新旧值的差异，有两种方法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;浅拷贝/深拷贝旧值&lt;/strong&gt;：用getter返回的时候，顺便拷贝一份，比如&lt;code&gt;watch(() =&amp;gt; JSON.parse(JSON.stringify(formData)), (newVal, oldVal) =&amp;gt; { ... })&lt;/code&gt;，但JSON.parse(JSON.stringify())有局限性（不能处理函数、Symbol、Date等特殊类型）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用VueUse的useWatch或者watchDeep配合computed缓存旧值&lt;/strong&gt;：VueUse是一个非常流行的Vue3工具库，里面的useWatch可以帮你自动处理新旧值的深拷贝对比，或者你可以自己写一个computed缓存旧值：&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watch, computed } from &amp;#39;vue&amp;#39;
export default {
setup() {
 const formData = reactive({ userName: &amp;#39;张三&amp;#39;, age: 20 })
 // 缓存旧值的computed，computed是惰性的，只有当formData.userName变了才会重新计算
 const oldUserName = computed(() =&amp;gt; formData.userName)
 let prevOldUserName = oldUserName.value
 watch(() =&amp;gt; formData.userName, (newVal) =&amp;gt; {
   console.log(&amp;#39;新值:&amp;#39;, newVal, &amp;#39;旧值:&amp;#39;, prevOldUserName)
   prevOldUserName = newVal
 })
 return { formData }
}
}&lt;/pre&gt;
&lt;p&gt;这个方法更稳妥,没有JSON的局限性，性能也不错。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;坑2：immediate: true的时候oldVal是undefined&lt;/h3&gt;
&lt;p&gt;这个不管是Vue2还是Vue3也都有,是正常现象——因为immediate是在组件挂载前（或者setup执行完后立即）触发一次回调，这时候还没有旧值，所以oldVal是undefined，如果你的逻辑里必须用到oldVal，要先做个判断：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;watch(userPhone, (newVal, oldVal) =&amp;gt; {
  if (oldVal === undefined) {
    console.log(&amp;#39;初始加载，手机号是:&amp;#39;, newVal)
    return
  }
  console.log(&amp;#39;手机号从&amp;#39;, oldVal, &amp;#39;变成了&amp;#39;, newVal)
}, { immediate: true })&lt;/pre&gt;
&lt;h3&gt;坑3：数组用索引修改元素或者直接修改length属性，watch没触发&lt;/h3&gt;
&lt;p&gt;等等,这个坑在Vue3里应该已经被解决了吧？对！Vue2里因为Object.defineProperty的局限性，直接修改数组索引或者length属性是不会触发响应式的；但Vue3用的是Proxy，不管你是修改索引、length、还是用push/pop/splice等方法，都会触发响应式，包括watch的回调，不信你试试：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watch } from &amp;#39;vue&amp;#39;
export default {
  setup() {
    const list = reactive([1, 2, 3])
    watch(() =&amp;gt; list, (newList) =&amp;gt; {
      console.log(&amp;#39;数组变了:&amp;#39;, newList)
    }, { deep: true })
    // 测试修改索引
    setTimeout(() =&amp;gt; list[1] = 22, 1000)
    // 测试修改length
    setTimeout(() =&amp;gt; list.length = 2, 2000)
    return { list }
  }
}&lt;/pre&gt;
&lt;p&gt;控制台会分别在1秒和2秒的时候打印数组变化,所以这个坑在Vue3里已经不存在了，不用再像Vue2那样用Vue.set或者splice了。&lt;/p&gt;
&lt;h3&gt;坑4：监听computed返回的响应式值，但computed本身没有依赖变化&lt;/h3&gt;
&lt;p&gt;computed是&lt;strong&gt;依赖追踪&lt;/strong&gt;的，只有当它的依赖项（ref/reactive的属性）变化时，才会重新计算；如果computed没有依赖任何响应式值，或者依赖项没变，那watch监听computed是不会触发的，不管computed的返回值有没有被手动修改（当然computed的返回值是只读的，不能手动修改，除非是computed的setter）。&lt;/p&gt;
&lt;h2&gt;最佳实践：什么时候用watch，什么时候用watchEffect？&lt;/h2&gt;
&lt;p&gt;除了watch,Vue3还有一个watchEffect，很多人分不清什么时候用哪个，这里给个简单的判断标准：&lt;/p&gt;
&lt;h3&gt;用watch的情况：&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;需要明确知道依赖项&lt;/strong&gt;：比如只监听userPhone，不监听其他的；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要获取新旧值&lt;/strong&gt;：比如要对比手机号的变化做校验；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不需要初始加载就触发&lt;/strong&gt;：比如只有当用户修改了手机号才请求接口；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要手动控制监听的开启和停止&lt;/strong&gt;：watch的返回值是一个stop函数，调用stop()就可以停止监听，&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const stopWatch = watch(userPhone, (newVal) =&amp;gt; {
if (newVal.length === 11) {
 console.log(&amp;#39;手机号格式正确，停止监听&amp;#39;)
 stopWatch()
}
})&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;用watchEffect的情况：&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不需要明确知道依赖项&lt;/strong&gt;：比如只要用到的任何响应式值变化，就执行回调，比如自动保存表单：&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watchEffect } from &amp;#39;vue&amp;#39;
export default {
setup() {
 const formData = reactive({ userName: &amp;#39;&amp;#39;, phone: &amp;#39;&amp;#39;, email: &amp;#39;&amp;#39; })
 // 只要formData的任何属性变化，就自动保存到localStorage
 watchEffect(() =&amp;gt; {
   localStorage.setItem(&amp;#39;userForm&amp;#39;, JSON.stringify(formData))
 })
 return { formData }
}
}&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要初始加载就触发&lt;/strong&gt;：watchEffect默认就是immediate: true，不需要手动加；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不需要获取新旧值&lt;/strong&gt;：比如自动保存只需要最新的值。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;总结一下&lt;/h2&gt;
&lt;p&gt;Vue3监听v-model的变化，核心就是抓住响应式载体：单个ref直接传变量，reactive的单个属性用getter包，整个reactive对象默认deep但要注意性能，嵌套组件父子监听的原理一样，子组件不能直接改prop要emit，避坑的话，注意新旧值对比、immediate的oldVal、还有不用再纠结Vue2的数组索引坑，根据需求选watch还是watchEffect，不要乱用。&lt;/p&gt;
&lt;p&gt;好了,今天的内容就到这里，有什么问题可以在评论区留言，咱们一起讨论！&lt;/p&gt;</description><pubDate>Tue, 05 May 2026 14:01:31 +0800</pubDate></item><item><title>Vue3中怎么高效监听多个props？不同场景下选哪种方式最靠谱？</title><link>https://codeqd.com/post/20260521806.html</link><description>&lt;p&gt;很多刚从Vue2转过来的前端开发者，或者学Vue3组件传值监听刚入门的朋友，应该都碰到过这个场景：父组件传了不止一个props给子组件，子组件要在这些props变化的时候做统一的逻辑处理，比如发请求、更新本地状态、联动第三方库组件，这时候直接用单个watch去写N次？显然太啰嗦了，那有没有更优雅的方式？不同方式之间有啥坑？今天就拆解透这个问题,结合真实开发的场景给你实用的方案。&lt;/p&gt;
&lt;h2&gt;先快速回忆单个props的Vue3 watch怎么写？&lt;/h2&gt;
&lt;p&gt;在说多props之前，得先把单个props的基础写法捋清楚,不然多props的进阶逻辑容易混。&lt;/p&gt;
&lt;p&gt;Vue3的组合式API里有两种监听方法：&lt;code&gt;watch&lt;/code&gt;和&lt;code&gt;watchEffect&lt;/code&gt;。&lt;code&gt;watch&lt;/code&gt;是“懒执行”的——只有监听的源变化了才会触发回调；&lt;code&gt;watchEffect&lt;/code&gt;是“立即执行+自动追踪依赖”的——初始化页面就会跑一次，之后用到的响应式数据（包括props）变化都会自动触发,不用显式列出来。&lt;/p&gt;
&lt;p&gt;单个props的&lt;code&gt;watch&lt;/code&gt;写法大概是这样的：
假设父组件传了个&lt;code&gt;userId: Number&lt;/code&gt;的props,子组件要在它变的时候查用户信息。&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watch, ref } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userId&amp;#39;])
const userInfo = ref(null)
// 单个源，懒执行，有旧值新值
watch(
  () =&amp;gt; props.userId, // 这里注意：如果是基本类型的props，直接写props.userId也行？但最好用getter函数，因为组合式API里的props是响应式对象，但解构赋值（除非用toRefs/toRef）会丢失响应性，用getter更稳妥，不管后续改不改结构都没问题
  (newId, oldId) =&amp;gt; {
    console.log(`用户ID从${oldId}变成了${newId}`)
    // 这里写查用户的接口逻辑
  }
)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;单个源的&lt;code&gt;watchEffect&lt;/code&gt;写法更短，但没有旧值,而且不能指定懒执行：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watchEffect, ref } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userId&amp;#39;])
const userInfo = ref(null)
watchEffect(() =&amp;gt; {
  // 直接在这里用props.userId，系统会自动追踪
  console.log(`当前用户ID是${props.userId}`)
  // 查接口
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h2&gt;Vue3 watch多props的核心4种写法，附适用场景和避坑指南&lt;/h2&gt;
&lt;p&gt;好了，基础铺垫完了，现在进入正题：多props监听。&lt;/p&gt;
&lt;h3&gt;写法1：数组形式的watch（最常用的Vue2转Vue3平滑过渡方案）&lt;/h3&gt;
&lt;p&gt;Vue2里很多人已经习惯了用数组列多个源，比如&lt;code&gt;watch: { &#039;a,b&#039;(newVal, oldVal) { ... } }&lt;/code&gt;，Vue3完美保留了这个特性，但参数格式有点不一样——Vue2数组形式的旧值新值是对应的数组，比如a变了但b没动，b的旧值新值也是一样的；Vue3组合式API的数组形式也是这样。&lt;/p&gt;
&lt;h4&gt;适用场景&lt;/h4&gt;
&lt;p&gt;需要获取&lt;strong&gt;所有监听源的新值+旧值&lt;/strong&gt;，或者监听源有&lt;strong&gt;基本类型和对象/数组混合的情况&lt;/strong&gt;（这时候用getter函数统一处理所有源的响应性问题），而且要&lt;strong&gt;懒执行&lt;/strong&gt;（初始化不触发，只在变化时触发）。&lt;/p&gt;
&lt;h4&gt;代码示例&lt;/h4&gt;
&lt;p&gt;还是刚才的用户查询，但这次父组件除了传&lt;code&gt;userId&lt;/code&gt;，还传了个&lt;code&gt;lang: String&lt;/code&gt;（切换语言的时候用户简介要变）、&lt;code&gt;userOptions: Object&lt;/code&gt;（比如要不要显示头像、联系方式的配置）。&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watch, ref, toRefs } from &amp;#39;vue&amp;#39;
// 假设toRefs在这里只是做个对比的演示
const props = defineProps([&amp;#39;userId&amp;#39;, &amp;#39;lang&amp;#39;, &amp;#39;userOptions&amp;#39;])
// 如果要解构，必须用toRefs/toRef，不然基本类型和对象的属性变化会丢失响应性
// const { userId, lang } = toRefs(props)
// const isShowAvatar = toRef(props.userOptions, &amp;#39;isShowAvatar&amp;#39;)
const userInfo = ref(null)
const translatedBio = ref(&amp;#39;&amp;#39;)
watch(
  // 数组里可以放：响应式对象的getter函数、ref变量、toRef/toRefs生成的ref变量
  [() =&amp;gt; props.userId, () =&amp;gt; props.lang, () =&amp;gt; props.userOptions.isShowAvatar],
  // 回调的第一个参数是所有源的新值组成的数组，顺序和上面的数组一致
  // 第二个参数是所有源的旧值组成的数组，顺序也一致
  ([newUserId, newLang, newShowAvatar], [oldUserId, oldLang, oldShowAvatar]) =&amp;gt; {
    // 可以先判断哪些源真的变了，避免不必要的逻辑执行
    if (newUserId !== oldUserId) {
      console.log(&amp;#39;userId变了，重新查整个用户&amp;#39;)
      // 查完整用户信息的接口，返回的结果里包含不同语言的bio数组
    }
    if (newLang !== oldLang) {
      console.log(&amp;#39;lang变了，切换用户简介语言&amp;#39;)
      // 如果userInfo已经有数据了，直接切对应语言的bio，不用重新查接口
      if (userInfo.value?.bioArr) {
        translatedBio.value = userInfo.value.bioArr.find(b =&amp;gt; b.lang === newLang)?.content || &amp;#39;&amp;#39;
      }
    }
    if (newShowAvatar !== oldShowAvatar) {
      console.log(&amp;#39;是否显示头像的配置变了&amp;#39;)
      // 更新本地的UI控制变量
    }
  },
  // 可选的配置项：immediate（立即执行）、deep（深层监听）
  { immediate: true } // 这里加immediate的话，初始化页面就会跑一次，把默认的lang对应的bio先取出来
)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;避坑指南&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;必须注意数组顺序&lt;/strong&gt;：回调里的新值旧值数组，顺序严格对应上面的监听源数组，千万不能写错,不然逻辑会全乱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对象/数组属性监听的响应性问题&lt;/strong&gt;：如果要监听props里对象的某个属性（比如上面的&lt;code&gt;userOptions.isShowAvatar&lt;/code&gt;），&lt;strong&gt;千万不能直接写&lt;code&gt;props.userOptions.isShowAvatar&lt;/code&gt;，必须用getter函数&lt;code&gt;() =&amp;gt; props.userOptions.isShowAvatar&lt;/code&gt;&lt;/strong&gt;——因为直接写的话，相当于把这个属性的初始值取出来当成了普通的基本类型变量，后续对象属性变化不会触发监听；同理，如果是监听整个对象/数组的变化但不需要深层监听，直接写&lt;code&gt;() =&amp;gt; props.userOptions&lt;/code&gt;；如果需要深层监听（比如整个对象的任何属性变了都触发），可以用&lt;code&gt;() =&amp;gt; props.userOptions&lt;/code&gt;再加上&lt;code&gt;{ deep: true }&lt;/code&gt;配置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有源变化才触发？不，只要有一个变就触发&lt;/strong&gt;：很多人以为数组形式的watch要所有监听源都变才会跑，其实不是——只要数组里的任意一个源变化，整个回调就会执行，所以最好像示例里那样，先对比每个源的新值旧值，只处理真的变了的部分,不然会有很多重复的接口请求或者逻辑处理。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;写法2：单独写多个watch，但把重复逻辑抽成公共函数&lt;/h3&gt;
&lt;p&gt;刚才的数组形式虽然可以一次性处理，但如果监听源之间的逻辑关联度很低，或者每个监听源需要不同的配置项（比如有的要深层监听，有的要立即执行，有的不用）,那单独写多个watch反而更清晰。&lt;/p&gt;
&lt;h4&gt;适用场景&lt;/h4&gt;
&lt;p&gt;监听源之间&lt;strong&gt;逻辑关联度极低&lt;/strong&gt;（比如一个是查数据，一个是重置弹窗状态，一个是触发动画），或者每个监听源需要&lt;strong&gt;不同的配置项&lt;/strong&gt;（immediate、deep的组合不一样）。&lt;/p&gt;
&lt;h4&gt;代码示例&lt;/h4&gt;
&lt;p&gt;还是刚才的三个props，但这次查数据、切换语言、重置头像UI的逻辑完全分开，而且查数据的要懒执行，切换语言和重置头像的要立即执行，切换语言还需要深层监听？（哦，切换语言是基本类型，不用深层监听，假设这次的userOptions里有个嵌套的对象比如&lt;code&gt;displaySettings.fontSize&lt;/code&gt;,要深层监听这个嵌套对象的变化）&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watch, ref } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userId&amp;#39;, &amp;#39;lang&amp;#39;, &amp;#39;userOptions&amp;#39;])
const userInfo = ref(null)
const translatedBio = ref(&amp;#39;&amp;#39;)
const fontSize = ref(&amp;#39;16px&amp;#39;)
// 抽公共查询函数
const fetchUserById = (id) =&amp;gt; {
  console.log(`查询用户${id}`)
  // 接口逻辑...
}
// 抽公共切换语言函数
const switchBioLang = (lang) =&amp;gt; {
  if (userInfo.value?.bioArr) {
    translatedBio.value = userInfo.value.bioArr.find(b =&amp;gt; b.lang === lang)?.content || &amp;#39;&amp;#39;
  }
}
// 抽公共更新字号函数
const updateFontSize = (newFontSize) =&amp;gt; {
  fontSize.value = newFontSize
}
// 单独监听userId：懒执行，不需要旧值新值对比太多（因为只有一个源）
watch(
  () =&amp;gt; props.userId,
  (newId) =&amp;gt; {
    fetchUserById(newId)
  }
)
// 单独监听lang：立即执行
watch(
  () =&amp;gt; props.lang,
  (newLang) =&amp;gt; {
    switchBioLang(newLang)
  },
  { immediate: true }
)
// 单独监听userOptions的displaySettings.fontSize：深层监听整个displaySettings？或者直接监听嵌套属性的getter？
// 直接监听嵌套属性的getter更精准，只有这个属性变了才触发，不用整个displaySettings的其他属性变也触发
watch(
  () =&amp;gt; props.userOptions.displaySettings.fontSize,
  (newFontSize) =&amp;gt; {
    updateFontSize(newFontSize)
  },
  { immediate: true }
)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;避坑指南&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;一定要抽公共逻辑&lt;/strong&gt;：不然单独写多个watch就会出现大量重复代码,维护起来很麻烦。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;根据需求选择精准的监听源&lt;/strong&gt;：比如监听嵌套属性的时候，直接写嵌套属性的getter比监听整个对象再加deep更高效，因为deep监听会遍历整个对象的所有属性（包括嵌套的），性能开销比较大,尤其是对象比较大的时候。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;写法3：用watchEffect自动追踪多个props&lt;/h3&gt;
&lt;p&gt;watchEffect的好处是不用显式列监听源，只要在回调里用到了，系统就会自动追踪，而且初始化就会跑一次，很适合“用什么数据就监听什么数据变化”的场景。&lt;/p&gt;
&lt;h4&gt;适用场景&lt;/h4&gt;
&lt;p&gt;监听源之间&lt;strong&gt;逻辑高度耦合&lt;/strong&gt;（比如所有数据变化都要统一生成一个查询参数然后发请求），而且&lt;strong&gt;不需要旧值&lt;/strong&gt;，&lt;strong&gt;可以接受立即执行&lt;/strong&gt;。&lt;/p&gt;
&lt;h4&gt;代码示例&lt;/h4&gt;
&lt;p&gt;还是刚才的三个props，但这次父组件传的&lt;code&gt;userOptions&lt;/code&gt;里除了&lt;code&gt;isShowAvatar&lt;/code&gt;，还有&lt;code&gt;pageSize&lt;/code&gt;、&lt;code&gt;currentPage&lt;/code&gt;，子组件要在userId、lang、pageSize、currentPage任何一个变的时候，都发一个带这四个参数的接口请求（比如查某个用户的多语言评论列表，分页的）。&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watchEffect, ref } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userId&amp;#39;, &amp;#39;lang&amp;#39;, &amp;#39;userOptions&amp;#39;])
const commentList = ref([])
const loading = ref(false)
// 抽公共生成查询参数函数
const buildQueryParams = () =&amp;gt; {
  return {
    userId: props.userId,
    lang: props.lang,
    pageSize: props.userOptions.pageSize,
    currentPage: props.userOptions.currentPage
  }
}
// 抽公共查询评论列表函数
const fetchCommentList = async (params) =&amp;gt; {
  loading.value = true
  try {
    console.log(&amp;#39;查询评论，参数是&amp;#39;, params)
    // 接口逻辑...
    // commentList.value = 接口返回的数据
  } catch (err) {
    console.error(&amp;#39;查询评论失败&amp;#39;, err)
  } finally {
    loading.value = false
  }
}
watchEffect(() =&amp;gt; {
  // 直接在这里调用buildQueryParams和fetchCommentList，所有用到的props（包括嵌套的）都会被自动追踪
  const params = buildQueryParams()
  fetchCommentList(params)
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;避坑指南&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;没有旧值&lt;/strong&gt;：如果需要对比变化前后的数据（比如从第一页变到第二页，要不要保留之前的评论列表做无限滚动？还是清空？从中文变到英文，要不要清空？），那watchEffect就不太合适了,必须用watch。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;立即执行是强制的吗？不是&lt;/strong&gt;：可以用&lt;code&gt;watchPostEffect&lt;/code&gt;（DOM更新后立即执行）或者&lt;code&gt;watchSyncEffect&lt;/code&gt;（同步立即执行），但都没有懒执行的选项——如果一定要懒执行,只能用watch。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;容易追踪到多余的依赖&lt;/strong&gt;：比如在watchEffect的回调里不小心用到了某个本地的ref变量，那这个ref变量变化的时候也会触发回调，所以最好把回调里的逻辑写得纯粹一点,只用到需要监听的响应式数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;嵌套对象/数组的追踪问题&lt;/strong&gt;：和单个源的watch一样，直接在watchEffect里用props的对象/数组属性，系统会自动追踪到吗？比如上面的&lt;code&gt;props.userOptions.pageSize&lt;/code&gt;——是的，只要props的userOptions对象是响应式的（defineProps生成的默认就是响应式的），直接用嵌套属性也会被自动追踪，但如果是解构赋值后不用toRefs/toRef,就不会追踪到了。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;写法4：用computed先把多个props合并成一个计算属性，再watch这个计算属性&lt;/h3&gt;
&lt;p&gt;这种写法稍微有点绕，但有时候很有用——比如要监听多个props的“组合变化”，比如只有当userId和lang同时变的时候才触发？不过其实组合变化用数组形式的watch加条件判断也能实现，但如果合并后的逻辑比较复杂，比如要把多个props转换成一个特定格式的对象，那用computed先合并，再watch计算属性,代码会更清晰。&lt;/p&gt;
&lt;h4&gt;适用场景&lt;/h4&gt;
&lt;p&gt;需要把多个props&lt;strong&gt;先转换成一个特定格式的响应式数据&lt;/strong&gt;，再监听这个数据的变化，或者需要监听多个props的“特殊组合变化”（不过还是那句话，条件判断也能实现，但这种写法可读性可能更高，尤其是复杂转换的时候）。&lt;/p&gt;
&lt;h4&gt;代码示例&lt;/h4&gt;
&lt;p&gt;假设父组件传了&lt;code&gt;userName: String&lt;/code&gt;、&lt;code&gt;userAge: Number&lt;/code&gt;、&lt;code&gt;userCity: String&lt;/code&gt;，子组件要在这三个props的任意一个变的时候，生成一个“用户卡片摘要”的响应式文本，然后根据这个文本的长度调整字体大小（比如超过20个字用14px，没超过用16px）。&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watch, computed, ref, defineProps } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userName&amp;#39;, &amp;#39;userAge&amp;#39;, &amp;#39;userCity&amp;#39;])
const cardSummaryFontSize = ref(&amp;#39;16px&amp;#39;)
// 先把多个props合并成一个计算属性
const userCardSummary = computed(() =&amp;gt; {
  return `我是${props.userName}，今年${props.userAge}岁，来自${props.userCity}`
})
// 再watch这个计算属性
watch(
  userCardSummary, // 注意：computed生成的是响应式的ref对象（如果返回的是基本类型）或者响应式的proxy对象（如果返回的是对象/数组），所以直接写变量名就行，不用getter函数
  (newSummary) =&amp;gt; {
    console.log(`用户卡片摘要变成了：${newSummary}`)
    // 根据摘要长度调整字体大小
    cardSummaryFontSize.value = newSummary.length &amp;gt; 20 ? &amp;#39;14px&amp;#39; : &amp;#39;16px&amp;#39;
  },
  { immediate: true } // 初始化页面也要根据默认的摘要长度调整字体大小
)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;避坑指南&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;computed必须返回响应式依赖的结果&lt;/strong&gt;：就是说computed的回调里必须用到至少一个响应式数据（这里是三个props），不然computed不会是响应式的,watch它也没用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;computed的缓存特性&lt;/strong&gt;：computed有缓存，只有当它的依赖变化的时候才会重新计算，所以这种写法的性能其实和直接watch多个props差不多，但代码更清晰,尤其是转换逻辑复杂的时候。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;怎么选择这4种写法？给你一张决策表&lt;/h2&gt;
&lt;p&gt;可能说了这么多，你还是有点纠结：到底什么时候用哪种？没关系，给你整理了一张简单的决策表,按照这个来选就不会错：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr class=&quot;firstRow&quot;&gt;
&lt;th&gt;你的需求场景&lt;/th&gt;
&lt;th&gt;推荐写法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;需要所有监听源的新值+旧值，懒执行，逻辑关联度一般&lt;/td&gt;
&lt;td&gt;写法1：数组形式的watch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;逻辑关联度极低，每个监听源需要不同的配置&lt;/td&gt;
&lt;td&gt;写法2：单独多个watch+抽公共逻辑&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;逻辑高度耦合，不需要旧值，可以接受立即执行&lt;/td&gt;
&lt;td&gt;写法3：watchEffect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要先把多个props转换成特定格式的响应式数据，再监听变化&lt;/td&gt;
&lt;td&gt;写法4：computed合并后再watch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;再补充几个关于watch多props的高级技巧&lt;/h2&gt;
&lt;h3&gt;技巧1：如何让数组形式的watch只在特定几个源同时变化的时候触发？&lt;/h3&gt;
&lt;p&gt;刚才说了，数组形式的watch只要有一个源变就触发，但有时候我们需要“同时变化”的情况，比如父组件是通过一个按钮同时修改userId和lang的，子组件只需要在这两个同时变的时候查一次接口，而不是先查userId变的，再查lang变的,这时候可以用条件判断：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;watch(
  [() =&amp;gt; props.userId, () =&amp;gt; props.lang],
  ([newUserId, newLang], [oldUserId, oldLang]) =&amp;gt; {
    // 只有当两个都变了的时候才触发
    if (newUserId !== oldUserId &amp;amp;&amp;amp; newLang !== oldLang) {
      console.log(&amp;#39;userId和lang同时变了，只查一次接口&amp;#39;)
      // 查接口...
    }
  }
)&lt;/pre&gt;
&lt;p&gt;不过这里有个问题：Vue的响应式更新是批量的，所以如果父组件是同步修改userId和lang的，子组件的watch只会触发一次；但如果父组件是异步修改的（比如先改userId，等100ms再改lang），那子组件的watch会触发两次,这时候条件判断就有用了。&lt;/p&gt;
&lt;h3&gt;技巧2：如何取消watch多props的监听？&lt;/h3&gt;
&lt;p&gt;有时候我们需要在组件销毁或者某个条件满足的时候取消监听，避免内存泄漏或者不必要的逻辑执行，组合式API里的watch和watchEffect都会返回一个“停止函数”,调用这个函数就能取消监听：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { watch, onUnmounted } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;userId&amp;#39;])
// 保存停止函数
const stopWatch = watch(
  () =&amp;gt; props.userId,
  (newId) =&amp;gt; {
    console.log(&amp;#39;监听中...&amp;#39;)
  }
)
// 比如当用户点击某个按钮的时候取消监听
const handleCancel = () =&amp;gt; {
  stopWatch()
  console.log(&amp;#39;已取消监听&amp;#39;)
}
// 或者在组件销毁的时候自动取消监听（其实Vue3组合式API里的watch/watchEffect默认会在组件销毁的时候自动取消，但如果是在组件之外或者动态创建的监听，最好手动取消）
onUnmounted(() =&amp;gt; {
  stopWatch()
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h3&gt;技巧3：深层监听多个props的对象/数组，怎么优化性能？&lt;/h3&gt;
&lt;p&gt;刚才说了，deep监听的性能开销比较大，尤其是对象/数组比较大的时候,那怎么优化？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;尽量用精准的嵌套属性getter&lt;/strong&gt;：比如只监听&lt;code&gt;props.userOptions.isShowAvatar&lt;/code&gt;，而不是整个&lt;code&gt;props.userOptions&lt;/code&gt;再加deep。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;如果必须监听整个对象/数组的变化，但不需要知道具体哪个属性变了，可以用&lt;code&gt;JSON.parse(JSON.stringify())&lt;/code&gt;？不，这个太消耗性能了，尤其是大对象/数组的时候&lt;/strong&gt;——更好的方法是用&lt;code&gt;watch&lt;/code&gt;的回调里的新值旧值对比？不对，对象/数组的新值旧值是同一个引用的话（比如直接修改对象的属性，而不是替换整个对象），对比&lt;code&gt;newVal === oldVal&lt;/code&gt;是没用的——这时候还是只能用精准的嵌套属性getter，或者用VueUse库里的&lt;code&gt;watchDebounced&lt;/code&gt;（防抖监听，避免短时间内多次触发）、&lt;code&gt;watchThrottled&lt;/code&gt;（节流监听，一定时间内只触发一次）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Vue3监听多个props的核心4种写法都讲完了，还补充了几个高级技巧，其实没有最好的写法，只有最适合你当前场景的写法——按照决策表来选，再结合避坑指南和高级技巧，就能写出高效、清晰、易维护的代码了。&lt;/p&gt;
&lt;p&gt;最后再提醒一句：不管用哪种写法，都要注意响应性问题（尤其是解构props的时候，一定要用toRefs/toRef），还有要避免不必要的逻辑执行（比如对比新值旧值、用精准的监听源、防抖节流）。&lt;/p&gt;</description><pubDate>Tue, 05 May 2026 08:01:34 +0800</pubDate></item><item><title>Vue3怎么同时watch多个响应式属性？不同场景的写法和避坑指南全整理</title><link>https://codeqd.com/post/20260521805.html</link><description>&lt;p&gt;刚开始用Vue3的组合式API写项目时,我经常碰到需要监听多个变量联动变化的需求：比如电商网站里，用户切换商品SKU的颜色和尺寸时，要同时触发价格更新、库存检查和SKU图切换；或者后台管理系统中，筛选条件的时间范围、状态下拉、关键词输入任意一个变了，都要重新请求列表接口，这时候如果单独写好几个watch函数，不仅代码冗余，还可能因为触发顺序的问题出bug——比如先更新了状态，还没等到时间范围的新值就去调接口了，今天就把我摸索出来的所有实用写法、适用场景和踩过的坑，一次性说清楚。&lt;/p&gt;
&lt;h2&gt;基础写法：用数组包多个源&lt;/h2&gt;
&lt;p&gt;最直接的同时监听多个属性的方法,就是把这些源放在一个数组里传给watch的第一个参数，不管是ref、reactive的属性、计算属性还是getter函数返回的响应式值，都可以混着放进去。&lt;/p&gt;
&lt;p&gt;举个最基础的电商SKU例子：假设页面有colorRef（商品颜色）、sizeRef（商品尺寸）两个ref，还有对应的reactive对象skuObj，属性是color和size，同时还有个根据这俩算出来的computed：isStockAvailableRef（计算是否有库存），现在想把这四个混着监听，每次变了都打印下所有值。&lt;/p&gt;
&lt;p&gt;那代码可以这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, reactive, computed, watch } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    // 定义几个不同类型的源
    const colorRef = ref(&amp;#39;红色&amp;#39;);
    const sizeRef = ref(&amp;#39;M&amp;#39;);
    const skuObj = reactive({ color: &amp;#39;红色&amp;#39;, size: &amp;#39;M&amp;#39; });
    const isStockAvailableRef = computed(() =&amp;gt; 
      colorRef.value === &amp;#39;红色&amp;#39; &amp;amp;&amp;amp; sizeRef.value === &amp;#39;L&amp;#39;
    );
    // 数组混放监听
    watch(
      [colorRef, sizeRef, () =&amp;gt; skuObj.color, isStockAvailableRef],
      ([newColor, newSize, newSkuColor, newStockVal], [oldColor, oldSize, oldSkuColor, oldStockVal]) =&amp;gt; {
        console.log(&amp;#39;新值数组：&amp;#39;, [newColor, newSize, newSkuColor, newStockVal]);
        console.log(&amp;#39;旧值数组：&amp;#39;, [oldColor, oldSize, oldSkuColor, oldStockVal]);
        // 这里放你的业务逻辑，比如调价格接口
      }
    );
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};&lt;/pre&gt;
&lt;p&gt;这里有个小细节要注意：如果数组里直接放reactive对象本身，比如&lt;code&gt;[skuObj]&lt;/code&gt;，那默认是&lt;strong&gt;浅监听&lt;/strong&gt;——只有当整个对象被替换（比如&lt;code&gt;skuObj = reactive(...)&lt;/code&gt;）才会触发，对象内部的属性变化是不会响应的，如果想监听reactive的整个对象变化，要么给watch加第三个参数&lt;code&gt;{ deep: true }&lt;/code&gt;，要么像例子里那样，把内部属性拆成getter函数&lt;code&gt;() =&amp;gt; skuObj.color&lt;/code&gt;一个个单独列出来。&lt;/p&gt;
&lt;h3&gt;基础写法的适用场景&lt;/h3&gt;
&lt;p&gt;这种数组写法最常用,适合所有需要“任意一个源变了就执行回调”的场景，而且回调里能拿到所有源的新值和旧值，逻辑可以写得很灵活，比如刚才提到的SKU场景，拿到新旧尺寸后，还可以判断是不是从缺货变到有货，要不要加个小弹窗提示用户。&lt;/p&gt;
&lt;h2&gt;进阶写法：用watchEffect结合解构，自动追踪依赖&lt;/h2&gt;
&lt;p&gt;如果你觉得写数组太麻烦,或者不确定到底哪些源会用到，可以试试watchEffect，它的核心逻辑是“第一次执行回调时自动收集所有用到的响应式依赖，之后只要有一个依赖变了，就重新执行回调”。&lt;/p&gt;
&lt;p&gt;那刚才的SKU例子用watchEffect怎么写呢？可以先解构出需要的值，或者直接在回调里用：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, reactive, computed, watchEffect } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    const colorRef = ref(&amp;#39;红色&amp;#39;);
    const sizeRef = ref(&amp;#39;M&amp;#39;);
    const skuObj = reactive({ color: &amp;#39;红色&amp;#39;, size: &amp;#39;M&amp;#39; });
    const isStockAvailableRef = computed(() =&amp;gt; 
      colorRef.value === &amp;#39;红色&amp;#39; &amp;amp;&amp;amp; sizeRef.value === &amp;#39;L&amp;#39;
    );
    // 直接用值，自动追踪
    watchEffect(() =&amp;gt; {
      console.log(&amp;#39;当前用到的源：&amp;#39;);
      console.log(&amp;#39;colorRef.value&amp;#39;, colorRef.value);
      console.log(&amp;#39;sizeRef.value&amp;#39;, sizeRef.value);
      console.log(&amp;#39;skuObj.color&amp;#39;, skuObj.color);
      console.log(&amp;#39;isStockAvailableRef.value&amp;#39;, isStockAvailableRef.value);
      // 业务逻辑
    });
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};&lt;/pre&gt;
&lt;p&gt;这里有没有发现和基础写法的区别？第一，watchEffect不需要传依赖数组；第二，第一次渲染组件时它&lt;strong&gt;会自动执行一次&lt;/strong&gt;（watch默认只有源变了才执行，除非加第三个参数&lt;code&gt;{ immediate: true }&lt;/code&gt;）；第三，它的回调函数里&lt;strong&gt;拿不到旧值&lt;/strong&gt;——只能拿到当前的新值；第四，如果想停止监听，需要提前存下来它的返回值，然后调用：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const stopWatchEffect = watchEffect(() =&amp;gt; { /* ... */ });
// 比如组件卸载时停止，不过组合式API里setup返回的stop函数会自动在onUnmounted时执行，不用手动写
onUnmounted(() =&amp;gt; stopWatchEffect());&lt;/pre&gt;
&lt;h3&gt;进阶写法的适用场景和注意事项&lt;/h3&gt;
&lt;p&gt;watchEffect适合业务逻辑里用到的依赖源比较多、动态性强的场景——比如后台管理系统的复杂筛选，筛选条件可能根据权限动态添加或者隐藏，用watchEffect就不用每次修改筛选框的配置时，还要去改依赖数组。&lt;/p&gt;
&lt;p&gt;但也要注意它的几个“坑”：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不能拿旧值&lt;/strong&gt;：如果你的业务逻辑需要对比新旧值，比如判断关键词输入的变化幅度，或者统计筛选条件切换的次数，那watchEffect就不行了，得用watch。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自动收集依赖的范围&lt;/strong&gt;：只有在回调函数&lt;strong&gt;同步执行的代码&lt;/strong&gt;里用到的响应式值才会被追踪，异步代码里的不算，比如下面这个代码：&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;watchEffect(() =&amp;gt; {
  // 这里的colorRef会被追踪，变了就会重新调接口
  const color = colorRef.value;
  setTimeout(() =&amp;gt; {
    // 这里的sizeRef不会被追踪，变了不会触发watchEffect
    console.log(sizeRef.value);
  }, 1000);
});&lt;/pre&gt;
&lt;p&gt;如果异步代码里也需要追踪某个源,那得把那个源移到同步代码里先“碰一下”，比如&lt;code&gt;const size = sizeRef.value;&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第一次默认执行&lt;/strong&gt;：有时候我们第一次渲染时不想调接口，比如关键词输入为空时，调接口会返回所有数据，但我们想等用户输入至少3个字符再调，这时候要么在回调开头加个判断逻辑，要么用watch加&lt;code&gt;immediate: true&lt;/code&gt;然后手动控制是否执行业务。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;特殊写法：用getter函数返回一个包含多个属性的对象&lt;/h2&gt;
&lt;p&gt;这种写法其实是基础数组写法的变形——不是把多个源放在数组里，而是放在一个对象里，然后用getter函数返回这个对象，它的好处是回调里拿到的新值和旧值也是对象，属性名对应着你定义的源，不用记数组的索引顺序，代码可读性更高。&lt;/p&gt;
&lt;p&gt;还是刚才的SKU例子,改成对象写法：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, reactive, computed, watch } from &amp;#39;vue&amp;#39;;
export default {
  setup() {
    const colorRef = ref(&amp;#39;红色&amp;#39;);
    const sizeRef = ref(&amp;#39;M&amp;#39;);
    const skuObj = reactive({ color: &amp;#39;红色&amp;#39;, size: &amp;#39;M&amp;#39; });
    const isStockAvailableRef = computed(() =&amp;gt; 
      colorRef.value === &amp;#39;红色&amp;#39; &amp;amp;&amp;amp; sizeRef.value === &amp;#39;L&amp;#39;
    );
    // 用getter返回包含多个属性的对象
    watch(
      () =&amp;gt; ({
        color: colorRef.value,
        size: sizeRef.value,
        skuColor: skuObj.color,
        isStock: isStockAvailableRef.value
      }),
      (newVals, oldVals) =&amp;gt; {
        console.log(&amp;#39;新值对象：&amp;#39;, newVals);
        console.log(&amp;#39;旧值对象：&amp;#39;, newVals.color); // 直接用属性名，不用索引0
        console.log(&amp;#39;旧值对象的旧尺寸：&amp;#39;, oldVals.size);
        // 业务逻辑
      }
    );
    return { colorRef, sizeRef, skuObj, isStockAvailableRef };
  }
};&lt;/pre&gt;
&lt;p&gt;这里又有个和基础数组写法类似的细节：getter返回的对象本身是普通对象，所以默认也是&lt;strong&gt;浅监听&lt;/strong&gt;——只有当对象的属性值变了（比如colorRef.value从红色变成蓝色）才会触发，如果属性值是对象或者数组，内部变化默认不触发，比如把skuObj整个放进去：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 这样默认不会监听skuObj内部的color和size变化
() =&amp;gt; ({
  color: colorRef.value,
  sku: skuObj
})&lt;/pre&gt;
&lt;p&gt;如果想监听skuObj内部的变化,要么加&lt;code&gt;{ deep: true }&lt;/code&gt;，要么把skuObj拆成内部属性。&lt;/p&gt;
&lt;h3&gt;特殊写法的适用场景&lt;/h3&gt;
&lt;p&gt;这种对象getter写法最适合依赖源比较多（超过3个）的场景——数组索引记起来太麻烦，用对象属性名一眼就能看明白哪个新值对应哪个旧值，比如刚才提到的复杂后台筛选，筛选条件有10个，用对象写法的话，回调里的逻辑清晰得多。&lt;/p&gt;
&lt;h2&gt;避坑指南：这5个点90%的新手都会踩&lt;/h2&gt;
&lt;p&gt;刚才写每种写法的时候,其实已经提到了一些小细节，现在把它们整理成5个最容易踩的“大坑”，大家一定要注意：&lt;/p&gt;
&lt;h3&gt;避坑1：reactive对象直接传给watch的第一个参数&lt;/h3&gt;
&lt;p&gt;很多新手会这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const skuObj = reactive({ color: &amp;#39;红色&amp;#39;, size: &amp;#39;M&amp;#39; });
watch(skuObj, (newVal, oldVal) =&amp;gt; { /* ... */ });&lt;/pre&gt;
&lt;p&gt;以为这样就能监听color和size的变化,但实际上，watch默认是按值比较监听源的——对于普通对象/数组，是浅比较；对于ref，是比较value；对于getter函数返回的值，也是浅比较，而reactive对象本身是一个Proxy代理，它的引用地址是不会变的，所以直接传reactive对象的话，除非你把整个对象替换掉（比如&lt;code&gt;skuObj = reactive(...)&lt;/code&gt;，但reactive定义的对象不能直接替换，否则会失去响应式），否则永远不会触发watch。&lt;/p&gt;
&lt;p&gt;正确的做法有两个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;拆成getter函数：&lt;code&gt;() =&amp;gt; skuObj.color&lt;/code&gt;，&lt;code&gt;() =&amp;gt; skuObj.size&lt;/code&gt;，放在数组里。&lt;/li&gt;
&lt;li&gt;加&lt;code&gt;{ deep: true }&lt;/code&gt;：但要注意，如果reactive对象很大很深，加deep会有性能问题，因为每次内部属性变化，Vue都要递归遍历整个对象比较新旧值。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;避坑2：ref定义的数组/对象直接修改内部元素，watch没加deep&lt;/h3&gt;
&lt;p&gt;和避坑1类似,ref定义的数组/对象，比如&lt;code&gt;const listRef = ref([1,2,3])&lt;/code&gt;，如果直接修改内部元素：&lt;code&gt;listRef.value.push(4)&lt;/code&gt;，因为listRef.value的引用地址没变，所以默认的watch也不会触发。&lt;/p&gt;
&lt;p&gt;正确的做法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;加&lt;code&gt;{ deep: true }&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;或者重新赋值整个数组/对象：&lt;code&gt;listRef.value = [...listRef.value, 4]&lt;/code&gt;（数组），&lt;code&gt;listRef.value = { ...listRef.value, name: &#039;新名字&#039; }&lt;/code&gt;（对象）——这种方法不用加deep，性能更好，但如果数组/对象很大，重新赋值的内存消耗会比直接修改大一点，大家可以根据实际情况选择。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;避坑3：watchEffect的异步依赖问题&lt;/h3&gt;
&lt;p&gt;刚才已经举过例子了,再强调一遍：只有在watchEffect回调函数&lt;strong&gt;同步执行的代码&lt;/strong&gt;里用到的响应式值才会被追踪，异步代码里的不算，如果异步代码里需要用到某个源，一定要在同步代码里先访问它一下，“碰一碰”让Vue知道这是个依赖。&lt;/p&gt;
&lt;h3&gt;避坑4：忘记加immediate，第一次渲染时业务逻辑不执行&lt;/h3&gt;
&lt;p&gt;watch默认只有当监听源&lt;strong&gt;发生变化&lt;/strong&gt;时才会执行回调，第一次渲染组件时不会执行，但有些场景第一次渲染时就需要执行，比如SKU页面，刚打开就要显示默认颜色和尺寸的价格、库存；或者后台管理系统，刚打开就要显示默认筛选条件的列表。&lt;/p&gt;
&lt;p&gt;这时候只要给watch加第三个参数&lt;code&gt;{ immediate: true }&lt;/code&gt;就行：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;watch(
  [colorRef, sizeRef],
  (newVals, oldVals) =&amp;gt; {
    // 第一次执行时，oldVals是undefined或者null？注意这里哦！
    if (oldVals) {
      // 有旧值，说明是用户切换的
    }
    // 业务逻辑
  },
  { immediate: true }
);&lt;/pre&gt;
&lt;p&gt;这里还有个小细节：加了immediate后，第一次执行回调时，旧值数组（或者旧值对象）会是&lt;strong&gt;全undefined&lt;/strong&gt;或者&lt;strong&gt;全null&lt;/strong&gt;？不对，Vue3.3+之前是全undefined，Vue3.3+之后改成了全null？或者我记错了？其实不管是啥，加个判断逻辑就行，比如&lt;code&gt;if (oldVals.every(val =&amp;gt; val !== undefined))&lt;/code&gt;（数组写法），或者&lt;code&gt;if (oldVals.color !== undefined)&lt;/code&gt;（对象写法），就能区分是第一次执行还是用户切换触发的。&lt;/p&gt;
&lt;h3&gt;避坑5：频繁触发导致的性能问题（防抖节流）&lt;/h3&gt;
&lt;p&gt;比如关键词输入的场景,用户每敲一个字就触发watch，然后就去调接口，这样会给服务器造成很大的压力，也会让页面出现卡顿的加载动画，这时候就需要给watch加&lt;strong&gt;防抖&lt;/strong&gt;——等待用户停止输入一段时间（比如500毫秒）后，再去调接口。&lt;/p&gt;
&lt;p&gt;Vue3的组合式API里没有内置防抖节流函数,但我们可以自己写一个简单的，或者用第三方库比如lodash的debounce和throttle。&lt;/p&gt;
&lt;p&gt;用lodash debounce的例子：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;;
import debounce from &amp;#39;lodash/debounce&amp;#39;;
export default {
  setup() {
    const keywordRef = ref(&amp;#39;&amp;#39;);
    const statusRef = ref(&amp;#39;all&amp;#39;);
    // 定义防抖后的业务函数
    const fetchList = debounce((keyword, status) =&amp;gt; {
      console.log(&amp;#39;调接口，参数：&amp;#39;, keyword, status);
      // 这里写真实的接口请求代码
    }, 500);
    // 监听关键词和状态，调用防抖函数
    watch(
      [keywordRef, statusRef],
      ([newKeyword, newStatus]) =&amp;gt; {
        fetchList(newKeyword, newStatus);
      }
    );
    // 组件卸载时要取消防抖，防止内存泄漏
    onUnmounted(() =&amp;gt; {
      fetchList.cancel();
    });
    return { keywordRef, statusRef };
  }
};&lt;/pre&gt;
&lt;p&gt;这里一定要注意：组件卸载时要调用防抖函数的cancel方法，否则防抖函数里的定时器还在运行，可能会造成内存泄漏，如果是自己写的防抖函数，也要记得在onUnmounted里清除定时器。&lt;/p&gt;
&lt;h2&gt;不同场景选哪种写法？&lt;/h2&gt;
&lt;p&gt;最后给大家整理一张“选型表”，方便大家快速找到适合自己的写法：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr class=&quot;firstRow&quot;&gt;
&lt;th&gt;场景描述&lt;/th&gt;
&lt;th&gt;推荐写法&lt;/th&gt;
&lt;th&gt;核心优势&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;需要对比新旧值，依赖源≤3个&lt;/td&gt;
&lt;td&gt;基础数组写法&lt;/td&gt;
&lt;td&gt;代码简单，逻辑清晰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;需要对比新旧值，依赖源&amp;gt;3个&lt;/td&gt;
&lt;td&gt;对象getter写法&lt;/td&gt;
&lt;td&gt;不用记数组索引，可读性高&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不需要对比新旧值，依赖源多且动态&lt;/td&gt;
&lt;td&gt;watchEffect写法&lt;/td&gt;
&lt;td&gt;自动追踪依赖，不用手动维护数组&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不需要对比新旧值，依赖源固定且第一次要执行&lt;/td&gt;
&lt;td&gt;watch数组+immediate写法 或者 watchEffect+开头判断&lt;/td&gt;
&lt;td&gt;两种都可以，看个人习惯&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;频繁触发的场景（比如关键词输入、窗口 resize）&lt;/td&gt;
&lt;td&gt;任意watch/watchEffect写法+防抖节流&lt;/td&gt;
&lt;td&gt;提高性能，减少服务器压力&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;好了,以上就是我关于Vue3同时watch多个响应式属性的所有整理，都是我在项目里踩过坑、试过多次才总结出来的实用内容，如果大家还有其他问题，或者有更好的写法，欢迎在评论区留言交流！&lt;/p&gt;</description><pubDate>Mon, 04 May 2026 20:01:59 +0800</pubDate></item><item><title>Vue3开发必问，watch和v-model到底怎么选？新手踩坑全解析+进阶用法干货</title><link>https://codeqd.com/post/20260521804.html</link><description>&lt;p&gt;我最近半年帮几个初创公司搭Vue3的后台和C端小程序，面试和群里聊技术的时候，这个问题出现频率太高了——要么是刚从Vue2转Vue3的开发者搞不清watchEffect/watch/watchPostFlush的区别顺便连v-model的新语法糖都没搞透，要么是完全零基础入门的，不知道什么时候该监听数据什么时候该用双向绑定，甚至有人把watch放在v-model的回调里用，结果死循环调试了一下午，今天咱们就彻底掰开揉碎讲清楚这俩兄弟（或者说表亲？）的事儿，从基础定义、核心区别、新手常见的7个坑、到后台表单、C端搜索栏这种真实场景的搭配，还有我自己最近琢磨出来的带防抖节流的双向绑定小技巧,全给你整明白。&lt;/p&gt;
&lt;h3&gt;先别慌选！把两者的本质捏准了再说&lt;/h3&gt;
&lt;p&gt;很多人学技术总喜欢先背语法再硬套，这很容易踩死，不管是Vue3还是其他前端框架，搞懂“工具是为了解决什么问题而存在的”才是关键，咱们先分别摸透watch和v-model的底层逻辑,区别自然就出来了。&lt;/p&gt;
&lt;h4&gt;v-model的本质：双向绑定的语法糖合集&lt;/h4&gt;
&lt;p&gt;很多Vue2转过来的人会觉得Vue3的v-model变复杂了，一会儿冒出来个:model-value一会儿冒出来个@update:model-value，其实换汤不换药，本质还是“父组件传一个prop，子组件抛一个带新值的事件，父组件接收事件把新值赋给prop对应的变量”——对，就是单向数据流的包装,为了让你少写两行代码而已。&lt;/p&gt;
&lt;p&gt;不过Vue3确实给v-model加了不少有用的升级,不是简单换名字：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不再局限于input/textarea/select这些原生表单组件&lt;/strong&gt;：自定义组件随便用，不管是单选多选框封装，还是表格行内编辑，甚至是C端的点赞收藏组件（对，你没看错，点赞本质也是个0和1的双向绑定）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;支持多个v-model绑定&lt;/strong&gt;：这个太香了！比如后台的日期范围选择器，Vue2你可能得传个startTime和endTime，然后子组件抛update:start和update:end，父组件要写两个监听或者把变量合并，Vue3直接v-model:start=&quot;dateRange.start&quot; v-model:end=&quot;dateRange.end&quot;,一行代码搞定双向。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可以自定义修饰符&lt;/strong&gt;：原生有.trim/.lazy/.number，现在自定义组件也能自己造，比如后台的金额输入框，你可以造个.money的修饰符，自动把输入的数字转成两位小数，或者自动加千分位,非常方便。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;再给你提个醒——v-model虽然是双向的，但底层还是单向数据流！子组件绝对不能直接修改prop，否则Vue3开发环境会直接给你报错，生产环境虽然可能不报错但会有各种不可预知的问题，这个是新手踩坑最多的点之一,后面单独说。&lt;/p&gt;
&lt;h4&gt;watch的本质：数据变化的“监听器+触发器”&lt;/h4&gt;
&lt;p&gt;watch的核心逻辑只有一个：“当某个特定的数据发生变化时，执行一段你定义好的代码”，这里的“特定数据”可以是单个ref/reactive，也可以是reactive里的某个属性，甚至可以是一个返回值的函数（比如你想监听某个计算属性的变化，或者想同时监听多个数据中只要有一个变就触发）；这里的“变化”也有讲究，默认是浅监听（reactive里的属性如果是对象/数组，只换地址才触发，改属性里的内容不触发），你可以传deep: true改成深监听，也可以传immediate: true让它初始化的时候就执行一次。&lt;/p&gt;
&lt;p&gt;Vue3比Vue2多了个watchEffect，很多人搞不清watch和watchEffect的区别：watch是“明确告诉Vue我要监听谁，等它变了再执行”，watchEffect是“自动追踪代码里用到的所有响应式数据，不管是谁变了都执行”，举个简单的例子，你有个搜索栏，里面有输入的关键词、筛选的分类、选择的页码，三个都要变了才发请求——watch需要把这三个都放在数组里当监听源，watchEffect直接在回调里写请求的代码，自然就追踪到这三个变量了，不过watchEffect有时候会有“多余触发”的问题，比如你不小心在回调里用了一个无关的响应式变量，它也会跟着跑，这时候watch的精准性就体现出来了，另外Vue3还有watchPostFlush和watchSyncFlush，前者是DOM更新后执行，后者是同步执行（一般别用，会阻塞渲染）,这些进阶用法后面也会讲。&lt;/p&gt;
&lt;h3&gt;新手踩过的7个“致命”坑，你中了几个？&lt;/h3&gt;
&lt;p&gt;我整理了最近半年遇到的新手问题，挑出了最典型、最容易浪费时间的7个,咱们一个一个排雷。&lt;/p&gt;
&lt;h4&gt;踩坑1：子组件直接修改v-model对应的prop&lt;/h4&gt;
&lt;p&gt;这个绝对是第一名！昨天还在群里看到一个应届生问：“为什么我改了子组件里的modelValue，父组件没反应，开发环境还报错？”原因刚才说了，Vue是单向数据流，子组件只能读prop，不能直接改，必须抛@update:modelValue事件，举个错误例子和正确例子对比一下：
错误代码（自定义输入框组件）：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 错误！直接修改prop --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;modelValue&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
const props = defineProps([&amp;#39;modelValue&amp;#39;])
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;正确代码：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 正确！抛事件 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input 
    type=&amp;quot;text&amp;quot; 
    :value=&amp;quot;modelValue&amp;quot; 
    @input=&amp;quot;$emit(&amp;#39;update:modelValue&amp;#39;, $event.target.value)&amp;quot; 
  /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
const props = defineProps([&amp;#39;modelValue&amp;#39;])
const emit = defineEmits([&amp;#39;update:modelValue&amp;#39;])
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;或者你可以用computed的getter和setter来简化，这样更像用v-model：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 用computed简化自定义v-model --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;inputValue&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { computed } from &amp;#39;vue&amp;#39;
const props = defineProps([&amp;#39;modelValue&amp;#39;])
const emit = defineEmits([&amp;#39;update:modelValue&amp;#39;])
const inputValue = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    emit(&amp;#39;update:modelValue&amp;#39;, val)
  }
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;踩坑2：把v-model绑定到reactive的根对象上&lt;/h4&gt;
&lt;p&gt;比如你写了个自定义登录框组件,父组件传了个reactive的userInfo对象：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 父组件错误绑定 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;LoginForm v-model=&amp;quot;userInfo&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { reactive } from &amp;#39;vue&amp;#39;
import LoginForm from &amp;#39;./LoginForm.vue&amp;#39;
const userInfo = reactive({ username: &amp;#39;&amp;#39;, password: &amp;#39;&amp;#39; })
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;子组件里这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 子组件错误接收 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;userInfo.username&amp;quot; /&amp;gt;
  &amp;lt;input type=&amp;quot;password&amp;quot; v-model=&amp;quot;userInfo.password&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
const props = defineProps([&amp;#39;modelValue&amp;#39;])
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;乍一看好像没问题？开发环境也没报错？因为你直接修改了prop里的对象属性，Vue3开发环境对这种情况是“半警告半放行”的——它只禁止直接替换prop的对象/数组地址，不禁止修改属性内容，但这依然违反单向数据流！万一以后你父组件把userInfo从reactive改成ref对象（比如为了reset的时候更方便，直接userInfo.value = { ... }），子组件就炸了，正确的做法还是刚才说的，要么传单个属性，要么用多个v-model,要么子组件里computed转换。&lt;/p&gt;
&lt;h4&gt;踩坑3：watch监听ref的时候忘了加.value&lt;/h4&gt;
&lt;p&gt;这个坑主要出现在刚从Vue2转Vue3的人身上，Vue2的data里的变量直接用，Vue3的ref要加.value，但要注意！在模板里不用加，在setup函数里的普通JavaScript代码里要加,在watch的监听源里分情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果是单个ref，监听源可以直接写ref变量，Vue3会自动解包，不用加.value；&lt;/li&gt;
&lt;li&gt;如果是ref里的属性（比如你有个ref的userInfo对象，想监听userInfo.value.username），或者是返回ref属性的函数，那必须加.value或者把整个逻辑放在函数里；&lt;/li&gt;
&lt;li&gt;如果是数组里的ref，比如监听多个ref，直接放数组里就行，不用加.value。
举个例子：&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const count = ref(0)
const userInfo = ref({ username: &amp;#39;张三&amp;#39;, age: 18 })&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;// 单个ref，正确，自动解包
watch(count, (newVal, oldVal) =&amp;gt; {
console.log(&#039;count变了&#039;, newVal, oldVal)
})&lt;/p&gt;
&lt;p&gt;// 监听ref里的属性，第一种写法：函数返回属性值，正确
watch(() =&amp;gt; userInfo.value.username, (newVal, oldVal) =&amp;gt; {
console.log(&#039;username变了&#039;, newVal, oldVal)
})&lt;/p&gt;
&lt;p&gt;// 监听ref里的属性，第二种写法：直接加.value，也正确
watch(userInfo.value.age, (newVal, oldVal) =&amp;gt; {
console.log(&#039;age变了&#039;, newVal, oldVal)
})&lt;/p&gt;
&lt;p&gt;// 多个ref，正确，自动解包
watch([count, userInfo], ([newCount, newUserInfo], [oldCount, oldUserInfo]) =&amp;gt; {
console.log(&#039;多个数据变了&#039;, newCount, newUserInfo, oldCount, oldUserInfo)
})&lt;/p&gt;
&lt;/script&gt;
```
&lt;h4&gt;踩坑4：watch监听reactive的根对象时deep: true没用&lt;/h4&gt;
&lt;p&gt;很多新手会遇到这种情况：监听reactive的根对象，默认浅监听，换对象地址才触发，加了deep: true，改对象里的属性也触发了——但这不是deep: true的功劳！因为Vue3对reactive的根对象默认就是深监听的！那为什么会有人觉得没用呢？因为他监听的是“解构后的reactive属性”！&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { reactive, watch } from &amp;#39;vue&amp;#39;
const userInfo = reactive({ username: &amp;#39;张三&amp;#39;, age: 18 })
const { username, age } = userInfo // 解构后变成普通变量了，不是响应式的！
// 解构后的变量，不是响应式的，加deep: true也没用
watch(username, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;username变了&amp;#39;, newVal, oldVal) // 永远不会触发
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;那如果非要解构reactive的属性怎么办？用toRefs或者toRef！toRefs把reactive的所有属性都转成ref,toRef只转单个：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;script setup&amp;gt;
import { reactive, toRefs, toRef, watch } from &amp;#39;vue&amp;#39;
const userInfo = reactive({ username: &amp;#39;张三&amp;#39;, age: 18 })
const { username: usernameRef } = toRefs(userInfo)
const ageRef = toRef(userInfo, &amp;#39;age&amp;#39;)
// 现在是ref了，正确监听
watch(usernameRef, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;username变了&amp;#39;, newVal, oldVal)
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;踩坑5：watch和v-model同时用导致死循环&lt;/h4&gt;
&lt;p&gt;这个坑也很常见，比如你写了个搜索栏，输入关键词后要把关键词转成小写，然后再执行搜索,你可能会这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 错误！死循环 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;keyword&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
// 监听keyword，转小写后重新赋值
watch(keyword, (newVal) =&amp;gt; {
  keyword.value = newVal.toLowerCase()
  // 然后执行搜索...
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;乍一看好像没问题？但当你输入大写字母的时候，比如输入“A”，keyword.value变成“A”，触发watch，把keyword.value改成“a”，又触发watch，又把“a”改成“a”——哦，不对，第二次改成“a”的时候，newVal和oldVal都是“a”，应该不会触发吧？但有些情况下（比如用了computed或者其他响应式数据干扰），或者你转成的不是完全一样的字符串（比如转小写的时候带了空格处理，第一次带空格，第二次不带，又触发），就会导致死循环，正确的做法是在监听的时候加个判断,或者直接用computed的getter：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 正确！加判断 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;keyword&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
watch(keyword, (newVal) =&amp;gt; {
  const lowerKeyword = newVal.toLowerCase()
  if (lowerKeyword !== newVal) {
    keyword.value = lowerKeyword
  }
  // 然后执行搜索...
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;或者用computed更优雅：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 正确！用computed --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;inputKeyword&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, computed, watch } from &amp;#39;vue&amp;#39;
const inputKeyword = ref(&amp;#39;&amp;#39;)
const lowerKeyword = computed(() =&amp;gt; inputKeyword.value.toLowerCase())
// 直接监听lowerKeyword执行搜索
watch(lowerKeyword, (newVal) =&amp;gt; {
  // 执行搜索...
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;踩坑6：自定义v-model修饰符的时候忘了接收modifiers&lt;/h4&gt;
&lt;p&gt;比如你想造个.money的修饰符，自动把输入的数字转成两位小数,你可能会这么写：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 父组件正确绑定修饰符 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;MoneyInput v-model.money=&amp;quot;price&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref } from &amp;#39;vue&amp;#39;
import MoneyInput from &amp;#39;./MoneyInput.vue&amp;#39;
const price = ref(0)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后子组件里直接判断：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 错误！没接收modifiers --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;number&amp;quot; :value=&amp;quot;modelValue&amp;quot; @input=&amp;quot;handleInput&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
const props = defineProps([&amp;#39;modelValue&amp;#39;])
const emit = defineEmits([&amp;#39;update:modelValue&amp;#39;])
const handleInput = (e) =&amp;gt; {
  let val = Number(e.target.value)
  // 错误！props里没有money修饰符的判断
  if (props.money) {
    val = val.toFixed(2)
  }
  emit(&amp;#39;update:modelValue&amp;#39;, val)
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;哦，对了，自定义v-model修饰符的时候，父组件传的修饰符会放在props的modelModifiers里！如果是带参数的v-model，比如v-model:start.money，修饰符会放在startModifiers里！正确的子组件代码：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 正确！接收modelModifiers --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input type=&amp;quot;number&amp;quot; :value=&amp;quot;modelValue&amp;quot; @input=&amp;quot;handleInput&amp;quot; /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
const props = defineProps({
  modelValue: {
    type: [Number, String],
    default: 0
  },
  modelModifiers: {
    type: Object,
    default: () =&amp;gt; ({}) // 默认是空对象
  }
})
const emit = defineEmits([&amp;#39;update:modelValue&amp;#39;])
const handleInput = (e) =&amp;gt; {
  let val = Number(e.target.value)
  if (props.modelModifiers.money) {
    val = val.toFixed(2)
  }
  emit(&amp;#39;update:modelValue&amp;#39;, val)
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;踩坑7：watchEffect没有清理副作用&lt;/h4&gt;
&lt;p&gt;这个坑可能新手暂时遇不到，但做C端项目或者后台带实时数据更新的项目的时候很容易踩，比如你写了个倒计时组件，或者写了个实时获取位置的组件，或者写了个定时发送请求的组件，用watchEffect自动追踪倒计时结束的变量，那你必须在watchEffect的回调里写清理函数，否则组件卸载后，定时器/请求/位置监听还在跑，会导致内存泄漏！
举个实时获取位置的例子：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 错误！没有清理副作用 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;当前位置：{{ location }}&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watchEffect } from &amp;#39;vue&amp;#39;
const location = ref(&amp;#39;正在获取...&amp;#39;)
watchEffect(() =&amp;gt; {
  const watchId = navigator.geolocation.watchPosition(
    (position) =&amp;gt; {
      location.value = `经度：${position.coords.longitude.toFixed(4)}，纬度：${position.coords.latitude.toFixed(4)}`
    },
    (error) =&amp;gt; {
      location.value = `获取失败：${error.message}`
    }
  )
  // 错误！组件卸载后watchId还在
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;正确的做法是在watchEffect的回调里返回一个清理函数，当组件卸载或者watchEffect的依赖变化重新执行之前,Vue会自动调用这个清理函数：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 正确！有清理副作用 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;当前位置：{{ location }}&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watchEffect } from &amp;#39;vue&amp;#39;
const location = ref(&amp;#39;正在获取...&amp;#39;)
watchEffect((onCleanup) =&amp;gt; {
  const watchId = navigator.geolocation.watchPosition(
    (position) =&amp;gt; {
      location.value = `经度：${position.coords.longitude.toFixed(4)}，纬度：${position.coords.latitude.toFixed(4)}`
    },
    (error) =&amp;gt; {
      location.value = `获取失败：${error.message}`
    }
  )
  // 返回清理函数
  onCleanup(() =&amp;gt; {
    navigator.geolocation.clearWatch(watchId)
    console.log(&amp;#39;位置监听已清理&amp;#39;)
  })
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h3&gt;真实场景下的搭配技巧：什么时候该用谁？&lt;/h3&gt;
&lt;p&gt;现在咱们把本质和坑都搞清楚了，接下来聊聊实际开发中怎么选、怎么搭，我总结了3个最常用的场景,给你具体的解决方案。&lt;/p&gt;
&lt;h4&gt;场景1：后台表单开发：v-model为主，watch为辅&lt;/h4&gt;
&lt;p&gt;后台表单是Vue3用得最多的场景之一，比如登录注册、商品编辑、订单修改，这里的核心逻辑是“用户输入→实时更新表单数据→提交时验证并发送请求”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;什么时候用v-model&lt;/strong&gt;：所有的表单输入元素（包括自定义的日期范围选择器、文件上传组件、富文本编辑器），都应该用v-model绑定,这样能大大减少代码量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;什么时候用watch&lt;/strong&gt;：主要有两个场景：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;表单联动&lt;/strong&gt;：比如选择商品分类后，自动加载该分类下的商品属性；或者选择省份后,自动加载该省份下的城市和区县。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;表单实时验证&lt;/strong&gt;：比如输入用户名时，实时检查用户名是否已被注册；或者输入密码时，实时检查密码强度。
举个商品编辑表单联动的例子：&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;
&amp;lt;!-- 商品编辑表单联动 --&amp;gt;
&amp;lt;template&amp;gt;
&amp;lt;div&amp;gt;
&amp;lt;label&amp;gt;商品分类：&amp;lt;/label&amp;gt;
&amp;lt;select v-model=&amp;quot;form.categoryId&amp;quot;&amp;gt;
 &amp;lt;option value=&amp;quot;&amp;quot;&amp;gt;请选择分类&amp;lt;/option&amp;gt;
 &amp;lt;option v-for=&amp;quot;category in categories&amp;quot; :key=&amp;quot;category.id&amp;quot; :value=&amp;quot;category.id&amp;quot;&amp;gt;
   {{ category.name }}
 &amp;lt;/option&amp;gt;
&amp;lt;/select&amp;gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;label&gt;商品属性：&lt;/label&gt;
  &lt;div v-if=&quot;form.categoryId&quot;&gt;
    &lt;div v-for=&quot;attr in attributes&quot; :key=&quot;attr.id&quot;&gt;
      &lt;label&gt;{{ attr.name }}：&lt;/label&gt;
      &lt;input type=&quot;text&quot; v-model=&quot;form.attrs[attr.id]&quot; /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;p&gt;&amp;lt;button @click=&quot;submit&quot;&amp;gt;提交&lt;/button&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;script setup&gt;
import { ref, reactive, watch } from &#039;vue&#039;
&lt;p&gt;const form = reactive({
categoryId: &#039;&#039;,
attrs: {}
})
const categories = ref([])
const attributes = ref([])&lt;/p&gt;
&lt;p&gt;// 页面加载时获取所有分类
// 这里假设已经有个getCategories的API函数
getCategories().then(res =&amp;gt; {
categories.value = res.data
})&lt;/p&gt;
&lt;p&gt;// 监听分类变化，加载该分类下的属性
watch(() =&amp;gt; form.categoryId, async (newCategoryId) =&amp;gt; {
if (!newCategoryId) {
attributes.value = []
form.attrs = {}
return
}
// 这里假设已经有个getAttributesByCategoryId的API函数
const res = await getAttributesByCategoryId(newCategoryId)
attributes.value = res.data
// 初始化属性值
form.attrs = {}
res.data.forEach(attr =&amp;gt; {
form.attrs[attr.id] = &#039;&#039;
})
})&lt;/p&gt;
&lt;p&gt;const submit = () =&amp;gt; {
// 这里做表单验证和提交
console.log(&#039;表单数据&#039;, form)
}&lt;/p&gt;
&lt;/script&gt;
```
&lt;h4&gt;场景2：C端搜索栏开发：带防抖的v-model + watch&lt;/h4&gt;
&lt;p&gt;C端搜索栏的核心逻辑是“用户输入→防抖延迟→发送搜索请求→显示搜索结果”，这里如果不用防抖，用户每输入一个字符就发一次请求，会给服务器造成很大的压力，也会影响用户体验。
这里有两种解决方案：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;用watch + 自定义防抖函数&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;我自己最近琢磨出来的带防抖的v-model自定义组件&lt;/strong&gt;。
先给你看第一种解决方案,然后再给你看第二种更优雅的。&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;解决方案1：watch + 自定义防抖函数&lt;/h5&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- C端搜索栏：watch + 自定义防抖函数 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;input type=&amp;quot;text&amp;quot; v-model=&amp;quot;keyword&amp;quot; placeholder=&amp;quot;请输入搜索关键词&amp;quot; /&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;div v-for=&amp;quot;result in searchResults&amp;quot; :key=&amp;quot;result.id&amp;quot;&amp;gt;
        {{ result.title }}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
const searchResults = ref([])
let timer = null // 防抖定时器
// 自定义防抖函数
const debounce = (fn, delay) =&amp;gt; {
  return (...args) =&amp;gt; {
    clearTimeout(timer)
    timer = setTimeout(() =&amp;gt; {
      fn.apply(this, args)
    }, delay)
  }
}
// 监听keyword变化，防抖延迟500ms后发送请求
const search = debounce(async (newKeyword) =&amp;gt; {
  if (!newKeyword.trim()) {
    searchResults.value = []
    return
  }
  // 这里假设已经有个search的API函数
  const res = await search(newKeyword)
  searchResults.value = res.data
}, 500)
watch(keyword, (newKeyword) =&amp;gt; {
  search(newKeyword)
})
// 组件卸载时清理定时器
import { onUnmounted } from &amp;#39;vue&amp;#39;
onUnmounted(() =&amp;gt; {
  clearTimeout(timer)
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h5&gt;解决方案2：带防抖的v-model自定义组件（更优雅，可复用）&lt;/h5&gt;
&lt;p&gt;这个组件可以直接在任何需要防抖输入的地方用，不管是C端搜索栏还是后台的实时筛选,非常方便。&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- DebounceInput.vue：带防抖的v-model自定义组件 --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;input 
    type=&amp;quot;text&amp;quot; 
    :value=&amp;quot;innerValue&amp;quot; 
    @input=&amp;quot;handleInput&amp;quot; 
    :placeholder=&amp;quot;placeholder&amp;quot;
  /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, computed, watch, onUnmounted } from &amp;#39;vue&amp;#39;
const props = defineProps({
  modelValue: {
    type: String,
    default: &amp;#39;&amp;#39;
  },
  delay: {
    type: Number,
    default: 500
  },
  placeholder: {
    type: String,
    default: &amp;#39;&amp;#39;
  }
})
const emit = defineEmits([&amp;#39;update:modelValue&amp;#39;])
const innerValue = ref(props.modelValue)
let timer = null
// 监听modelValue变化，同步到innerValue
watch(() =&amp;gt; props.modelValue, (newVal) =&amp;gt; {
  innerValue.value = newVal
})
const handleInput = (e) =&amp;gt; {
  innerValue.value = e.target.value
  clearTimeout(timer)
  timer = setTimeout(() =&amp;gt; {
    emit(&amp;#39;update:modelValue&amp;#39;, innerValue.value)
  }, props.delay)
}
// 组件卸载时清理定时器
onUnmounted(() =&amp;gt; {
  clearTimeout(timer)
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;然后在父组件里直接用就行,代码非常简洁：&lt;/p&gt;
&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;&amp;lt;!-- 父组件使用DebounceInput --&amp;gt;
&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;DebounceInput v-model=&amp;quot;keyword&amp;quot; placeholder=&amp;quot;请输入搜索关键词&amp;quot; :delay=&amp;quot;300&amp;quot; /&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;div v-for=&amp;quot;result in searchResults&amp;quot; :key=&amp;quot;result.id&amp;quot;&amp;gt;
        {{ result.title }}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
import DebounceInput from &amp;#39;./DebounceInput.vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
const searchResults = ref([])
// 直接监听keyword变化发送请求，不用再写防抖了！
watch(keyword, async (newKeyword) =&amp;gt; {
  if (!newKeyword.trim()) {
    searchResults.value = []
    return
  }
  const res = await search(newKeyword)
  searchResults.value = res.data
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;h4&gt;场景3：C端点赞收藏组件：多个v-model + watchPostFlush&lt;/h4&gt;
&lt;p&gt;这个场景可能用v-model的人不多，但用了之后会非常方便，点赞收藏组件的核心逻辑是“用户点击按钮→切换点赞/收藏状态→发送请求更新服务器→请求成功/失败后处理UI”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;为什么用多个v-model&lt;/strong&gt;：因为点赞和收藏是两个独立的状态，用v-model:liked和v-model:collected可以直接在父组件里控制初始状态,也可以直接获取最新状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;为什么用watchPostFlush&lt;/strong&gt;：因为我们希望先更新UI（给用户反馈，比如点赞按钮变红色），然后再发送请求，这样用户体验更好。
举个例子：&lt;pre class=&quot;brush:vue;toolbar:false&quot;&gt;
&amp;lt;!-- LikeCollectButton.vue：点赞收藏组件 --&amp;gt;
&amp;lt;template&amp;gt;
&amp;lt;div&amp;gt;
  &amp;lt;button 
    :class=&amp;quot;{ active: liked }&amp;quot; 
    @click=&amp;quot;toggleLike&amp;quot;
  &amp;gt;
    👍 {{ likeCount }}
  &amp;lt;/button&amp;gt;
  &amp;lt;button 
    :class=&amp;quot;{ active: collected }&amp;quot; 
    @click=&amp;quot;toggleCollect&amp;quot;
  &amp;gt;
    📦 {{ collectCount }}
  &amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;script setup&gt;
import { ref, watchPostFlush } from &#039;vue&#039;
&lt;p&gt;const props = defineProps({
modelValue: {
type: [Number, String],
required: true // 这里用单个modelValue传文章ID，或者用articleId也可以
},
liked: {
type: Boolean,
default: false
},
collected: {
type: Boolean,
default: false
},
likeCount: {
type: Number,
default: 0
},
collectCount: {
type: Number,
default: 0
}
})
const emit = defineEmits([&#039;update:liked&#039;, &#039;update:collected&#039;, &#039;update:likeCount&#039;, &#039;update:collectCount&#039;])&lt;/p&gt;
&lt;p&gt;let pendingAction = null // 待执行的请求&lt;/p&gt;
&lt;p&gt;// 先切换UI状态，再发送请求
const toggleLike = () =&amp;gt; {
emit(&#039;update:liked&#039;, !props.liked)
emit(&#039;update:likeCount&#039;, props.liked ? props.likeCount - 1 : props.likeCount + 1)
pendingAction = &#039;like&#039;
}&lt;/p&gt;
&lt;p&gt;const toggleCollect = () =&amp;gt; {
emit(&#039;update:collected&#039;, !props.collected)
emit(&#039;update:collectCount&#039;, props.collected ? props.collectCount - 1 : props.collectCount + 1)
pendingAction = &#039;collect&#039;
}&lt;/p&gt;
&lt;p&gt;// watchPostFlush在DOM更新后执行，也就是UI变了之后再发送请求
watchPostFlush(async () =&amp;gt; {
if (!pendingAction) return
try {
// 这里假设已经有个toggleLike和toggleCollect的API函数
if (pendingAction === &#039;like&#039;) {
await toggleLike(props.modelValue, !props.liked)
} else {
await toggleCollect(props.modelValue, !props.collected)
}
pendingAction = null
} catch (error) {
// 请求失败，回滚UI状态
if (pendingAction === &#039;like&#039;) {
emit(&#039;update:liked&#039;, props.liked)
emit(&#039;update:likeCount&#039;, props.likeCount)
} else {
emit(&#039;update:collected&#039;, props.collected)
emit(&#039;update:collectCount&#039;, props.collectCount)
}
pendingAction = null
alert(error.message || &#039;操作失败，请重试&#039;)
}
})&lt;/p&gt;
&lt;/script&gt;
&lt;style scoped&gt;
button {
  margin-right: 10px;
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background-color: #fff;
  cursor: pointer;
}
&lt;p&gt;button.active {
border-color: #ff4d4f;
color: #ff4d4f;
background-color: #fff1f0;
}&lt;/p&gt;
&lt;/style&gt;
```
然后在父组件里直接用：
```vue
&lt;!-- 父组件使用LikeCollectButton --&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;h2&gt;{{ article.title }}&lt;/h2&gt;
    &lt;p&gt;{{ article.content }}&lt;/p&gt;
    &lt;LikeCollectButton
      :modelValue=&quot;article.id&quot;
      v-model:liked=&quot;article.liked&quot;
      v-model:collected=&quot;article.collected&quot;
      v-model:likeCount=&quot;article.likeCount&quot;
      v-model:collectCount=&quot;article.collectCount&quot;
    /&gt;
  &lt;/div&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { ref, onMounted } from &#039;vue&#039;
import LikeCollectButton from &#039;./LikeCollectButton.vue&#039;
&lt;p&gt;const article = ref(null)&lt;/p&gt;
&lt;p&gt;onMounted(async () =&amp;gt; {
// 这里假设已经有个getArticle的API函数
const res = await getArticle(1)
article.value = res.data
})&lt;/p&gt;
&lt;/script&gt;
```
&lt;h3&gt;最后总结一下：记住这5句话，再也不用纠结怎么选&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;v-model是双向绑定的语法糖，本质是单向数据流，子组件只能抛事件不能直接改prop&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;watch是数据变化的监听器+触发器，精准监听特定数据，适合表单联动、实时验证、副作用处理&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;watchEffect自动追踪响应式数据，适合依赖多个数据的场景，但要注意清理副作用&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后台表单用v-model为主，watch为辅&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C端交互场景可以用自定义v-model组件简化代码，搭配watchPostFlush提升用户体验&lt;/strong&gt;。
就讲到这里，如果你还有其他Vue3的问题，或者想了解更多的进阶用法，可以在评论区留言，我会一一回复，觉得有用的话,别忘了点赞收藏转发哦！&lt;/li&gt;
&lt;/ol&gt;</description><pubDate>Mon, 04 May 2026 14:02:38 +0800</pubDate></item><item><title>Vue3 watch的newValue和oldValue到底怎么用？还有哪些隐藏坑？</title><link>https://codeqd.com/post/20260521803.html</link><description>&lt;p&gt;用过Vue2的老开发者都知道，watch的两个回调参数非常有用——判断变化是正方向还是反方向、只响应首次外的后续修改、对比特定字段有没有变动……但到了Vue3，组合式API的watch来了，新老参数好像没变，但坑变多了？用法也加了不少细节？别慌，今天就用大家平时写代码的场景,把这俩参数掰扯透。&lt;/p&gt;
&lt;h2&gt;基础回顾：watch的newValue、oldValue分别是什么？&lt;/h2&gt;
&lt;p&gt;不管你用的是组合式API的&lt;code&gt;watch&lt;/code&gt;，还是选项式API里保留的&lt;code&gt;watch&lt;/code&gt;，核心逻辑没差：这俩都是函数的&lt;strong&gt;形参名字，可以随便改&lt;/strong&gt;，但位置固定——第一个是&lt;strong&gt;监听器触发时目标属性的最新值&lt;/strong&gt;，第二个是&lt;strong&gt;触发前的旧值&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;举个最直观的小例子：比如你有个计数器，点击按钮加1，你想看看加1前后的数,代码大概长这样：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;// 组合式API写法
import { ref, watch } from &amp;#39;vue&amp;#39;
const count = ref(0)
watch(count, (newCount, oldCount) =&amp;gt; {
  console.log(`加完啦！之前是${oldCount}，现在是${newCount}`)
})
// 模拟点击
count.value = 1 // 控制台输出：加完啦！之前是0，现在是1
count.value = 2 // 控制台输出：加完啦！之前是1，现在是2&lt;/pre&gt;
&lt;p&gt;选项式API的写法其实就是把变量放在&lt;code&gt;data&lt;/code&gt;，watch放在&lt;code&gt;watch&lt;/code&gt;配置项里，参数逻辑一模一样，这一步是入门级操作，但别嫌简单，后面所有坑都是从这个“基础对应位置”的认知延伸出来的。&lt;/p&gt;
&lt;h2&gt;进阶场景1：监听引用类型数据，为啥oldValue和newValue看起来一样？&lt;/h2&gt;
&lt;p&gt;这是&lt;strong&gt;Vue3 watch里最常见、最容易让人挠头的坑&lt;/strong&gt;，没有之一，之前有个朋友做了个购物车，想在商品数量增加时只弹“新增商品成功”，减少时弹“减少商品成功”，结果不管加还是减，console.log出来的old和new都是修改后的数组,根本没法判断。&lt;/p&gt;
&lt;p&gt;为什么会这样？得从JavaScript的数据类型说起：基本类型（string、number、boolean、null、undefined、Symbol、BigInt）是按&lt;strong&gt;值传递&lt;/strong&gt;的，也就是变量存的是具体数据；引用类型（object、array、Map、Set这些）是按&lt;strong&gt;引用传递&lt;/strong&gt;的,变量存的是内存地址。&lt;/p&gt;
&lt;p&gt;Vue2里监听数组的非直接赋值（比如&lt;code&gt;push&lt;/code&gt;、&lt;code&gt;pop&lt;/code&gt;）时，oldValue和newValue也是一样的，但监听整个引用（比如直接&lt;code&gt;arr = []&lt;/code&gt;）时不一样；到了Vue3组合式API，不管你怎么监听引用类型的响应式数据，&lt;strong&gt;只要修改的是引用内部的属性/元素，newValue和oldValue永远指向同一个内存地址&lt;/strong&gt;,打印出来当然一模一样！&lt;/p&gt;
&lt;p&gt;那怎么解决？别慌，有两种主流的方法,看你的需求选就行：&lt;/p&gt;
&lt;h3&gt;直接监听引用内部的“关键点”&lt;/h3&gt;
&lt;p&gt;如果只是要判断某个具体字段变没变，别监听整个对象/数组，直接监听那个字段，比如刚才的购物车场景，假设每个商品是&lt;code&gt;{id: 1, name: &#039;可乐&#039;, count: 1}&lt;/code&gt;，你可以监听某个商品的count，或者监听购物车数组的length，甚至是某个商品ID对应的count（这个得用箭头函数返回）：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const cart = ref([{id: 1, name: &amp;#39;可乐&amp;#39;, count: 1}, {id: 2, name: &amp;#39;薯片&amp;#39;, count: 2}])
// 1. 直接监听整个购物车数组的length（如果判断整体增减）
watch(() =&amp;gt; cart.value.length, (newLen, oldLen) =&amp;gt; {
  if (newLen &amp;gt; oldLen) {
    alert(&amp;#39;新增商品成功！&amp;#39;)
  } else if (newLen &amp;lt; oldLen) {
    alert(&amp;#39;删除商品成功！&amp;#39;)
  }
})
// 2. 监听单个商品的count（比如可乐，这里用了箭头函数，因为cart是ref，得先.value）
watch(() =&amp;gt; cart.value[0].count, (newCount, oldCount) =&amp;gt; {
  if (newCount &amp;gt; oldCount) {
    alert(&amp;#39;可乐加购成功！&amp;#39;)
  } else if (newCount &amp;lt; oldCount) {
    alert(&amp;#39;可乐减购成功！&amp;#39;)
  }
})&lt;/pre&gt;
&lt;p&gt;这种方法的优点是&lt;strong&gt;性能最好&lt;/strong&gt;——只监听你关心的那个点，其他地方变了不触发；缺点是如果要监听多个关键点，得写好几个watch,代码会有点冗余。&lt;/p&gt;
&lt;h3&gt;开启deep + 手动深拷贝旧值&lt;/h3&gt;
&lt;p&gt;如果必须监听整个引用类型，比如要对比整个对象里所有字段的变动（比如表单提交前的脏值检测，看看用户改了啥），那只能开启deep选项，但这还不够，因为开启deep后，内部变动触发的回调里，new和old还是同一个内存地址的引用，这时候得&lt;strong&gt;手动保存旧值的深拷贝&lt;/strong&gt;：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch, reactive, toRaw, cloneDeep } from &amp;#39;lodash-es&amp;#39; // 这里用了lodash的cloneDeep，也可以自己写深拷贝，但推荐用成熟库
const form = reactive({
  name: &amp;#39;张三&amp;#39;,
  age: 18,
  phone: &amp;#39;13800138000&amp;#39;
})
// 先存一份初始的深拷贝
let oldForm = cloneDeep(toRaw(form)) // toRaw把reactive转成普通对象，避免深拷贝响应式代理
watch(form, (newFormRaw) =&amp;gt; {
  // 对比两个普通对象
  const changedFields = []
  Object.keys(oldForm).forEach(key =&amp;gt; {
    if (oldForm[key] !== newFormRaw[key]) {
      changedFields.push(key)
    }
  })
  console.log(&amp;#39;用户修改了这些字段：&amp;#39;, changedFields)
  // 更新oldForm
  oldForm = cloneDeep(newFormRaw)
}, { deep: true })
// 模拟修改
form.name = &amp;#39;李四&amp;#39; // 控制台输出：用户修改了这些字段： [&amp;#39;name&amp;#39;]
form.age = 20 // 控制台输出：用户修改了这些字段： [&amp;#39;age&amp;#39;]&lt;/pre&gt;
&lt;p&gt;这里有几个细节要注意：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;最好用&lt;code&gt;toRaw&lt;/code&gt;把reactive转成普通对象再深拷贝，因为直接深拷贝响应式代理可能会带来一些不必要的性能开销,甚至是代理嵌套的问题；&lt;/li&gt;
&lt;li&gt;lodash-es是专门给ES模块用的，别直接用lodash，不然Vue3打包的时候可能会有问题（比如Tree Shaking失效）；&lt;/li&gt;
&lt;li&gt;如果只是浅拷贝（比如&lt;code&gt;Object.assign&lt;/code&gt;、展开运算符），那对象里还有嵌套对象的话，内层的引用还是一样的，对比没用，所以必须&lt;strong&gt;深拷贝&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;手动深拷贝旧值的话，watch的第二个回调参数oldValue其实就没用了,直接忽略就行。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;进阶场景2：监听ref的基本类型，开启immediate后，oldValue是undefined？&lt;/h2&gt;
&lt;p&gt;对，这个也是Vue3组合式API里的一个“特性”，不是bug，组合式API的watch默认是&lt;strong&gt;惰性监听&lt;/strong&gt;——第一次初始化的时候不会触发，只有目标属性后续变化了才会触发；但如果你开启了&lt;code&gt;immediate: true&lt;/code&gt;，第一次初始化的时候就会立即执行一次回调，这时候&lt;strong&gt;还没有“旧值”这个概念&lt;/strong&gt;,所以第二个参数就是undefined。&lt;/p&gt;
&lt;p&gt;选项式API里的watch开启immediate后，oldValue也是undefined，这个逻辑没变,只是很多组合式API的新手可能忘了这个点。&lt;/p&gt;
&lt;p&gt;举个例子说明一下：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const count = ref(0)
// 开启immediate
watch(count, (newCount, oldCount) =&amp;gt; {
  console.log(`加完啦？之前是${oldCount}，现在是${newCount}`)
}, { immediate: true })
// 不用手动点击，页面加载（或者说组件挂载后？不对，immediate是在watch创建的时候立即执行的）就会输出：加完啦？之前是undefined，现在是0
count.value = 1 // 输出：加完啦？之前是0，现在是1&lt;/pre&gt;
&lt;p&gt;那如果开启immediate后也需要旧值怎么办？比如第一次加载的时候，要判断初始值是不是符合要求，符合就显示默认提示，不符合就显示修改提示？这时候可以像刚才深拷贝引用类型那样，&lt;strong&gt;手动存一份初始值&lt;/strong&gt;：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;const count = ref(0)
const initCount = count.value // 初始值手动存下来
watch(count, (newCount, oldCount) =&amp;gt; {
  // 先判断是不是第一次immediate触发
  if (oldCount === undefined) {
    if (newCount &amp;gt;= 0) {
      console.log(&amp;#39;初始值符合要求！&amp;#39;)
    } else {
      console.log(&amp;#39;初始值不符合要求！&amp;#39;)
    }
    // 或者不管是不是第一次，直接用initCount和newCount、oldCount组合判断
  }
}, { immediate: true })&lt;/pre&gt;
&lt;p&gt;或者你也可以用&lt;code&gt;watchEffect&lt;/code&gt;代替watch？不过watchEffect没有new和old参数，只能自己手动对比，而且watchEffect是自动收集依赖的，有时候可能会不小心触发多余的回调,具体用哪个得看场景。&lt;/p&gt;
&lt;h2&gt;进阶场景3：用watch同时监听多个值，new和old参数是数组吗？&lt;/h2&gt;
&lt;p&gt;对！这个是组合式API比选项式API更方便的地方——可以用一个watch同时监听多个响应式数据，不管是ref还是reactive的属性（用箭头函数返回），这时候回调的第一个参数是&lt;strong&gt;所有监听目标最新值组成的数组&lt;/strong&gt;，第二个参数是&lt;strong&gt;所有监听目标旧值组成的数组&lt;/strong&gt;,顺序和你传进去的监听源数组的顺序完全一致。&lt;/p&gt;
&lt;p&gt;比如你有一个登录表单，同时监听用户名和密码，只要其中一个变了，就判断是不是可以提交（比如用户名长度大于3，密码长度大于6）：&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const username = ref(&amp;#39;&amp;#39;)
const password = ref(&amp;#39;&amp;#39;)
const canSubmit = ref(false)
// 同时监听两个ref
watch([username, password], ([newUser, newPwd], [oldUser, oldPwd]) =&amp;gt; {
  console.log(`用户名从${oldUser}变成了${newUser}，密码从${oldPwd}变成了${newPwd}`)
  canSubmit.value = newUser.length &amp;gt; 3 &amp;amp;&amp;amp; newPwd.length &amp;gt; 6
})
// 模拟输入
username.value = &amp;#39;zhangs&amp;#39; // 输出：用户名从变成了zhangs，密码从变成了，canSubmit还是false
password.value = &amp;#39;1234567&amp;#39; // 输出：用户名从zhangs变成了zhangs，密码从变成了1234567，canSubmit变成true&lt;/pre&gt;
&lt;p&gt;这里还要注意，如果监听的多个值里有引用类型，那引用类型对应的new和old数组元素，还是会有刚才提到的“内存地址相同”的问题，解决方法还是一样的——要么监听内部关键点，要么开启deep + 手动深拷贝。&lt;/p&gt;
&lt;h2&gt;进阶场景4：组合式API有watch和watchEffect，什么时候用watch带new和old？什么时候用watchEffect？&lt;/h2&gt;
&lt;p&gt;虽然这篇文章主要讲new和old，但这个问题太常见了，很多人分不清，导致用错了watch反而浪费了new和old的优势,简单总结一下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;当你需要“明确知道目标属性什么时候变了”、“需要对比新旧值”、“需要惰性监听（第一次不触发）”的时候，用watch带new和old&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;当你不需要对比新旧值、只需要在依赖变化时自动执行副作用（比如请求接口、修改DOM）、需要立即执行（不用写immediate）的时候，用watchEffect&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;举个对比的例子：比如你有一个搜索框，输入关键词后延迟500ms请求接口（防抖）,这时候两种写法都可以：&lt;/p&gt;
&lt;h3&gt;用watch的写法（适合需要对比关键词是否为空的情况）&lt;/h3&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
let timer = null
watch(keyword, (newKey, oldKey) =&amp;gt; {
  // 对比新旧值：如果新关键词是空，就清空搜索结果；如果和旧关键词一样，就不请求（虽然防抖已经处理了，但双重保险）
  if (newKey === oldKey) return
  clearTimeout(timer)
  if (newKey.trim() === &amp;#39;&amp;#39;) {
    console.log(&amp;#39;清空搜索结果&amp;#39;)
    return
  }
  timer = setTimeout(() =&amp;gt; {
    console.log(&amp;#39;请求接口，关键词是：&amp;#39;, newKey)
  }, 500)
})&lt;/pre&gt;
&lt;h3&gt;用watchEffect的写法（更简洁，但没法直接对比新旧值）&lt;/h3&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watchEffect } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
let timer = null
// 自动收集keyword的依赖，keyword变了就执行
watchEffect(() =&amp;gt; {
  clearTimeout(timer)
  const currentKey = keyword.value.trim()
  if (currentKey === &amp;#39;&amp;#39;) {
    console.log(&amp;#39;清空搜索结果&amp;#39;)
    return
  }
  timer = setTimeout(() =&amp;gt; {
    console.log(&amp;#39;请求接口，关键词是：&amp;#39;, currentKey)
  }, 500)
})&lt;/pre&gt;
&lt;p&gt;两种写法都能实现功能，但watch的写法多了一个“对比新旧值是否一样”的逻辑，虽然在这个场景下防抖已经能处理，但如果有其他逻辑（比如关键词从空变非空、非空变空要做不同的动画）,watch带new和old就会方便很多。&lt;/p&gt;
&lt;h2&gt;最后再提几个容易被忽略的小细节&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;组合式API里的watch可以停止监听&lt;/strong&gt;：调用watch会返回一个停止函数，你可以在组件卸载前调用它，或者在不需要监听的时候随时调用，比如刚才的搜索框，组件卸载的时候要记得清掉定时器和停止监听，避免内存泄漏：&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;
const stopWatch = watch(keyword, (newKey, oldKey) =&amp;gt; { /* ... */ })&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;// 组件卸载前调用
onUnmounted(() =&amp;gt; {
clearTimeout(timer)
stopWatch()
})&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
不过Vue3的组合式API里，如果你在setup或者&amp;lt;script setup&amp;gt;里调用watch，组件卸载的时候会自动停止监听，不需要手动调用stopWatch，这一点比选项式API方便；但如果你是在定时器、Promise回调、或者其他非setup的生命周期钩子之外调用的watch，就必须手动停止了。
2. **监听箭头函数返回的基本类型时，不需要开启deep**：只有监听引用类型的响应式数据，并且要监听内部属性/元素变动的时候，才需要开启deep；如果是箭头函数返回的基本类型（() =&amp;gt; cart.value.length`），直接监听就行，deep选项是无效的。
3. **deep选项会带来性能开销**：开启deep后，Vue会递归遍历整个引用类型的响应式数据，监听每一个内部属性/元素的变动，所以如果你的引用类型数据很大（比如包含1000个商品的购物车，每个商品又有很多嵌套属性），尽量别开启deep，还是用“监听内部关键点”的方法。
4. **flush选项可以控制回调执行的时机**：默认是`flush: &#039;pre&#039;`——在DOM更新前执行；如果改成`flush: &#039;post&#039;`，会在DOM更新后执行；改成`flush: &#039;sync&#039;`，会同步执行（每次变动都立即执行，不合并，性能很差，尽量别用），比如你需要在回调里获取修改后的DOM元素的高度，就得改成`flush: &#039;post&#039;`。
以上就是关于Vue3 watch的newValue和oldValue的所有内容，从基础回顾到进阶场景，再到隐藏坑和小细节，应该能解决你90%以上的问题，下次写代码的时候碰到这俩参数，别再慌了，先想想是什么数据类型，再想想你的需求是什么，然后选择合适的方法就行。&lt;/code&gt;&lt;/pre&gt;</description><pubDate>Mon, 04 May 2026 08:01:44 +0800</pubDate></item><item><title>Vue3 watch新值旧值一样？怎么正确获取区分新旧值？</title><link>https://codeqd.com/post/20260521802.html</link><description>&lt;p&gt;你有没有碰到过用Vue3 watch监听响应式数据时，打印出来的newVal和oldVal完全是一模一样的对象或数组？或者用了深度监听之后，oldVal干脆变成了新值的副本？刚从Vue2转过来的开发者可能尤其容易踩这个坑——毕竟Vue2里深度监听对象时，oldVal至少是监听前那次更新前的旧引用，今天咱们就把Vue3 watch的newVal、oldVal这事儿彻底理清楚，从原理到解决方案,全说透。&lt;/p&gt;
&lt;h2&gt;为什么有时候watch拿到的newVal和oldVal一模一样？&lt;/h2&gt;
&lt;p&gt;这个问题得先从Vue3的响应式原理和watch的触发逻辑说起。&lt;/p&gt;
&lt;p&gt;Vue3用的是Proxy拦截器实现响应式，而Vue2是Object.defineProperty，Proxy可以直接拦截整个对象的修改，包括新增、删除属性，修改数组下标这些，这是它比defineProperty强的地方，但在watch新旧值这件事上,也带来了一些变化。&lt;/p&gt;
&lt;p&gt;watch的触发条件是&lt;strong&gt;响应式数据的“依赖变化”被追踪到&lt;/strong&gt;——但这里的“依赖”要看你监听的是啥：&lt;/p&gt;
&lt;h3&gt;监听的是基本类型（字符串、数字、布尔、null、undefined、Symbol）&lt;/h3&gt;
&lt;p&gt;这种情况最省心，基本类型是“值传递”的，每次修改都是替换掉响应式对象里的整个值，Proxy会明确感知到“旧值被替换成新值了”，所以watch回调里的newVal和oldVal肯定不一样,而且都是准确的。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const count = ref(0)
watch(count, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;new:&amp;#39;, newVal, &amp;#39;old:&amp;#39;, oldVal) // 第一次点击count.value++后是new:1 old:0，很准
})&lt;/pre&gt;
&lt;h3&gt;监听的是引用类型（对象、数组、Map、Set等）但没开deep&lt;/h3&gt;
&lt;p&gt;这时候Vue3默认只监听引用类型的&lt;strong&gt;地址变化&lt;/strong&gt;——也就是你得把整个对象/数组重新赋值（比如&lt;code&gt;obj.value = { ...obj.value, name: &#039;新名字&#039; }&lt;/code&gt;或者&lt;code&gt;arr.value = [...arr.value, &#039;新元素&#039;]&lt;/code&gt;），Proxy才会认为依赖变了，触发watch，这时候newVal是新地址的对象，oldVal是旧地址的对象，所以不一样，但如果你只是修改对象的某个属性、数组的某个元素，引用地址没动，Vue3的默认watch是&lt;strong&gt;不会触发&lt;/strong&gt;的,更别说拿到新旧值了。&lt;/p&gt;
&lt;h3&gt;监听的是引用类型并且开了deep: true&lt;/h3&gt;
&lt;p&gt;这就是踩坑最多的场景！为什么？因为Proxy虽然能深度拦截属性修改，但它&lt;strong&gt;不会自动保存引用类型修改前的所有旧值副本&lt;/strong&gt;——保存副本要消耗内存，Vue3不可能为每一个引用类型的每一次小修改都存一份，那这时候watch拿到的oldVal是什么？其实和newVal是&lt;strong&gt;同一个内存地址的对象&lt;/strong&gt;，所以不管你打印的时候看到的是啥（有时候浏览器控制台会“偷懒”用缓存，有时候展开才发现全变了），本质上newVal === oldVal。&lt;/p&gt;
&lt;p&gt;不过这里有个小例外：如果监听的是用shallowRef包裹的浅层响应式引用类型，并且修改的是深层属性，那deep: true也没用，和没开一样；但如果修改的是shallowRef包裹的整个引用地址,newVal和oldVal还是会不一样。&lt;/p&gt;
&lt;h2&gt;监听引用类型怎么才能拿到准确的newVal和oldVal？&lt;/h2&gt;
&lt;p&gt;知道了原理，解决方案就好找了,主要分三种场景：&lt;/p&gt;
&lt;h3&gt;只需要监听引用类型的&lt;strong&gt;某个具体属性/元素/计算结果&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;这种情况最简单，直接监听那个具体的东西就行，别监听整个引用类型，比如你想监听对象里的user.name，就别watch(user)，而是watch(() =&amp;gt; user.name)；想监听数组里的第三个元素，就watch(() =&amp;gt; arr.value[2])；想监听数组的长度，就watch(() =&amp;gt; arr.value.length)。&lt;/p&gt;
&lt;p&gt;因为这些具体的东西如果是基本类型，那watch默认就能拿到准确的新旧值；如果是引用类型的嵌套引用,你再针对它开deep或者用下面的方法。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watch } from &amp;#39;vue&amp;#39;
const user = reactive({
  name: &amp;#39;张三&amp;#39;,
  age: 18,
  address: {
    city: &amp;#39;北京&amp;#39;
  }
})
// 监听具体的基本类型属性
watch(() =&amp;gt; user.name, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;name变了：&amp;#39;, newVal, oldVal) // 正确
})
// 监听具体的嵌套引用类型属性，但这时候如果修改address.city，还是会触发newVal===oldVal
// 这时候要么继续嵌套监听具体的city，要么用后面的方法
watch(() =&amp;gt; user.address, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;address被替换/开deep变了：&amp;#39;, newVal, oldVal) // 如果只替换整个address，没问题；开deep变city，地址一样
}, { deep: true })&lt;/pre&gt;
&lt;h3&gt;需要监听引用类型的&lt;strong&gt;所有变化，并且必须拿到修改前的完整旧值&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;这时候就得自己手动保存旧值副本了,常见的有两种方法：&lt;/p&gt;
&lt;h4&gt;方法1：用computed + watch的immediate选项&lt;/h4&gt;
&lt;p&gt;思路是：先写一个computed属性，把需要监听的引用类型&lt;strong&gt;深拷贝&lt;/strong&gt;一份；然后watch这个computed属性，同时开immediate: true，这样组件初始化的时候就会先执行一次watch回调，把初始的深拷贝旧值存下来；之后每次computed因为原响应式数据变化而重新计算时，watch回调里的newVal就是新的深拷贝,oldVal就是之前存的旧深拷贝。&lt;/p&gt;
&lt;p&gt;深拷贝要注意用可靠的方法，别用JSON.parse(JSON.stringify())——这个方法会丢失函数、正则、Symbol、循环引用这些东西，推荐用lodash的cloneDeep，或者自己写一个简单的深拷贝（如果你的数据结构很简单的话）。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, computed, watch } from &amp;#39;vue&amp;#39;
import cloneDeep from &amp;#39;lodash/cloneDeep&amp;#39;
const todos = reactive([
  { id: 1, text: &amp;#39;吃饭&amp;#39;, done: false },
  { id: 2, text: &amp;#39;睡觉&amp;#39;, done: true }
])
// 计算属性深拷贝todos
const todosCopy = computed(() =&amp;gt; cloneDeep(todos))
// 存旧值的变量
let oldTodosCopy = null
// 监听计算属性，开immediate初始化旧值
watch(todosCopy, (newVal) =&amp;gt; {
  console.log(&amp;#39;todos的新深拷贝：&amp;#39;, newVal)
  console.log(&amp;#39;todos的旧深拷贝：&amp;#39;, oldTodosCopy)
  // 更新旧值，为下一次做准备
  oldTodosCopy = newVal
}, { immediate: true })&lt;/pre&gt;
&lt;p&gt;这种方法的优点是逻辑清晰，不管原数据怎么变化都能拿到完整的新旧值；缺点是每次原数据变化都要深拷贝，数据量大的时候可能会有性能问题,要谨慎使用。&lt;/p&gt;
&lt;h4&gt;方法2：用watchEffect手动追踪+保存&lt;/h4&gt;
&lt;p&gt;思路是：用watchEffect代替watch，在watchEffect里手动访问需要监听的响应式数据，确保依赖被收集；同时在watchEffect第一次执行的时候，把初始数据深拷贝存下来；之后每次watchEffect因为数据变化重新执行时，先打印/处理旧值,再把新数据深拷贝存为下一次的旧值。&lt;/p&gt;
&lt;p&gt;注意watchEffect是没有明确的newVal和oldVal参数的,全靠自己控制。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watchEffect } from &amp;#39;vue&amp;#39;
import cloneDeep from &amp;#39;lodash/cloneDeep&amp;#39;
const user = reactive({
  name: &amp;#39;李四&amp;#39;,
  age: 20,
  tags: [&amp;#39;程序员&amp;#39;, &amp;#39;Vue爱好者&amp;#39;]
})
let oldUserCopy = null
watchEffect((onCleanup) =&amp;gt; {
  // 手动访问所有需要监听的属性，确保依赖被收集
  const currentName = user.name
  const currentAge = user.age
  const currentTags = [...user.tags] // 这里也可以浅拷贝，但为了保险还是深拷贝整个user
  const currentUser = cloneDeep(user)
  // 如果有旧值，先处理
  if (oldUserCopy) {
    console.log(&amp;#39;user的旧深拷贝：&amp;#39;, oldUserCopy)
    console.log(&amp;#39;user的新深拷贝：&amp;#39;, currentUser)
  }
  // 更新旧值
  oldUserCopy = currentUser
  // 这里可以加onCleanup清除副作用，但这个场景暂时不需要
})&lt;/pre&gt;
&lt;p&gt;这种方法的优点是更灵活，可以自由控制依赖的收集；缺点是需要自己手动访问所有依赖，容易漏,而且同样有深拷贝的性能问题。&lt;/p&gt;
&lt;h3&gt;只是想对比引用类型的&lt;strong&gt;某个部分有没有变化&lt;/strong&gt;，不需要完整旧值&lt;/h3&gt;
&lt;p&gt;比如你只想知道用户的tags数组有没有新增或删除元素，或者address的city有没有变，那不需要深拷贝整个对象,只需要在watch里用computed或者手动提取的关键值做对比就行。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { reactive, watch } from &amp;#39;vue&amp;#39;
const user = reactive({
  name: &amp;#39;王五&amp;#39;,
  tags: [&amp;#39;老师&amp;#39;, &amp;#39;React转Vue&amp;#39;]
})
// 提取关键对比值：tags的长度和join后的字符串（这样即使元素顺序变了也能发现，但如果只需要长度就更简单）
const getTagsKey = () =&amp;gt; [user.tags.length, user.tags.join(&amp;#39;,&amp;#39;)].join(&amp;#39;|&amp;#39;)
// 监听这个关键值
watch(getTagsKey, (newKey, oldKey) =&amp;gt; {
  console.log(&amp;#39;tags变化前的关键值：&amp;#39;, oldKey)
  console.log(&amp;#39;tags变化后的关键值：&amp;#39;, newKey)
  if (newKey!== oldKey) {
    // 在这里处理tags变化的逻辑，不需要完整旧值也能知道变了
    console.log(&amp;#39;tags真的变了！&amp;#39;)
  }
})&lt;/pre&gt;
&lt;p&gt;这种方法的优点是性能最好，没有深拷贝；缺点是只能对比自己定义的关键部分,不能拿到完整的旧值。&lt;/p&gt;
&lt;h2&gt;还有几个Vue3 watch的小细节要注意&lt;/h2&gt;
&lt;p&gt;除了newVal和oldVal的问题，还有几个容易混淆的点,顺便提一下：&lt;/p&gt;
&lt;h3&gt;watch监听ref和reactive的区别&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;监听ref：直接传ref变量就行，比如watch(count,...)，这时候回调里的newVal和oldVal是ref.value的值；&lt;/li&gt;
&lt;li&gt;监听reactive：直接传reactive对象的话，默认相当于开了deep: true（这也是Vue3和Vue2的一个小区别），但newVal和oldVal还是同一个地址；所以监听reactive最好用getter函数，比如watch(() =&amp;gt; user.name,...)或者watch(() =&amp;gt; ({...user }),...)。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;watch的flush选项&lt;/h3&gt;
&lt;p&gt;flush选项控制watch回调的执行时机：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;flush: &#039;pre&#039;（默认）：在DOM更新&lt;strong&gt;之前&lt;/strong&gt;执行；&lt;/li&gt;
&lt;li&gt;flush: &#039;post&#039;：在DOM更新&lt;strong&gt;之后&lt;/strong&gt;执行；&lt;/li&gt;
&lt;li&gt;flush:&#039;sync&#039;：同步执行，只要数据变化就立即触发，性能最差,尽量少用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如你需要在watch回调里访问更新后的DOM，就把flush设为&#039;post&#039;。&lt;/p&gt;
&lt;h3&gt;watch的onCleanup函数&lt;/h3&gt;
&lt;p&gt;watch回调里可以接收第三个参数onCleanup，用来清除上一次执行产生的副作用，比如定时器、事件监听器、网络请求等。&lt;/p&gt;
&lt;pre class=&quot;brush:javascript;toolbar:false&quot;&gt;import { ref, watch } from &amp;#39;vue&amp;#39;
const keyword = ref(&amp;#39;&amp;#39;)
watch(keyword, (newVal, oldVal, onCleanup) =&amp;gt; {
  let timer = null
  if (newVal) {
    timer = setTimeout(() =&amp;gt; {
      console.log(&amp;#39;搜索关键词：&amp;#39;, newVal)
    }, 500)
  }
  // 清除上一次的定时器，避免频繁输入时触发多次搜索
  onCleanup(() =&amp;gt; {
    clearTimeout(timer)
  })
})&lt;/pre&gt;
&lt;p&gt;Vue3 watch的newVal和oldVal问题，核心就是&lt;strong&gt;基本类型值传递没问题，引用类型默认只监听地址，开deep后地址不变所以newVal===oldVal&lt;/strong&gt;，解决方法根据场景不同有三种：监听具体属性/元素/计算结果、手动深拷贝保存旧值、对比关键值。&lt;/p&gt;
&lt;p&gt;最后再提醒一句：深拷贝虽然能解决问题，但数据量大的时候一定要谨慎，能不用就不用，尽量用监听具体属性或对比关键值的方法，性能会好很多，如果确实需要深拷贝，也可以用更高效的库，比如fast-copy,比lodash的cloneDeep快一些。&lt;/p&gt;
&lt;p&gt;现在你应该不会再被Vue3 watch的newVal和oldVal坑到了吧？如果还有其他Vue3的问题,欢迎留言讨论哦！&lt;/p&gt;</description><pubDate>Sun, 03 May 2026 20:01:30 +0800</pubDate></item><item><title>Vue3里watch嵌套对象属性的方法有哪些？踩坑点怎么避？</title><link>https://codeqd.com/post/20260521801.html</link><description>&lt;p&gt;刚上手Vue3的朋友，尤其是从Vue2转过来的，大概率会碰到这个问题：明明写了watch监听数据，改了深层的子属性怎么没反应？要么监听触发太频繁，要么完全没触发，调试半天找不到原因，今天咱们就从核心原理到实用方法，再到常见的5个避坑点,全给掰扯清楚。&lt;/p&gt;
&lt;h2&gt;先搞懂核心：Vue3的响应式为啥和嵌套属性“有隔阂”？&lt;/h2&gt;
&lt;p&gt;在讲方法之前，得先摸透响应式的底层——这能帮你从根源上理解为啥踩坑,以后再改嵌套数据也不会慌。&lt;/p&gt;
&lt;p&gt;Vue2用的是Object.defineProperty()，它会递归遍历对象的所有层级，给每个属性都加上getter和setter，所以只要你改了子属性，Vue2能直接感知到，哪怕只是监听整个对象,默认也会触发。&lt;/p&gt;
&lt;p&gt;但Vue3不一样，用的是Proxy+Reflect的组合，Proxy只代理&lt;strong&gt;传入的那个对象本身&lt;/strong&gt;，不会自动递归代理深层的子对象或数组元素，举个简单的例子：你有个user对象，里面嵌套了address，Vue3只会给user套上Proxy，address还是普通对象，这时候你直接改user.address.city，只有city这个普通属性变了，Proxy感知不到,监听整个user的watch自然不会触发。&lt;/p&gt;
&lt;p&gt;不过别担心，Vue3也不是完全不管嵌套属性，它有个&lt;strong&gt;懒代理&lt;/strong&gt;的机制：当你第一次访问深层子属性的时候，Vue3才会给那个子属性也套上Proxy，比如先console.log(user.address)，然后再改city，这时候Proxy就会捕获到变化了——但问题是，谁会没事先打印一遍子属性再监听呢？而且有些场景下你根本不会主动访问子属性,所以懒代理解决不了所有问题。&lt;/p&gt;
&lt;h2&gt;第一个实用方法：直接监听嵌套路径，简单粗暴但高效&lt;/h2&gt;
&lt;p&gt;如果你只需要监听嵌套对象里的某一个或几个&lt;strong&gt;明确的、路径清晰的子属性&lt;/strong&gt;，直接写路径字符串是最方便的,没有多余的性能损耗。&lt;/p&gt;
&lt;h3&gt;举个具体的代码例子&lt;/h3&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;p&amp;gt;用户名：{{ user.name }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;城市：{{ user.address.city }}&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;邮编：{{ user.address.zip }}&amp;lt;/p&amp;gt;
    &amp;lt;button @click=&amp;quot;changeName&amp;quot;&amp;gt;改名字&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;changeCity&amp;quot;&amp;gt;改城市&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&amp;quot;changeZip&amp;quot;&amp;gt;改邮编&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { ref, watch } from &amp;#39;vue&amp;#39;
const user = ref({
  name: &amp;#39;张三&amp;#39;,
  address: {
    city: &amp;#39;北京&amp;#39;,
    zip: &amp;#39;100000&amp;#39;
  }
})
const changeName = () =&amp;gt; user.value.name = &amp;#39;李四&amp;#39;
const changeCity = () =&amp;gt; user.value.address.city = &amp;#39;上海&amp;#39;
const changeZip = () =&amp;gt; user.value.address.zip = &amp;#39;200000&amp;#39;
// 直接监听嵌套路径的字符串，注意要加引号
watch(() =&amp;gt; user.value.address.city, (newVal, oldVal) =&amp;gt; {
  console.log(&amp;#39;城市变了：&amp;#39;, oldVal, &amp;#39;→&amp;#39;, newVal)
})
// 也可以监听多个路径，放在数组里
watch(
  [() =&amp;gt; user.value.name, () =&amp;gt; user.value.address.zip],
  ([newName, newZip], [oldName, oldZip]) =&amp;gt; {
    console.log(&amp;#39;用户名或邮编变了&amp;#39;)
    console.log(&amp;#39;用户名：&amp;#39;, oldName, &amp;#39;→&amp;#39;, newName)
    console.log(&amp;#39;邮编：&amp;#39;, oldZip, &amp;#39;→&amp;#39;, newZip)
  }
)
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这里要注意，路径不能直接写&lt;code&gt;user.value.address.city&lt;/code&gt;，必须用&lt;strong&gt;箭头函数返回路径值&lt;/strong&gt;，或者如果你是用reactive定义的user，路径可以直接写成&lt;code&gt;() =&amp;gt; user.address.city&lt;/code&gt;，或者简化成&lt;code&gt;&#039;address.city&#039;&lt;/code&gt;——对，reactive有个小优势，单路径监听的时候可以直接传字符串属性名或者嵌套路径字符串，不用箭头函数，但数组监听还是建议用箭头函数更稳妥,统一写法不容易出错。&lt;/p&gt;
&lt;h2&gt;第二个实用方法：开启deep选项，监听整个嵌套结构的变化&lt;/h2&gt;
&lt;p&gt;如果你的嵌套对象层级很深，或者子属性非常多，一个个写路径太麻烦，这时候可以用watch的&lt;code&gt;deep: true&lt;/code&gt;选项，开启之后，不管你改嵌套对象里的哪个层级、哪个属性,watch都会触发。&lt;/p&gt;
&lt;h3&gt;开启deep的代码&lt;/h3&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h3&amp;gt;个人资料&amp;lt;/h3&amp;gt;
    &amp;lt;input v-model=&amp;quot;user.name&amp;quot; placeholder=&amp;quot;姓名&amp;quot;&amp;gt;
    &amp;lt;input v-model=&amp;quot;user.age&amp;quot; placeholder=&amp;quot;年龄&amp;quot; type=&amp;quot;number&amp;quot;&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;h4&amp;gt;工作信息&amp;lt;/h4&amp;gt;
      &amp;lt;input v-model=&amp;quot;user.job.company&amp;quot; placeholder=&amp;quot;公司&amp;quot;&amp;gt;
      &amp;lt;input v-model=&amp;quot;user.job.title&amp;quot; placeholder=&amp;quot;职位&amp;quot;&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;h5&amp;gt;同事列表&amp;lt;/h5&amp;gt;
        &amp;lt;input v-for=&amp;quot;(col, idx) in user.job.colleagues&amp;quot; :key=&amp;quot;idx&amp;quot; v-model=&amp;quot;user.job.colleagues[idx]&amp;quot;&amp;gt;
        &amp;lt;button @click=&amp;quot;addColleague&amp;quot;&amp;gt;加同事&amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;p v-if=&amp;quot;lastChange&amp;quot;&amp;gt;最后修改的内容：{{ lastChange }}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { reactive, watch } from &amp;#39;vue&amp;#39;
const user = reactive({
  name: &amp;#39;王五&amp;#39;,
  age: 28,
  job: {
    company: &amp;#39;字节跳动&amp;#39;, &amp;#39;前端开发工程师&amp;#39;,
    colleagues: [&amp;#39;赵六&amp;#39;, &amp;#39;孙七&amp;#39;]
  }
})
const lastChange = ref(&amp;#39;&amp;#39;)
const addColleague = () =&amp;gt; user.job.colleagues.push(&amp;#39;周八&amp;#39;)
// 开启deep: true监听整个user对象
watch(user, (newUser) =&amp;gt; {
  // 这里newUser和oldUser是同一个引用！！！等下避坑点会讲
  console.log(&amp;#39;user的某个地方变了&amp;#39;)
  // 这里我们可以用JSON.stringify比较一下，看看大概改了啥
  // 不过更精准的比较建议用lodash的isEqual，但不要滥用，会影响性能
  const lastModifiedKey = findLastModifiedKey(user, initialUser) // 这里的initialUser要自己存一份深拷贝的初始值，避坑点也会提
  lastChange.value = lastModifiedKey
}, { deep: true })
// 这里的findLastModifiedKey只是个示例函数，实际开发根据需求写
const initialUser = JSON.parse(JSON.stringify(user))
function findLastModifiedKey(newObj, oldObj, path = &amp;#39;&amp;#39;) {
  for (const key in newObj) {
    const currentPath = path ? `${path}.${key}` : key
    if (typeof newObj[key] === &amp;#39;object&amp;#39; &amp;amp;&amp;amp; newObj[key] !== null) {
      const subPath = findLastModifiedKey(newObj[key], oldObj[key], currentPath)
      if (subPath) return subPath
    } else if (newObj[key] !== oldObj[key]) {
      return currentPath
    }
  }
  return null
}
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这个方法覆盖范围最广，但缺点也很明显——&lt;strong&gt;性能开销大&lt;/strong&gt;，因为开启deep之后，Vue3会递归遍历整个嵌套结构的Proxy，每次有变化都会重新触发监听回调，哪怕你只改了一个无关紧要的子属性，所以如果你的嵌套对象非常大（比如超过100个属性，或者层级超过5层），尽量不要随便开deep,还是优先用路径监听。&lt;/p&gt;
&lt;h2&gt;第三个实用方法：拆分小的响应式对象，细粒度控制监听&lt;/h2&gt;
&lt;p&gt;这个方法其实是结合了前两个的优点，既避免了一个个写长路径的麻烦,又不会像deep那样有太大的性能损耗。&lt;/p&gt;
&lt;h3&gt;拆分的思路&lt;/h3&gt;
&lt;p&gt;把原来的大嵌套对象，拆成几个独立的、逻辑上紧密相关的小响应式对象，然后分别监听，比如刚才的个人资料，可以拆成userBase（姓名、年龄）、userJob（公司、职位）、userColleagues（同事数组）三个reactive对象。&lt;/p&gt;
&lt;h3&gt;拆分后的代码示例&lt;/h3&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h3&amp;gt;个人资料&amp;lt;/h3&amp;gt;
    &amp;lt;input v-model=&amp;quot;userBase.name&amp;quot; placeholder=&amp;quot;姓名&amp;quot;&amp;gt;
    &amp;lt;input v-model=&amp;quot;userBase.age&amp;quot; placeholder=&amp;quot;年龄&amp;quot; type=&amp;quot;number&amp;quot;&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;h4&amp;gt;工作信息&amp;lt;/h4&amp;gt;
      &amp;lt;input v-model=&amp;quot;userJob.company&amp;quot; placeholder=&amp;quot;公司&amp;quot;&amp;gt;
      &amp;lt;input v-model=&amp;quot;userJob.title&amp;quot; placeholder=&amp;quot;职位&amp;quot;&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;h5&amp;gt;同事列表&amp;lt;/h5&amp;gt;
        &amp;lt;input v-for=&amp;quot;(col, idx) in userColleagues&amp;quot; :key=&amp;quot;idx&amp;quot; v-model=&amp;quot;userColleagues[idx]&amp;quot;&amp;gt;
        &amp;lt;button @click=&amp;quot;addColleague&amp;quot;&amp;gt;加同事&amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { reactive, watch } from &amp;#39;vue&amp;#39;
// 拆分后的小响应式对象
const userBase = reactive({ name: &amp;#39;王五&amp;#39;, age: 28 })
const userJob = reactive({ company: &amp;#39;字节跳动&amp;#39;, title: &amp;#39;前端开发工程师&amp;#39; })
const userColleagues = reactive([&amp;#39;赵六&amp;#39;, &amp;#39;孙七&amp;#39;])
const addColleague = () =&amp;gt; userColleagues.push(&amp;#39;周八&amp;#39;)
// 只监听userJob的变化，不用开deep
watch(userJob, (newJob) =&amp;gt; {
  console.log(&amp;#39;工作信息变了：&amp;#39;, newJob.company, newJob.title)
})
// 监听数组的变化，默认只能监听到数组的方法（push、pop、shift、unshift、splice、sort、reverse）和长度变化，
// 如果要监听数组元素的直接修改（比如userColleagues[0] = &amp;#39;吴九&amp;#39;），可以开deep，但数组元素如果是基本类型的话，开deep也不会有太大性能问题
watch(userColleagues, (newCols) =&amp;gt; {
  console.log(&amp;#39;同事列表变了：&amp;#39;, newCols)
}, { deep: true })
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;这个拆分的思路非常推荐，尤其是在做大型项目的时候，既能提高代码的可读性和可维护性，又能精准控制监听的范围,避免不必要的性能浪费。&lt;/p&gt;
&lt;h2&gt;第四个补充方法：用watchEffect自动追踪依赖&lt;/h2&gt;
&lt;p&gt;watchEffect和watch不一样，它不需要你手动指定监听的目标，只要你在回调函数里用到了某个响应式数据（包括嵌套属性），它就会自动追踪,一旦这些数据变化就会触发回调。&lt;/p&gt;
&lt;h3&gt;watchEffect的适用场景&lt;/h3&gt;
&lt;p&gt;比如你需要根据多个嵌套属性做一些复杂的计算或者DOM操作，但不想一个个写路径，也不想开deep怕影响性能——这时候watchEffect就派上用场了。&lt;/p&gt;
&lt;h3&gt;watchEffect的代码示例&lt;/h3&gt;
&lt;pre class=&quot;brush:html;toolbar:false&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;input v-model=&amp;quot;product.price&amp;quot; placeholder=&amp;quot;价格&amp;quot; type=&amp;quot;number&amp;quot;&amp;gt;
    &amp;lt;input v-model=&amp;quot;product.discount&amp;quot; placeholder=&amp;quot;折扣（0-1）&amp;quot; type=&amp;quot;number&amp;quot;&amp;gt;
    &amp;lt;input v-model=&amp;quot;product.shipping.fee&amp;quot; placeholder=&amp;quot;运费&amp;quot; type=&amp;quot;number&amp;quot;&amp;gt;
    &amp;lt;p&amp;gt;最终价格：{{ finalPrice }}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup&amp;gt;
import { reactive, ref, watchEffect } from &amp;#39;vue&amp;#39;
const product = reactive({
  price: 100,
  discount: 0.8,
  shipping: {
    fee: 10
  }
})
const finalPrice = ref(0)
// watchEffect自动追踪product.price、product.discount、product.shipping.fee这三个属性
watchEffect(() =&amp;gt; {
  finalPrice.value = product.price * product.discount + product.shipping.fee
  console.log(&amp;#39;自动计算最终价格：&amp;#39;, finalPrice.value)
})
&amp;lt;/script&amp;gt;&lt;/pre&gt;
&lt;p&gt;watchEffect的优点是自动追踪，代码简洁；缺点是&lt;strong&gt;没有oldValue&lt;/strong&gt;，而且初始化的时候会自动执行一次（当然你可以用flush选项或者配置来改变执行时机，但默认是立即执行的），所以如果你的场景需要用到oldValue，或者不想初始化的时候就触发,还是用watch更合适。&lt;/p&gt;
&lt;h2&gt;最容易踩的5个坑，90%的人都中过&lt;/h2&gt;
&lt;p&gt;刚才讲方法的时候也提到了一些避坑点，现在我们集中整理一下,免得以后再踩。&lt;/p&gt;
&lt;h3&gt;坑1：直接监听ref定义的嵌套对象的.value&lt;/h3&gt;
&lt;p&gt;虽然有时候直接监听&lt;code&gt;user.value&lt;/code&gt;（ref定义的）也能触发，但这其实是依赖于懒代理的——如果你没有先访问过深层子属性，直接修改子属性就不会触发，正确的做法是要么用箭头函数返回整个ref对象的value（reactive可以直接传对象），要么开启deep,要么用路径监听。&lt;/p&gt;
&lt;h3&gt;坑2：开启deep后，oldValue和newValue是同一个引用&lt;/h3&gt;
&lt;p&gt;这个是从Vue2就有的问题，Vue3也没改，因为Proxy返回的是同一个对象的引用，所以不管你开不开deep，只要监听的是整个对象，oldValue和newValue都是一样的，如果你需要比较修改前后的差异，必须自己&lt;strong&gt;提前存一份深拷贝的初始值&lt;/strong&gt;，或者在回调函数里对newValue做深拷贝,然后和上次存的深拷贝值比较。&lt;/p&gt;
&lt;p&gt;深拷贝的话，简单场景可以用&lt;code&gt;JSON.parse(JSON.stringify())&lt;/code&gt;，但这个方法有局限性：不能处理函数、Symbol、循环引用、Date对象（会变成字符串）、RegExp对象（会变成空对象）等，如果是复杂场景，建议用lodash的&lt;code&gt;cloneDeep&lt;/code&gt;,或者自己写一个深拷贝函数。&lt;/p&gt;
&lt;h3&gt;坑3：监听数组元素的直接修改没反应&lt;/h3&gt;
&lt;p&gt;默认情况下，Vue3的watch只能监听到数组的&lt;strong&gt;变异方法&lt;/strong&gt;（push、pop、shift、unshift、splice、sort、reverse）和&lt;strong&gt;长度变化&lt;/strong&gt;，如果直接修改数组的某个元素（比如&lt;code&gt;arr[0] = 123&lt;/code&gt;），或者修改数组的某个索引（比如&lt;code&gt;arr.length = 5&lt;/code&gt;，不过这个长度变化默认是可以监听到的）,默认不会触发。&lt;/p&gt;
&lt;p&gt;解决方法有三个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用变异方法代替直接修改，比如&lt;code&gt;arr.splice(0, 1, 123)&lt;/code&gt;代替&lt;code&gt;arr[0] = 123&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;开启deep选项；&lt;/li&gt;
&lt;li&gt;直接监听数组的某个索引路径（比如&lt;code&gt;() =&amp;gt; arr[0]&lt;/code&gt;）。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;坑4：路径监听的时候写错了属性名或者路径&lt;/h3&gt;
&lt;p&gt;这个是低级错误，但很多人都会犯，比如把&lt;code&gt;user.address.city&lt;/code&gt;写成了&lt;code&gt;user.addr.city&lt;/code&gt;，或者少写了一层路径，因为Vue3的路径监听如果找不到属性，会返回undefined，这时候回调函数只会在初始化的时候触发一次（如果配置了immediate: true的话），以后都不会触发，所以写路径的时候一定要仔细检查,或者用TypeScript的类型提示来避免这个问题。&lt;/p&gt;
&lt;h3&gt;坑5：滥用deep选项导致性能问题&lt;/h3&gt;
&lt;p&gt;刚才讲过，开启deep之后，Vue3会递归遍历整个嵌套结构的Proxy，每次有变化都会重新触发监听回调，如果你的嵌套对象非常大，比如后台返回的一个有1000个数据项的数组，每个数据项又有10个属性，这时候开deep会导致页面卡顿,甚至崩溃。&lt;/p&gt;
&lt;p&gt;所以一定要记住：&lt;strong&gt;优先用路径监听，其次用拆分小响应式对象的方法，最后迫不得已再开deep&lt;/strong&gt;，开deep的时候，尽量把监听的范围缩小到最小的嵌套结构,不要监听整个大对象。&lt;/p&gt;
&lt;h2&gt;选哪个方法？&lt;/h2&gt;
&lt;p&gt;根据你的具体场景来选：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;只监听1-3个明确的、路径清晰的子属性&lt;/strong&gt;：直接写路径字符串（箭头函数或者reactive的简化路径）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;嵌套对象层级不深、属性不多、需要监听所有变化&lt;/strong&gt;：开启deep选项,但要注意oldValue的问题；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;大型项目、嵌套对象逻辑清晰可拆分&lt;/strong&gt;：拆分小的响应式对象,分别监听；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要根据多个嵌套属性做自动计算或操作、不需要oldValue&lt;/strong&gt;：用watchEffect。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;好了，今天关于Vue3 watch嵌套属性的内容就讲完了，如果还有什么疑问，可以在评论区留言,我们一起讨论。&lt;/p&gt;</description><pubDate>Sun, 03 May 2026 14:01:22 +0800</pubDate></item></channel></rss>