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

Vue3 watch once怎么用?它和watchEffect、watch的停止监听有啥区别?

terry 1小时前 阅读数 23 #Vue

最近刚上手Vue3组合式API的前端小白,应该都在ref、reactive的响应式坑里摸爬滚打过,顺带着接触到了watch和watchEffect这两个监听工具,但很少有人第一时间注意到watch once——哦不对,准确说Vue3组合式API里没有单独叫watchOnce的全局函数,它是watch和watchEffect里隐藏的小彩蛋?先别急着划走,今天咱们就把这个话题聊透,从“到底有没有watch once”这个看似“打脸”的开场,一步步到应用场景、手写简化版实现,最后给你一份避坑指南,看完绝对能在代码里少走弯路。

Vue3组合式API里真的没有单独的watchOnce吗?

有这个疑问太正常了!毕竟翻遍Vue3的官方核心文档(组合式API > 响应式API > watch/watchEffect那几页),都没出现过“watchOnce”作为独立API的介绍,不过别急,我刚接触的时候也以为漏看了版本,直到做项目需要监听某个状态只在第一次变化时触发一次回调才发现:Vue3把一次性监听的能力,分别放在了watch和watchEffect的配置项里——就是那个叫once的布尔值属性。

对,你没记错,就是这么简单粗暴但好用!只要给watch的第三个参数配置对象加个once: true,或者给watchEffect的第二个参数配置对象加once: true,监听函数就会在第一次满足触发条件时执行,执行完自动销毁监听器,完全不用你手动调用返回的stop函数了。

那Vue3 watch(加once)的具体用法是什么?

先给你举个最基础的例子,比如你有一个搜索框,用户输入完第一个字符后,自动弹出一个新手引导,告诉用户“这里可以输入模糊关键词哦”,之后不管用户再输入多少字符,或者清空再输入,都不会再弹了——这个场景简直就是为加了once的watch量身定做的。

咱们用ref来写个简单的搜索状态:

import { ref, watch } from 'vue';
export default {
  setup() {
    const searchInput = ref('');
    // 新手引导状态,默认不显示
    const showGuide = ref(false);
    // 只监听searchInput第一次变化的watch
    watch(
      // 监听源,这里是单个ref
      searchInput,
      // 回调函数,只有第一次searchInput从初始值''变成其他值时执行
      (newVal, oldVal) => {
        if (newVal.length > 0 && oldVal.length === 0) {
          showGuide.value = true;
          // 新手引导3秒后自动消失
          setTimeout(() => {
            showGuide.value = false;
          }, 3000);
        }
      },
      // 配置项!重点在这里
      {
        once: true,
        // 要不要加immediate?看你的需求
        immediate: false // 这里是默认值,不加也行
      }
    );
    return {
      searchInput,
      showGuide
    };
  }
};

哦对了,刚才提到immediate,这里必须插一句:加了once之后,immediate的逻辑还是不变的——如果设置immediate: true,那初始化组件时就会执行一次回调,执行完直接销毁监听器,相当于“只执行初始化那次监听,后续变化不管”;如果设置immediate: false(默认),那只有第一次监听源发生有效变化时才执行,然后销毁。

刚才的例子用的是单个ref作为监听源,那如果是reactive的属性、多个监听源,加once的用法会不会变?答案是完全不会,watch加once的监听规则和普通watch一模一样,只是多了个“执行一次就销毁”的限制。

比如监听reactive里的userName和age这两个属性的变化,只在第一次两者同时或者其中一个变化时弹出“您的个人信息有更新,请注意查看”:

import { reactive, watch } from 'vue';
export default {
  setup() {
    const userInfo = reactive({
      userName: '小明',
      age: 20
    });
    const showUpdateTip = ref(false);
    watch(
      // 监听源可以是数组
      [() => userInfo.userName, () => userInfo.age],
      // 回调的newVal和oldVal也是数组,和监听源一一对应
      ([newName, newAge], [oldName, oldAge]) => {
        console.log('userName从', oldName, '变成', newName);
        console.log('age从', oldAge, '变成', newAge);
        showUpdateTip.value = true;
        setTimeout(() => {
          showUpdateTip.value = false;
        }, 2000);
      },
      {
        once: true,
        // 要不要deep?如果userInfo是嵌套对象的话需要,但这里不需要
        deep: false
      }
    );
    return {
      userInfo,
      showUpdateTip
    };
  }
};

这里再补个小细节:如果监听的是reactive的整个对象,默认就是deep监听(也就是嵌套属性变化也会触发),加once之后也是一样的,第一次整个对象的任何嵌套属性变化,都会执行回调然后销毁。

watchEffect加once又有什么不一样?

说完了watch加once,再来聊聊watchEffect加once,首先得回忆一下watch和watchEffect的核心区别:watch是懒执行(默认初始化不执行,除非加immediate)、显式指定监听源能拿到newVal和oldVal;watchEffect是立即执行(不管加不加once都是先初始化执行一次?不对不对,等下仔细说!)、隐式追踪监听源(回调里用到了哪些响应式数据,就自动监听哪些)、拿不到oldVal

