看不懂来打我!Vue3的watch是如何实现数据监听的

前言

watch这个API大家都很熟悉,今天这篇文章欧阳来带你搞清楚Vue3的watch是如何实现对响应式数据进行监听的。注:本文使用的Vue版本为3.5.13

欧阳年底也要毕业了,加入欧阳的面试交流群(分享内推信息)、高质量vue源码交流群

看个demo

我们来看个简单的demo,代码如下:

javascript 复制代码
<template>
  <button @click="count++">count++</button>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";
const count = ref(0);
watch(count, (preVal, curVal) => {
  console.log("count is changed", preVal, curVal);
});
</script>

这个demo很简单,使用watch监听了响应式变量count,在watch回调中进行了console打印。如何有个button按钮,点击后会count++。

开始打断点

现在我们第一个断点应该打在哪里呢?

我们要看watch的实现,那么当然是给我们demo中的watch函数打个断点。

首先执行yarn dev将我们的demo跑起来,然后在浏览器的network面板中找到对应的vue文件,右键点击Open in Sources panel 就可以在source面板中打开我们的代码啦。如下图

然后给watch函数打个断点,如下图:

接着刷新页面,此时代码将会停留在断点出。将断点走进watch函数,代码如下:

javascript 复制代码
function watch(source, cb, options) {
  return doWatch(source, cb, options);
}

从上面的代码可以看到在watch函数中直接返回了doWatch函数。

将断点走进doWatch函数,在我们这个场景中简化后的代码如下(为了方便大家理解,本文中会将scheduler任务调度相关的代码移除掉,因为这个不影响watch的主流程):

javascript 复制代码
function doWatch(source, cb, options = EMPTY_OBJ) {
  const baseWatchOptions = extend({}, options);
  const watchHandle = baseWatch(source, cb, baseWatchOptions);
  return watchHandle;
}

从上面的代码可以看到底层实际是在执行baseWatch函数,而这个baseWatch就是由@vue/reactivity包中导出的watch函数。关于这个baseWatch函数的由来可以看看欧阳之前的文章: Vue3.5新增的baseWatch让watch函数和Vue组件彻底分手

baseWatch函数

将断点走进baseWatch函数,在我们这个场景中简化后的代码如下:

javascript 复制代码
const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

首先定义了两个变量effectgettereffectReactiveEffect类的实例。

接着就是使用isRef(source)判断watch监听的是不是一个ref变量,如果是就将getter函数赋值为getter = () => source.value。这么做的原因是为了保持一致(watch也可以直接监听一个getter函数),并且后面会对这个getter函数进行读操作触发依赖收集。

我们知道watch的回调中有oldValuenewValue这两个字段,在watch函数内部有个字段也名为oldValue用于存旧的值。

接着就是定义了一个job函数,我们先不看里面的代码,执行这个job函数就会执行watch的回调。

然后执行effect = new ReactiveEffect(getter),这个ReactiveEffect类是一个底层的类。在Vue的设计中,所有的订阅者都是继承的这个ReactiveEffect 。比如watchEffectcomputed()、render函数等。

在我们这个场景中new ReactiveEffect时传入的getter函数就是getter = () => source.value,这里的source就是watch监听的响应式变量count

接着将job函数赋值给effect.scheduler属性,在ReactiveEffect类中依赖触发时就会执行effect.scheduler方法(接下来会讲)。

最后就是执行effect.run()拿到初始化时watch监听变量的值,这个run方法也是在ReactiveEffect类中。接下来也会讲。

ReactiveEffect

前面我们讲过了ReactiveEffect是Vue的一个底层类,所有的订阅者都是继承的这个类。将断点走进ReactiveEffect类,在我们这个场景中简化后的代码如下:

javascript 复制代码
class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
  constructor(fn) {
    this.fn = fn;
  }

  run(): T {
    const prevEffect = activeSub;
    activeSub = this;
    try {
      return this.fn();
    } finally {
      activeSub = prevEffect;
    }
  }

  trigger(): void {
    this.scheduler();
  }
}

在new一个ReactiveEffect实例时传入的getter函数会赋值给实例的fn方法。(实际的ReactiveEffect代码比这个要复杂很多,感兴趣的同学可以去看源代码)

我们回到前面讲过的baseWatch函数中的最后一块:oldValue = effect.run()。这里执行了effect实例的run方法拿到watch监听变量的值,并且赋值给oldValue变量。

因为我们如果不使用immediate: true,那么Vue会等watch监听的变量改变后才会触发watch回调,回调中有个字段叫oldValue,这个oldValue就是初始化时执行run方法拿到的。

比如我们这里count初始化的值是0,初始化执行oldValue = effect.run()后就会给oldValue赋值为0。当点击**count++**按钮后,count的值就变成了1,所以在watch回调第一次触发的时候他就知道oldValue的值是0啦。

除此之外,在run方法中还有收集依赖的作用。Vue维护了一个全局变量activeSub表示当前active的订阅者是谁,在同一时间只可能有一个active的订阅者,不然触发get拦截进行依赖收集时就不知道该把哪个订阅者给收集了。

