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

Vue3 watch偶尔失效是怎么回事?今天就给大家理清楚踩过的90%以上的坑

terry 55分钟前 阅读数 21 #Vue

最近几个前端交流群里,每天至少有三四个新人问Vue3的watch失效问题——明明逻辑看起来没问题,数据也变了,可就是不触发回调?其实这事儿不是watch坏了,大概率是你没摸透Vue3响应式的本质,或者用watch的姿势踩了小细节,别慌,接下来咱们就从最基础的响应式盲区讲起,把所有常见原因拆透,还会给大家一些避坑和调试的小技巧。

第一个大坑:监听的根本不是响应式数据

很多刚从Vue2转过来的同学,或者刚学Vue3不久的新手,最容易犯的第一个错就是——把普通JS变量、普通对象的属性、没有被解构赋值正确处理的ref/reactive值喂给watch,那当然触发不了啊!

先回忆下Vue3的响应式原理:只有被ref()、reactive()、computed()(computed本质也是个ref)、或者用toRefs/toRef包过的变量才是响应式的,普通的let a = 1,你改一万次a=2,Vue也不知道。

举个具体的踩坑例子:

import { watch, reactive } from 'vue';
setup() {
  // 普通对象
  const userObj = { name: '张三', age: 18 };
  // reactive对象没问题,但接下来看
  const reactiveUser = reactive({ name: '李四', age: 20 });
  // 这里直接把普通对象userObj丢进去监听,没用
  watch(userObj, (newVal) => {
    console.log('普通对象变了', newVal); // 永远不会打印
  });
  // 这里如果是直接用.访问reactiveUser的单个属性,比如watch(reactiveUser.age, ...),
  // reactiveUser.age其实是个普通数字,也不行!
  watch(reactiveUser.age, (newVal) => {
    console.log('直接用.取reactive属性变了', newVal); // 也不会触发
  });
  // 测试修改
  setTimeout(() => {
    userObj.age = 25;
    reactiveUser.age = 22;
  }, 1000);
}

那单个reactive属性怎么监听?两种办法:要么用函数返回值(推荐日常用),要么用toRef/toRefs把属性转成ref。

函数返回值监听单个reactive属性,90%场景都适用

这个是官方最推荐的监听单个reactive属性的方式,简单粗暴,逻辑清晰,其实就是把你要监听的内容,写在watch的第一个参数的箭头函数里,这样Vue会自动追踪箭头函数里用到的所有响应式数据,只要有一个变,就触发回调。

刚才的例子改成这样就对了:

watch(
  () => reactiveUser.age,
  (newVal, oldVal) => {
    console.log('函数返回值监听单个reactive属性变了', newVal, oldVal); // 22,20 会打印
  }
);

除了单个属性,这个方法还可以监听多个属性的组合,

watch(
  () => `${reactiveUser.name}-${reactiveUser.age}`,
  (newVal) => {
    console.log('姓名或年龄变了,新组合是', newVal);
  }
);

这种组合监听的方式,有时候比监听整个对象加immediate更精准,能避免不必要的回调触发。

用toRef/toRefs处理单个/多个reactive属性,适合需要解耦的场景

如果你需要把reactive对象的某个属性单独抽出来用,或者传给子组件,还想保持响应式,那toRef/toRefs就派上用场了,当然用它们监听单个属性也没问题。

toRef是用来转单个属性的,不会创建新的属性(原对象没有的话会创建但不触发响应式原对象的响应式?不对不对,查过权威资料的,原对象没有的属性用toRef创建,修改toRef返回的ref.value,原对象会有这个属性,而且如果原对象之后变成响应式的,这个属性也会响应式;如果原对象本身就是响应式的,不管有没有这个属性,修改toRef.value都会同步到原对象),哦对了,不管原对象有没有,只要原对象是响应式的,或者toRef是基于reactive/toRefs的结果创建的,那都是响应式的。

toRefs是转整个reactive对象的所有属性的,转出来的都是ref,解耦的时候用,比如setup里return的时候经常用toRefs包裹一下,方便模板里不用加.value。

那刚才的单个属性监听用toRef怎么改?

import { watch, reactive, toRef } from 'vue';
setup() {
  const reactiveUser = reactive({ name: '李四', age: 20 });
  const ageRef = toRef(reactiveUser, 'age');
  watch(ageRef, (newVal) => {
    console.log('toRef监听单个属性变了', newVal); // 22,20 会打印
  });
  setTimeout(() => {
    ageRef.value = 22; // 或者直接改reactiveUser.age =22,都可以,因为是双向绑定的
  }, 1000);
}

第二个大坑:监听的是数组/对象的浅层变化?还是深层?