那加上once之后呢?这里我之前踩过坑!先给你看我第一次写错的代码:

import { ref, watchEffect } from 'vue';
export default {
  setup() {
    const count = ref(0);
    // 错误写法!!!
    watchEffect(
      () => {
        console.log('count现在是', count.value);
      },
      {
        once: true
      }
    );
    // 点击按钮让count加1
    const increment = () => {
      count.value++;
    };
    return {
      count,
      increment
    };
  }
};

你猜这段代码的执行结果是什么?是不是以为“点击一次按钮,count变成1,控制台打印一次1,然后销毁”?才不是!实际执行结果是:组件刚挂载,控制台就打印了count现在是0,然后监听器直接销毁了,点击按钮完全没反应

哦豁,原来加了once的watchEffect,会先遵循watchEffect的默认规则——立即执行一次初始化回调,然后因为once的限制,执行完这次就销毁,根本不会等后续的响应式数据变化!那如果我想让watchEffect只在后续响应式数据第一次变化时执行一次,不要初始化那次,怎么办?

别急,刚才提到了手写简化版实现,咱们后面再说,先记住加once的watchEffect的标准行为:先立即执行一次回调,隐式追踪完用到的响应式数据,然后直接销毁监听器,后续这些数据再怎么变都不会触发了。

那加once的watchEffect有没有应用场景?当然有!比如你想在组件刚挂载的时候,获取一次当前的用户地理位置权限状态,拿到之后就不管了——这个时候就很适合用加once的watchEffect,因为它是隐式追踪的,如果后续你代码里把获取权限的函数改成了依赖某个动态配置的ref,那初始化那次也会自动追踪到这个配置,不用显式写监听源:

import { ref, watchEffect } from 'vue';
export default {
  setup() {
    const useHighPrecision = ref(true);
    const locationPermission = ref('unknown');
    watchEffect(
      () => {
        navigator.geolocation.getCurrentPosition(
          () => {
            locationPermission.value = 'granted';
          },
          (error) => {
            locationPermission.value = error.code === 1 ? 'denied' : 'unavailable';
          },
          {
            enableHighAccuracy: useHighPrecision.value
          }
        );
      },
      {
        once: true
      }
    );
    return {
      locationPermission
    };
  }
};

这段代码里,useHighPrecision虽然是响应式的,但因为watchEffect加了once,初始化执行一次获取权限的函数之后,就算后面把useHighPrecision改成false,也不会再获取了,完全符合“只拿一次权限状态”的需求。

手写简化版的“只执行后续第一次变化的watchEffect”

刚才说到踩坑了watchEffect加once的立即执行问题,那现在咱们来补一个手写简化版实现,解决这个问题——也就是实现一个类似“Vue3 2.x版本里的watch once + 不要immediate”效果的工具函数?不对不对,3.x普通watch加once加immediate: false已经能实现单个或多个显式监听源的后续第一次变化触发,咱们手写的是隐式追踪的、只执行后续第一次变化的,暂时叫它watchOnceEffect吧。

先理一下思路:要实现隐式追踪,肯定得用到Vue3暴露给我们的底层响应式API,比如effect(对,就是watch和watchEffect底层用的那个effect函数!不过这个是底层API,文档里提过但不建议直接在生产环境大规模用,不过作为学习和小范围工具函数是没问题的);然后要跳过第一次effect的执行(也就是跳过初始化那次隐式追踪),等第一次有效变化触发后,再执行我们的回调,然后销毁effect。

哦对了,文档里说过,effect可以传配置项flush,用来控制执行时机(和watchEffect的flush一样);还可以传一个onTrack和onTrigger回调,用来追踪依赖和触发时机;effect函数本身会返回一个stop函数,用来销毁监听器。

那咱们来写代码:

import { effect, ref } from 'vue';
// 手写简化版的watchOnceEffect
function watchOnceEffect(callback, options = {}) {
  // 标记是不是第一次触发
  let isFirstTrigger = true;
  // 创建effect,先存起来
  const stopEffect = effect(
    callback,
    {
      ...options,
      // 重点!在trigger的时候判断是不是第一次
      onTrigger: (triggerInfo) => {
        // 如果有用户自己传的onTrigger,先执行
        if (options.onTrigger) {
          options.onTrigger(triggerInfo);
        }
        // 如果是第一次触发
        if (isFirstTrigger) {
          // 标记已经不是第一次了
          isFirstTrigger = false;
          // 不用做其他的,effect会自动执行callback
        } else {
          // 第二次及以后触发,直接销毁effect,不执行callback
          stopEffect();
        }
      }
    }
  );
  // 返回stop函数,用户如果想手动提前销毁也可以用
  return stopEffect;
}
// 测试一下刚才踩坑的例子
export default {
  setup() {
    const count = ref(0);
    // 调用我们手写的watchOnceEffect
    watchOnceEffect(
      () => {
        console.log('count现在是(后续第一次变化)', count.value);
      }
    );
    const increment = () => {
      count.value++;
    };
    return {
      count,
      increment
    };
  }
};

