◆

Vue3 源码:watch 监听实现原理解析

2023-04-19 00:510

watch 本质上还是一个 effect

watch 看起来是个独立的 API,实现上它就是一个带调度器的 ReactiveEffect,只是多做了两件事:把新旧值都存下来传给用户回调,以及提供一个清理上一次副作用的入口。

拆开来看是三个部分。第一部分把用户传进来的 source 归一成一个函数 getter,因为 effect 只接受函数;第二部分定义 job,也就是依赖变化后要执行的动作,它负责重新求值、对比新旧、调用回调;第三部分把 getter 和 job 交给 ReactiveEffect,然后立刻 run 一次拿到初始的旧值。

关键点在于:ReactiveEffect 传了第二个参数就会走调度器,依赖变化时不再直接执行 getter,而是执行 job。job 内部才去调 effect.run() 取新值。这一层间接让 watch 有机会在求值前后插入自己的逻辑。

为什么要递归遍历 source

watch 能监听整个 reactive 对象,靠的是一次深度遍历。依赖收集只在属性被读取时发生,所以想让对象里每一个属性都建立依赖关系,就得把它们全都读一遍。traversal 函数做的就是这件事——递归访问所有属性,访问的动作本身触发 getter,依赖就收集上了。

这也解释了 watch 深度监听的开销:对象层级越深、属性越多,每次求值都要完整走一遍。所以 Vue 里 deep 不是默认开启的,传函数形式只读需要的字段,性能会好得多。

递归还必须防循环引用。如果对象里有属性指回自己,或者两个对象互相引用,不做处理会直接栈溢出。代码里用一个 Set 记下访问过的对象,遇到重复的直接返回,这是处理循环引用的通用套路。

  1. watch 监控值值有两种形式,source 一种是 reactive 定义的值(这一种我们还是需要将其转换为函数),另一种是函数
  2. wacth 也会用到 effect,传入的 getter 就是上方的 soucre,也就是我们之前一直说的用户执行的 fn 函数
  3. 同时第二个参数就是 wacth 自己的调度器,这里面要注意 cleanup 去保存上次的 fn,如果上次的 fn 还没

读代码时按 traversal → getter 归一 → job → 末尾两行 new ReactiveEffect 和 effect.run() 的顺序看,逻辑就串起来了:

import { ReactiveEffect } from './effect';
import { isReactive } from './reactive';
import { isFunction, isObject } from '@vue/shared';

// 考虑如果对象中有循环引用的问题
function traversal(value, set = new Set()) {
  // 第一步递归要有终结条件,不是对象就不在递归了"
  if (!isObject(value)) return value;
  if (set.has(value)) {
    return value;
  }
  set.add(value);

  for (let key in value) {
    traversal(value[key], set);
  }

  return value;
}

/**
 *
 * @param source 用户传入的对象或者函数
 * @param cb 对应的用户的回调
 */
export function watch(source, cb) {
  let getter;
  if (isReactive(source)) {
    // 对我们用户传入的数据进行 递归循环(只要循环就会访问对象上的每一个属性,访问属性的时候会收集 effect)
    getter = () => traversal(source);
  } else if (isFunction(source)) {
    getter = source;
  } else {
    return;
  }

  let cleanup;
  const onCleanup = fn => {
    // 保存用户的函数
    cleanup = fn;
  };

  let oldValue;
  const job = () => {
    // 下一次 watch 开始触发上一次 watch 的清理
    if (cleanup) cleanup();
    const newValue = effect.run();
    cb(newValue, oldValue, onCleanup);
    oldValue = newValue;
  };

  /**
   * 在 effect 中访问属性就会依赖收集
   * 监控自己构造的函数,变化后重新执行 job
   */
  const effect = new ReactiveEffect(getter, job);
  oldValue = effect.run();
}

/**
 * watch = effect 会保存老值和新值的调用
 */

onCleanup 用来干什么

回调的第三个参数 onCleanup 是为了处理竞态。典型场景是根据关键字发请求:用户连续输入,watch 触发多次,多个请求同时在飞。请求返回的顺序不保证和发出顺序一致,先发的可能后回来,结果把新数据覆盖成旧数据。

onCleanup 让我们能注册一个清理函数,它会在下一次回调执行之前被调用。所以可以在里面取消上一次的请求,或者设一个标志位让上一次的结果被丢弃:

watch(keyword, (newVal, oldVal, onCleanup) => {
  let expired = false;
  onCleanup(() => {
    expired = true;
  });

  fetchData(newVal).then(res => {
    // 上一次的回调已经过期,结果直接丢掉
    if (!expired) result.value = res;
  });
});

代码里的实现方式是:onCleanup 把用户传的函数存进闭包变量 cleanup,job 每次开头先判断 cleanup 是否存在,有就先调用它,再往下走求值和回调。

新旧值的两个坑

oldValue 的初始值来自末尾那次 effect.run()。这次 run 有两个作用:完成首次依赖收集,以及拿到初始快照当作第一次回调的旧值。所以默认情况下 watch 不会在创建时执行回调,只有依赖变化后才执行——需要立刻执行一次的话得另传 immediate 选项。

监听 reactive 对象时,新值和旧值会是同一个引用。因为对象是原地修改的,traversal 返回的始终是那个对象本身,newValue === oldValue 成立,回调里对比新旧毫无意义。想拿到真正的旧值,得在 getter 里返回一份拷贝或者只取具体的原始值字段。

flush 决定回调在什么时机跑

上面的实现里 job 是同步执行的:依赖一变立刻跑回调。真实的 Vue 在这基础上加了调度队列,用 flush 选项控制时机,一共三档。

flush: 'pre' 是默认值,回调在组件更新之前执行,并且会被放进微任务队列做去重合并——同一次事件里改十次值,回调只跑一次,拿到的是最终值。这一档回调里读 DOM 拿到的还是更新前的状态。

flush: 'post' 把回调推到组件更新之后,需要在回调里访问更新后的 DOM 时用这个,等价于在回调里手动包一层 nextTick。

flush: 'sync' 是同步执行,也就是本文这份实现的行为。它没有去重,值改几次回调就跑几次,多数场景下不推荐,容易造成不必要的重复执行。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEVue3 源码:watch 监听实现原理解析加载中