Vue2的watch默认是浅层监听的,想要深层要加deep:true,Vue3其实也差不多,但有个小区别:Vue3的watch监听reactive对象本身的时候,默认是深层监听的!哦对,很多同学搞混这个,导致有时候会觉得“Vue3怎么有时候deep有时候不deep?”

先分情况说清楚:

  1. 监听ref对象本身(不是.value,是直接传ref变量名):默认浅层监听,也就是说,如果你把ref.value整个替换成新对象/新数组,会触发;但如果只改ref.value里的某个属性/数组的某个元素,默认不会触发,必须加deep:true。

    import { watch, ref } from 'vue';
    setup() {
      const userRef = ref({ name: '王五', age: 25 });
      const listRef = ref([1,2,3]);
      // 直接传userRef,默认浅层,替换整个对象才触发
      watch(userRef, (newVal) => {
        console.log('浅层监听ref对象,替换整个了', newVal);
      });
      // 加了deep:true,改属性/元素也触发
      watch(userRef, (newVal) => {
        console.log('深层监听ref对象,内容变了', newVal);
      }, { deep: true });
      // 监听listRef的.value变化(不管是替换还是改元素?不,直接传listRef.value如果是数组普通元素?哦不对,listRef.value是普通数组,刚才第一个大坑说过普通数组不能直接传,应该用函数返回值或者直接传listRef加deep)
      // 哦重新举listRef的例子:
      // 直接传listRef,不加deep,只有替换整个数组(比如listRef.value = [4,5,6])才触发
      watch(listRef, (newVal) => {
        console.log('浅层监听ref数组,替换整个了', newVal);
      });
      // 直接传listRef加deep,替换或者改元素(listRef.value[0] = 10)都触发
      watch(listRef, (newVal) => {
        console.log('深层监听ref数组,内容变了', newVal);
      }, { deep: true });
      // 测试
      setTimeout(() => {
        userRef.value.age = 26; // 只有第二个watch触发
        listRef.value[0] = 10; // 只有第四个watch触发
        setTimeout(() => {
          userRef.value = { name: '赵六', age: 30 }; // 第一第二个都触发
          listRef.value = [7,8,9]; // 第三第四个都触发
        }, 500);
      }, 1000);
    }
  2. 监听reactive对象本身:默认深层监听,不管加不加deep:true都是深层,加了也没用,但要注意,刚才第一个大坑说过,不能直接用.取单个属性,要用函数返回值或者toRef。

    import { watch, reactive } from 'vue';
    setup() {
      const reactiveUser = reactive({ name: '钱七', info: { city: '北京', job: '前端' } });
      // 直接传reactiveUser,默认深层,改info.job也触发
      watch(reactiveUser, (newVal) => {
        console.log('直接传reactive对象,深层变化了', newVal);
      });
      setTimeout(() => {
        reactiveUser.info.job = '全栈'; // 会触发
      }, 1000);
    }
  3. 监听函数返回值的数组/对象:默认也是浅层监听!这个很多同学也容易忘,刚才第一个大坑里举的单个属性的函数返回值没问题,但如果函数返回的是整个数组/对象,那只有替换整个的时候才触发,改里面的内容必须加deep:true。

    import { watch, reactive } from 'vue';
    setup() {
      const reactiveUser = reactive({ name: '孙八', info: { city: '上海', job: '产品' } });
      // 函数返回整个info对象,默认浅层,替换reactiveUser.info才触发
      watch(
        () => reactiveUser.info,
        (newVal) => {
          console.log('函数返回整个info对象,浅层变化了', newVal);
        }
      );
      // 加deep:true,改info.city也触发
      watch(
        () => reactiveUser.info,
        (newVal) => {
          console.log('函数返回整个info对象,深层变化了', newVal);
        },
        { deep: true }
      );
      setTimeout(() => {
        reactiveUser.info.city = '广州'; // 只有第二个watch触发
        setTimeout(() => {
          reactiveUser.info = { city: '深圳', job: '设计' }; // 第一第二个都触发
        }, 500);
      }, 1000);
    }

第三个大坑:替换了整个ref/reactive的根,但没注意oldVal的问题

其实这个不算严格的“失效”,但很多同学会因为oldVal不对觉得出问题了,或者有时候结合其他逻辑会踩坑。

先讲旧值的问题:

  1. 监听ref对象本身(不管浅层深层)或者函数返回值的对象/数组(浅层的时候替换整个):oldVal是之前的整个值,没问题。
  2. 监听reactive对象本身或者函数返回值的对象/数组加deep:true的时候:oldVal和newVal是同一个引用!也就是说,你打印newVal和oldVal,看到的内容是一样的(都是最新的),因为它们指向内存里的同一个地址,这是Vue3的一个设计,因为深层监听会遍历整个对象/数组,保存旧值的副本太消耗性能了,所以直接不给旧的引用副本。