现在你再测试这段代码:组件刚挂载的时候,控制台不会打印任何东西;点击一次按钮,count变成1,控制台打印“count现在是(后续第一次变化)1”;再点击按钮,count变成2、3……控制台都不会再打印了——完美解决了刚才的问题!

不过再强调一遍:这个effect是底层API,不同的Vue3小版本可能会有细微的变化(不过flush、onTrack、onTrigger这些应该是比较稳定的),如果是生产环境的大规模项目,建议还是尽量用普通watch加once加immediate: false来实现,除非你真的非常需要隐式追踪的能力。

避坑指南:加once的watch/watchEffect别乱用

聊完了用法和手写实现,最后来给你一份避坑指南,都是我或者身边同事踩过的真实坑,看完能帮你节省很多debug的时间:

避坑1:加once的watch不要同时加deep监听嵌套对象的所有属性

刚才提到过,如果监听的是reactive的整个对象,默认就是deep监听,加once之后也是一样的——第一次整个对象的任何嵌套属性变化,都会执行回调然后销毁。

但如果你本来的需求是“只监听嵌套对象的userName属性第一次变化”,结果不小心监听了整个userInfo对象,那如果age属性先变化了,回调就会执行,userName属性再变化也没用了,这就完全不符合需求了!

所以正确的做法是:如果只需要监听嵌套对象的某个或某几个属性,一定要显式指定这些属性作为监听源,不要监听整个对象。

避坑2:加once的watch的回调里不要修改监听源

哦不对,这个其实是所有watch都要注意的坑,但加once之后更坑——因为如果修改了监听源,普通watch可能会陷入无限循环,但加once的watch只会执行一次修改,然后销毁,无限循环的问题不会出现,但你可能会觉得“为什么我的监听源会变两次?第一次变没触发我的逻辑?”

比如这段代码:

import { ref, watch } from 'vue';
export default {
  setup() {
    const count = ref(0);
    watch(
      count,
      (newVal) => {
        console.log('newVal第一次是', newVal);
        // 不小心在回调里修改了count
        count.value = newVal + 1;
      },
      {
        once: true
      }
    );
    const increment = () => {
      count.value++;
    };
    return {
      count,
      increment
    };
  }
};

测试这段代码:点击一次按钮,count变成1,然后watch的回调执行,打印“newVal第一次是1”,然后把count改成2,接着因为once的限制,监听器销毁了;再点击按钮,count变成3、4……都不会有反应了——表面上看起来好像没问题,但如果你本来的需求是“只监听用户手动点击按钮让count从0变成1的那一次”,结果回调里又把count改成了2,可能会影响到其他依赖count的逻辑,所以还是要尽量避免在watch的回调里修改监听源。

避坑3:加once的watchEffect不要依赖太多可能提前变化的响应式数据

刚才说了加once的watchEffect是先立即执行一次初始化回调,然后销毁,所以初始化回调里用到的所有响应式数据,后续都不会再被监听了——如果你依赖了太多可能提前变化的响应式数据,比如依赖了一个在onMounted里才会赋值的ref,那初始化回调里这个ref还是初始值,后续onMounted赋值之后,也不会触发回调了,这就会导致逻辑错误。

比如这段代码:

import { ref, watchEffect, onMounted } from 'vue';
export default {
  setup() {
    const userId = ref(null);
    const userDetail = ref(null);
    // 错误写法!!!
    watchEffect(
      () => {
        if (userId.value) {
          // 模拟调用接口获取用户详情
          setTimeout(() => {
            userDetail.value = { id: userId.value, name: '小红' };
          }, 500);
        }
      },
      {
        once: true
      }
    );
    // onMounted里才给userId赋值
    onMounted(() => {
      userId.value = 123;
    });
    return {
      userDetail
    };
  }
};

测试这段代码:组件刚挂载,watchEffect立即执行一次,此时userId是null,所以不会调用接口;然后onMounted执行,把userId改成123,但因为watchEffect加了once,已经销毁了,所以接口永远不会调用,userDetail永远是null——这就完全不符合需求了!

所以正确的做法是:如果你的watchEffect依赖了一些在生命周期钩子或异步操作里才会赋值的响应式数据,要么不要加once,要么用普通watch加once加immediate: false显式监听这些数据

今天咱们聊了这么多,总结一下核心知识点:

  1. Vue3组合式API里没有单独叫watchOnce的全局函数,它是watch和watchEffect配置项里的once: true属性;
  2. 加once的普通watch:遵循普通watch的所有规则(懒执行、显式指定监听源、能拿到newVal和oldVal),只是第一次满足触发条件(初始化如果加immediate就执行,否则等第一次变化)执行完回调后自动销毁;
  3. 加once的watchEffect:遵循watchEffect的默认规则(立即执行一次初始化回调、隐式追踪监听源、拿不到oldVal),只是执行完这次初始化回调后自动销毁;
  4. 如果需要隐式追踪且只执行后续第一次变化的监听,可以手写简化版的watchOnceEffect,但要注意这是底层API,生产环境大规模使用要谨慎;
  5. 最后给了三个避坑指南,一定要记牢。

好了,今天的内容就到这里了,如果你还有其他关于Vue3组合式API的问题,欢迎在评论区留言讨论!

版权声明

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

热门