run方法中将当前的activeSub给存起来,等下面的代码执行完了后将全局变量activeSub改回去。

接着就是执行activeSub = this;将当前的watch设置为全局变量activeSub

接下来就是执行return this.fn(),前面我们讲过了这个this.fn()方法就是watch监听的getter函数。由于我们watch监听的是一个响应式变量count,在前面处理后他的getter函数就是getter = () => source.value;。这里的source就是watch监听的变量,这个getter函数实际就是getter = () => count.value;

那么这里执行return this.fn()就是执行() => count.value,将会触发响应式变量count的get拦截。在get拦截中会进行依赖收集,由于此时的全局变量activeSub已经变成了订阅者watch,所以响应式变量count在依赖收集的过程中收集的订阅者就是watch。这样响应式变量count就和订阅者watch建立了依赖收集的关系。关于Vue3.5依赖收集和依赖触发可以看看欧阳之前的文章: 看不懂来打我!让性能提升56%的Vue3.5响应式重构

当我们点击**count++**后会修改响应式变量count的值,就会进行依赖触发,经过一堆操作后最后就会执行到这里的trigger方法中。在trigger方法中直接执行this.scheduler(),在前面已经对scheduler方法进行了赋值,回忆一下baseWatch函数的代码。如下:

javascript 复制代码
const INITIAL_WATCHER_VALUE = {}

function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ
): WatchHandle {
  let effect: ReactiveEffect;
  let getter: () => any;

  if (isRef(source)) {
    getter = () => source.value;
  }

  let oldValue: any = INITIAL_WATCHER_VALUE;

  const job = () => {
    if (cb) {
      const newValue = effect.run();
      if (hasChanged(newValue, oldValue)) {
        const args = [
          newValue,
          oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
          boundCleanup,
        ];

        cb(...args);
        oldValue = newValue;
      }
    }
  };
  effect = new ReactiveEffect(getter);
  effect.scheduler = job;

  oldValue = effect.run();
}

这里将job函数赋值给effect.scheduler方法,所以当响应式变量count的值改变后实际就是在执行这里的job函数。

job函数中首先判断是否有传入watch的callback函数,然后执行const newValue = effect.run()

执行这行代码有两个作用:

第一个作用是重新执行getter函数,也就是getter = () => count.value;,拿到最新count的值,将其赋值给newValue

第二个作用是watch除了监听响应式变量之外还可以监听一个getter函数,那么在getter函数中就可以类似computed一样在某些条件下监听变量A,某些条件下监听变量B。这里的第二个作用是重新收集依赖,因为此时watch可能从监听变量A变成了监听变量B。

接着就是执行if (hasChanged(newValue, oldValue))判断watch监听的变量新的值和旧的值是否相等,如果不相等才去执行cb(...args)触发watch的回调。最后就是将当前的newValue赋值给oldValue,下次触发watch回调时作为oldValue字段。

总结

这篇文章讲了watch如何对响应式变量进行监听,其实底层依赖的是@vue/reactivity包的baseWatch函数。在baseWatch函数中会使用ReactiveEffect类new一个effect实例,这个ReactiveEffect类是一个底层的类,Vue的订阅者都是基于这个类去实现的。

如果没有使用immediate: true,初始化时会去执行一次effect.run()对watch监听的响应式变量进行读操作并且将其赋值给oldValue。读操作会触发get拦截进行响应式变量的依赖收集,会将当前watch作为订阅者进行收集。

当响应式变量的值改变后会触发set拦截,进而依赖触发。前一步将watch也作为订阅者进行了收集,依赖触发时也会通知到watch,所以此时会执行watch中的job函数。在job函数中会再次执行effect.run()拿到响应式变量最新的值赋值给newValue,同时再次进行依赖收集。如果oldValuenewValue不相等,那么就触发watch的回调,并且将oldValuenewValue作为参数传过去。

相关推荐
苹果酱056730 分钟前
redis系列--1.redis是什么
java·vue.js·spring boot·mysql·课程设计
琦遇2 小时前
Vue3使用AntvG6写拓扑图,可添加修改删除节点和边
前端·javascript·vue.js
南囝coding2 小时前
做Docx预览,一定要做这个神库!!
前端·vue.js·面试
樊小肆2 小时前
Vue3 在线 PDF 编辑 1.0 保存、下载
前端·vue.js
樊小肆2 小时前
Vue3 在线 PDF 编辑 1.0 文字、圆、矩形批注
前端·vue.js
李大2 小时前
uniapp怎么进行代码混淆
前端·javascript·vue.js
柚子8162 小时前
用transform给vue加点功能
前端·vue.js·vite
wordbaby2 小时前
Vue 图片重试指令 (v-img-retry) 增强:集成 visibility 控制,实现无缝加载过渡
前端·vue.js·http
xin4972 小时前
Calcite 如何通过 SQL 来查询不同数据源? 有性能问题吗?
后端·源码
ZhZhXuan2 小时前
micro-app环境下,子系统加载vxe-table样式被吞解决方法
vue.js