那如果我需要拿到旧值怎么办?可以用computed先把要监听的内容转成一个计算属性,然后监听这个计算属性,这样每次computed重新计算的时候会返回一个新的引用(如果是对象/数组的话,要在computed里手动返回新对象/新数组),这样watch就能拿到正确的oldVal了。

举个需要旧值的例子,比如要对比数组变化前后的差集:

import { watch, ref, computed } from 'vue';
setup() {
  const listRef = ref([1,2,3,4]);
  // 用computed手动返回新数组,这样每次listRef变化(不管是替换还是改元素)都会返回新引用
  const listComputed = computed(() => [...listRef.value]);
  watch(listComputed, (newVal, oldVal) => {
    console.log('新数组', newVal); // [2,3,4,5]
    console.log('旧数组', oldVal); // [1,2,3,4]
    const added = newVal.filter(x => !oldVal.includes(x));
    const removed = oldVal.filter(x => !newVal.includes(x));
    console.log('新增的元素', added); // [5]
    console.log('删除的元素', removed); // [1]
  });
  setTimeout(() => {
    // 随便改,不管是替换还是改元素,listComputed都会返回新数组
    listRef.value.push(5);
    listRef.value.shift();
  }, 1000);
}

第四个大坑:immediate和flush的时机不对

很多同学会用immediate:true来让watch在组件初始化的时候就执行一次,但有时候会因为flush的时机问题,导致拿到的DOM不对或者其他逻辑出错,甚至看起来像失效?其实失效的情况比较少,但时机不对会产生很多误解。

先讲flush的三个选项:

  1. 'pre'(默认值):在组件更新DOM之前执行,也就是说,这时候你拿到的是最新的数据,但DOM还是旧的,如果要在回调里操作DOM,会拿到旧的。
  2. 'post':在组件更新DOM之后执行,这时候数据和DOM都是最新的,适合操作DOM。
  3. 'sync':同步执行,也就是数据一变化就立即执行,不管什么生命周期,性能可能会差一点,除非有特殊需求(比如要在某个同步逻辑里用到watch的回调结果),否则不推荐用。

举个flush时机的例子:

<template>
  <div ref="countDiv">{{ count }}</div>
  <button @click="count++">加1</button>
</template>
<script setup>
import { watch, ref, nextTick } from 'vue';
const count = ref(0);
const countDiv = ref(null);
// 默认pre,DOM更新前执行
watch(count, (newVal) => {
  console.log('pre flush div的内容', countDiv.value?.textContent); // 加1前的内容,比如第一次加1会打印0
});
// post,DOM更新后执行
watch(count, (newVal) => {
  console.log('post flush div的内容', countDiv.value?.textContent); // 加1后的内容,比如第一次加1会打印1
}, { flush: 'post' });
// 或者用nextTick也可以实现post的效果,但官方推荐直接用flush:'post'
watch(count, async (newVal) => {
  await nextTick();
  console.log('nextTick div的内容', countDiv.value?.textContent); // 也是1
});
</script>

那immediate和flush结合的时候呢?immediate:true会在组件挂载前执行一次(如果是flush:'pre'或者'sync'),这时候DOM还没挂载,countDiv.value是null;如果是flush:'post',immediate:true会在组件挂载后、第一次DOM更新前?不对不对,查过准确的:immediate结合flush:'post'的时候,第一次立即执行是在组件挂载完成之后,其他时候还是在DOM更新之后,哦对,这样就避免了immediate的时候拿到null的问题。

比如刚才的例子加上immediate:true:

// pre + immediate,第一次执行在挂载前,countDiv是null
watch(count, (newVal) => {
  console.log('pre+immediate flush div的内容', countDiv.value?.textContent); // 第一次null,之后0、1...
}, { immediate: true });
// post + immediate,第一次执行在挂载后,countDiv有值了,是0
watch(count, (newVal) => {
  console.log('post+immediate flush div的内容', countDiv.value?.textContent); // 第一次0,之后1、2...
}, { immediate: true, flush: 'post' });

第五个大坑:监听的是computed的getter依赖没变化

computed是惰性计算的,只有当它的getter函数里用到的响应式数据变化时,它才会重新计算,那如果你监听的是一个computed,但它的依赖没变化,不管你怎么手动调用computed.value,它都不会重新计算,watch当然也不会触发。

举个例子:

import { watch, ref, computed } from 'vue';
setup() {
  const a = ref(1);
  const b = ref(2);
  // 这个computed的依赖只有a,没有b
  const sumComputed = computed(() => a.value + 10);
  watch(sumComputed, (newVal) => {
    console.log('sumComputed变了', newVal);
  });
  setTimeout(() => {
    b.value = 20; // sumComputed的依赖没变化,不会重新计算,watch不触发
    setTimeout(() => {
      a.value = 5; // 依赖a变了,sumComputed重新计算,watch触发,打印15
    }, 500);
  }, 1000);
}

这个其实不算坑,但很多新手会不小心搞错computed的依赖,导致看起来watch失效了。

第六个大坑:组件卸载后watch还在运行?或者被意外清理了?

哦这个刚好反过来,不是失效,是有时候不该运行还在运行,但也有时候会被意外清理导致之后失效,不过先讲清理的问题,因为题目是watch not working。

Vue3的setup里创建的watch/watchEffect,会在组件卸载的时候自动清理,不需要手动cleanup,但如果你是在异步操作里创建的watch/watchEffect,那它就不会被自动清理了!比如在setTimeout、Promise.then、或者第三方库的回调里创建的,这时候组件卸载后,这个watch还会继续监听数据变化,可能会导致内存泄漏或者其他bug,但如果是之后数据变化触发的时候组件已经卸载,但Vue还会执行回调吗?好像会?查过的,异步创建的watch不会绑定到当前组件的生命周期,所以组件卸载后不会自动停止,除非你手动调用stop()。

那意外清理是什么情况?比如你把watch的返回值(stop函数)存起来,然后不小心调用了stop(),那之后数据再变化,watch就不会触发了。

举个意外清理的例子:

import { watch, ref } from 'vue';
setup() {
  const count = ref(0);
  const stopWatch = watch(count, (newVal) => {
    console.log('count变了', newVal);
  });
  setTimeout(() => {
    count.value = 1; // 触发,打印1
    // 不小心调用了stop
    stopWatch();
    setTimeout(() => {
      count.value = 2; // 不会触发,因为已经停止了
    }, 500);
  }, 1000);
}

第七个大坑:监听的是props的属性,但直接解构赋值了props

很多同学喜欢在setup里直接解构props,比如const { name, age } = defineProps(['name', 'age']),这样看起来方便,但其实name和age已经变成普通变量了!失去了响应式!那之后监听name或者age,当然不会触发。

那props怎么保持响应式?要么用函数返回值监听,要么用toRefs/toRef包一下。

举个踩坑例子和正确例子:

<script setup>
import { watch, defineProps, toRefs } from 'vue';
// 踩坑:直接解构props,name/age变成普通变量
const props = defineProps(['name', 'age']);
const { name: badName, age: badAge } = props;
// 监听badName,没用
watch(badName, (newVal) => {
  console.log('直接解构的badName变了', newVal); // 永远不会打印
});
// 正确1:函数返回值监听
watch(
  () => props.name,
  (newVal) => {
    console.log('函数返回值监听props.name变了', newVal); // 会触发
  }
);
// 正确2:toRefs包一下
const { name: goodName, age: goodAge } = toRefs(props);
watch(goodName, (newVal) => {
  console.log('toRefs的goodName变了', newVal); // 会触发
});
</script>

这里要注意,toRefs包裹props的时候,如果某个prop是可选的(也就是父组件没传),那toRefs返回的ref.value是undefined,但还是响应式的,父组件之后传了这个prop,它会自动更新。

最后给大家一个调试Vue3 watch失效的万能步骤

如果你的watch又失效了,别慌,按照这个步骤一步一步查:

  1. 先确认监听的是不是响应式数据:把你要监听的变量(或者函数返回值里用到的变量)打印出来,看看有没有__v_isRef或者__v_isReactive的标志(浏览器控制台里展开对象就能看到,Vue3加的内部标志)。
  2. 再确认监听的是浅层还是深层:如果你要监听的是数组/对象的内容变化(不是替换整个),看看有没有加deep:true(除非是直接传reactive对象本身)。
  3. 再确认immediate和flush的时机:如果是初始化的时候没执行,看看有没有加immediate:true;如果是操作DOM不对,看看有没有加flush:'post'或者用nextTick。
  4. 再确认computed的依赖:如果监听的是computed,看看它的getter里有没有用到你修改的那个响应式数据。
  5. 再确认有没有被意外停止:看看有没有调用过watch的返回值stop函数。
  6. 再确认是不是异步创建的watch:如果是异步创建的,看看有没有绑定到组件的生命周期(可以用getCurrentInstance()拿到实例,然后把stop函数存到instance.unmounted里手动清理,不过日常还是尽量不要在异步里创建watch)。

其实Vue3的watch比Vue2的强大很多,但也因为响应式原理的变化,多了一些需要注意的细节,只要摸透了这些细节,踩过一次坑之后,之后就不会再犯了。

哦对了,再补充一个小知识点:Vue3还有一个watchEffect,它不需要明确指定监听的数据源,会自动追踪回调函数里用到的所有响应式数据,有时候比watch更方便,比如不需要immediate:true,它默认就会在初始化的时候执行一次,不过watchEffect不能拿到旧值,这个要注意,大家可以根据自己的需求选择用watch还是watchEffect。

版权声明

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

热门