ShawDubie

Vue 系列(八):调度器之 computed 与 watch 的实现

Designed by @吃肉的小羊

通过前面几篇文章的介绍,整个 Vue 的响应式系统我们就已经了解的差不多了,本篇将是关于响应式系统的最后一篇文章。本篇我们将会介绍 Vue 响应式系统中非常重要的一个部分:调度器。

什么是调度器


我们当前实现的响应式系统,每当数据发生改变的时候,系统会自动去执行副作用函数,比如:

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo);

// 注册副作用函数
effect(effectFn);

// 修改响应式数据
setTimeout(() => { data.foo = 1 }, 1000);

在上面的例子中,当我们修改 data.foo = 1 的时候,会自动触发副作用函数的执行,如果我们现在要改变副作用函数的执行方式,比如当响应式数据被修改的时候,希望副作用函数能够延迟 1s 执行。当然你可能会说,这还不简单?拿起键盘,直接修改 effectFn 函数,给她加个 setTimeout,一把梭,完事!这当然也是一种办法,但是,我们可不可以在不修改 effectFn 的情况下实现这个需求呢?这就需要我们的响应式系统支持调度器了。那什么是调度器呢?

所谓调度器,就是用户能够自行地去控制副作用函数的执行方式、执行时机等。对于当前我们实现的响应式系统而言,当响应式数据发生变化的时候,我们希望副作用函数能够按照我们规定的方式去执行。那么如何才能让副作用函数按照我们规定的方式去执行呢?就是将副作用函数作为一个参数,传递给某个方法,当响应式数据发生变化的时候,就在这个方法中按照我们规定的方式去调用副作用函数即可,而这个方法就是我们即将要实现的调度器。

我们先来看看在 Vue 的响应式系统中调度器是如何被使用的:

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo);

// 副作用注册的时候
effect(effectFn, {
  scheduler: (fn?: Function) => {
    //使用传入的副作用函数
  }
});

通过上面的代码我们可以看到,用户在使用 effect 方法注册副作用函数的时候,新增了一个参数 scheduler,这个参数就是我们所说的调度器,她是一个函数,接收一个方法作为参数。当响应式数据发生变化的时候,会触发 trigger 的执行,在我们之前实现的响应式系统中,我们是直接在 trigger 中执行了相应的副作用函数。有了调度器,则会将副作用函数作为参数传递给调度器,然后执行调度器中的逻辑,这样用户就能手动控制副作用函数的执行了。

根据上面的分析,要实现调度器,我们首先需要修改 effect 方法:

查看代码
interface EffectOptions {
  scheduler?: (fn?: Function) => any
}

// 注册副作用的函数,新增了一个参数 options
const effect = (fn: Function, options?: EffectOptions) => {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    const res = fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1]
    return res;
  }
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  // 给副作用函数增加一个属性 options
  effectFn.options = options;
  effectFn();
}

可以看到,我们给 effect 增加了一个参数 options,她的类型是 EffectOptions,用户可以通过该参数定义调度器。当用户通过 effect 注册副作用函数的时候,传入 options,effect 会将 options 挂载到相应的副作用函数上面。除此之外,我们还需要修改 trigger 函数:

查看代码
const trigger = (target: any, key: string | symbol, triggerType?: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectsToRun: Set<EffectFunction> = new Set();

    // 省略其他代码

    effectsToRun.forEach(fn => {
      // 是否有调度器
      if (fn.options && fn.options.scheduler) {
        // 执行调度器,将副作用函数作为参数传入调度器方法中
        fn.options.scheduler(fn);
      } else {
        fn();
      }
    });
  }
};

在 trigger 方法内,如果某个副作用函数挂载了 options,且 options 中实现了调度器,就不再直接执行该副作用函数了,而是将副作用函数作为参数传递给挂载的 scheduler。这样当 trigger 被触发的时候,响应式系统就会执行用户定义的调度器,这样就实现了对副作用函数的控制。

那么有了上面我们实现的调度器,我们就可以实现之前的需求了——「希望副作用函数能够延迟 1s 执行」:

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo);

effect(effectFn, {
  scheduler: (fn?: Function) => {
    // 通过 setTimeout 控制副作用函数的执行
    setTimeout(() => {
      fn && fn();
    }, 1000);
  }
});

// 修改响应式数据,副作用将会在一秒钟之后执行
data.foo = 1;

在上面的代码中,我们在注册副作用函数的时候实现了一个调度器 scheduler,我们在该调度器内改变了副作用函数的执行时机——即 1s 之后再执行。看到这里你可能会说,实现这么一个简单的需求需要这么多改动,还不如直接一把梭来的快呢。其实调度器的作用远不止于此,Vue 中非常重要的两个能力—— watch 和 computed 就需要依赖调度器来实现。在此之前,我们再来看一个例子以加深对调度器的理解。

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo); 

// 注册副作用函数
effect(effectFn); // data.foo---> 0

data.foo = 1; // data.foo---> 1
data.foo = 2; // data.foo---> 2
data.foo = 3; // data.foo---> 3

在上面的例子中,我们连续修改了 data.foo 的值,可以看到,每次修改都去执行了副作用函数,总共打印了 3 次(不算副作用函数注册时的那次打印)。但是在 Vue 中,如果我们连续多次修改了响应式数据,最终只会触发一次更新。对于上面的例子来说 data.foo 最终都会被修改成 3,我们其实不需要去关心她的过渡状态,而且多次执行也会影响性能。那么我们就可以通过调度器来让上面的例子只打印 1 次:

查看代码
// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo); // data.foo---> 0

// 是否正在执行的标识
let isInvolving = false;

effect(effectFn, {
  scheduler: (fn?: Function) => {
    if (!fn) return;
    if (!isInvolving) {
      // 将标识置为 true
      isInvolving = true;
      // 通过微任务来执行副作用函数
      Promise.resolve().then(() => {
        // 执行副作用函数
        fn();
        // 执行完将标识设为 false
        isInvolving = false;
      });
    }
  }
});

data.foo = 1;
data.foo = 2;
data.foo = 3; // data.foo---> 3

在上面的例子中,我们在调度器中将副作用函数放在一个微任务中进行执行,当其开始执行的时候,我们将标识 isInvolving 置为 true,当微任务执行完毕的时候,将其置为 false。当我们连续 3 次修改 data.foo 的值时,调度器会被执行三次,当第一次执行的时候,isInvolving 会被置为 true,这样后面两次执行的时候,程序就走不到微任务里面去了。那么在这个周期内微任务只会执行一次,即当 data.foo = 3 执行完之后,微任务才会继续执行,此时会执行副作用函数,这样就能做到只打印一次,且打印结果为最终态,即:data.foo---> 3。

当然 Vue 中实现的调度器要比我们上面的完善许多,但是原理是类似的。

computed


computed 是一个非常实用的属性,在日常开发中,我们通过 computed 来处理比较复杂的计算逻辑。在具体实现 computed 之前,我们先来看看她的用法。

懒执行
const foo = ref(1);

const bar = computed(() => foo.value + 2);

console.log('bar--->', bar.value);

上面就是一个使用 computed 的例子,我们向 computed 中传递了一个 getter,通过她生成了数据 bar。bar 其实是一个 ref,当访问 bar.value 的时候,就能拿到计算属性的值。这里需要注意的是,当我们通过 computed 定义 bar 时候,computed 并没有立即将 foo.value + 2 的值计算出来,而是返回了一个通过 ref 包裹的方法。当访问 bar.value 时候才会去计算 foo.value + 2 的值。我们称这种方式为懒(lazy)执行。这么说可能有些抽象,我们先通过一个例子来看看什么是懒执行。

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo); 

// 注册副作用函数,effectFn 会立即执行一次
effect(effectFn); // data.foo---> 0

在上面的例子,当我们注册副作用函数的时候,副作用函数在注册的时候就会立即执行一次,如果我们不想让她立即执行,而是希望她在被使用的时候执行,比如:

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo); 

// 注册副作用函数,希望副作用函数不会立即执行
const lazyEffect = effect(effectFn);

// 使用的时候才执行
lazyEffect() // data.foo---> 0

上面展示的就是一个懒执行的例子,effectFn 被注册的时候不会立即执行,而是当使用她的时候才会执行。那我们要怎么去实现懒执行呢?参考我们实现调度器那样,当副作用函数被注册的时候,我们传一个懒执行的标识,然后根据这个标识去判断是否需要懒执行:

// 创建响应式数据
const data = reactive({ foo: 0 });

// 定义副作用函数
const effectFn = () => console.log('data.foo--->', data.foo); 

// 注册副作用函数,传入懒执行的标识
const lazyEffect = effect(effectFn, { lazy: true });

// 使用的时候才执行
lazyEffect() // data.foo---> 0

可以看到,我们给 effect 的 options 中又增加了一个参数 lazy,用来标识是否为懒执行。要实现懒执行,我们还得再观察一下上面的例子,可以看到,当注册副作用函数 effectFn 的时候,effectFn 没有立即执行,而是返回了一个新的函数 lazyEffect,当我们使用 lazyEffect 的时候才会去执行副作用函数。知道了这一点之后,我们就可以实现懒执行了:

查看代码
interface EffectOptions {
  lazy?: boolean, // 懒执行的标识
  scheduler?: (fn?: Function) => any // 调度器
}

// 注册副作用的函数,新增了一个参数 options
const effect = (fn: Function, options?: EffectOptions) => {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    const res = fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1]
    return res;
  }
  effectFn.deps = [] as Array<Set<EffectFunction>>;
  // 给副作用函数增加一个属性 options
  effectFn.options = options;
  // 判断是否为懒执行
  if (options && options.lazy) {
    // 如果是懒执行,则返回 effectFn
    return effectFn;
  }
  // 不是懒执行,则立即执行 effectFn
  effectFn();
}

可以看到,懒执行的本质其实就是返回一个待执行的函数。既然知道了什么是懒执行,那么我们就可以着手实现 computed 了:

查看代码
const computed = (getter: () => any) => {
  const effectFn = effect(getter, { lazy: true });

  const wrapper = {
    get value() {
      return effectFn && effectFn();
    }
  }
  // 添加一个不可枚举且不可修改的属性,用来标识该对象是通过 ref 包裹的
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  // 返回 ref
  return wrapper;
}

上面就是 computed 的基本实现,可以看到,非常简单。我们首先定义了一个懒执行的函数 effectFn,由于 computed 返回的是一个 ref,我们可以参考上一篇文章使用一个包裹对象 wrapper。该对象内实现了一个 getter,当访问这个 getter 的时候才会去执行 effectFn。

缓存

computed 还有一个非常重要的能力——computed是基于她的响应式依赖进行缓存的,什么意思呢?就是说只有当相关响应式依赖发生改变时,computed 才会重新求值,即执行 getter 中 的 effectFn,否则会返回上一次求值的结果。所以我们需要修改上面的实现:

查看代码
const computed = (getter: () => any) => {
  // 缓存的 value
  let value;
  // 用来标识响应式依赖是否被修改
  let dirty = true;
  const effectFn = effect(getter, { 
    lazy: true,
    scheduler: () => {
      if(!dirty) {
        // 当响应式数据被改变时,将标识置为true
        dirty = true;
      }
    }
  });
  // 创建一个包裹对象
  const wrapper = { 
    // 只有访问 value 的时候才执行 effectFn
    get value() {
      // dirty 为 true 的时候才去执行副作用函数
      if(dirty) {
        value = effectFn && effectFn();
        // 执行完副作用函数后将dirty置为false
        dirty = false;
      }
      // dirty 为 false 说明数据没有发生改变,直接返回缓存的值
      return value;
    }
  }
  // 添加一个不可枚举且不可修改的属性,用来标识该对象是通过 ref 包裹的
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  // 返回 ref
  return wrapper;
}

在上面的代码中,我们首先定义了两个变量:value 和 dirty。value 用来缓存计算属性的值,dirty 则用来标识响应式依赖是否被修改。在使用 effect 时候我们新增了一个调度器 scheduler,她的作用是:当计算属性依赖的响应式数据发生改变的时候,会执行调度器中的代码,也就是将 dirty 置为 true。这样我们就能判断什么时候使用缓存的数据什么时候再次执行副作用函数了。

副作用嵌套

通过前面的介绍,computed 其实已经实现得比较完善了,但是还存在着一点缺陷,比如下面这个例子:

// 创建响应式数据
const data = reactive({ foo: 1 });

// 使用 computed 创建计算属性
const bar = computed(() => data.foo + 2);

// 在副作用函数中读取 computed 的值
const effectFn = () => console.log('bar--->', bar.value);

// 注册副作用函数
effect(effectFn);

setTimeout(() => {
  // 修改响应式数据
  data.foo = 2;
}, 1000);

在上面的例子中,我们定义了一个计算属性 bar,该计算属性依赖响应式数据 data.foo,然后我们注册了一个副作用函数 effectFn,该函数会读取 bar.value。如果执行上面的代码,当修改 data.foo = 1 的时候,副作用函数并不会执行。这是因为,我们通过 computed 读取 data.foo 的时候,她只会收集 computed 内部的副作用函数。当我们通过另一个副作用函数,也就是 effectFn 访问 bar.value 的时候,data.foo 并不能对 effectFn 进行收集,所以我们修改 data.foo = 1 不会触发 effectFn 的执行。

那要怎么做才能解决上面的问题呢?其实很简单,我们可以将 bar 看作是一个响应式数据,当有副作用函数访问 bar.value 的时候,我们将其收集不就可以了吗?访问 bar.value 不就是访问 computed 中包裹对象 wrapper 的 getter 吗?所以当 wrapper 中的 value 被访问的时候,我们进行副作用函数的收集。那么何时触发执行呢?我们知道,当计算属性依赖的响应式数据发生改变的时候,需要触发执行,也就是修改 dirty 的时候触发执行。经过这样的分析之后,我们再来修改 computed:

查看代码
const computed = (getter: () => any) => {
   // 缓存的 value
  let value;
  // 用来标识响应式依赖是否被修改
  const effectFn = effect(getter, { 
    lazy: true,
    scheduler: () => {
      if (!dirty) {
        // 当响应式数据被改变时,将标识置为true
        dirty = true;
        // 触发副作用函数
        trigger(wrapper, 'value');
      }
    }
  });
  // 创建一个包裹对象
  const wrapper = { 
    // 只有访问 value 的时候才执行 effectFn
    get value() {
      if(dirty) {
        value = effectFn && effectFn();
        // 执行完副作用函数后将dirty置为false
        dirty = false;
      }
      // 收集副作用函数
      track(wrapper, 'value');
      return value;
    }
  }
  // 添加一个不可枚举且不可修改的属性,用来标识该对象是通过 ref 包裹的
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  return wrapper;
}

watch


watch 也是我们在日常开发中经常使用的一个方法,我们通过她来侦听响应式数据的变化,当被侦听的数据发生变化的时候会执行一个自定义的回调方法。同上面介绍 computed 的方法一样,在具体实现 watch 之前,我们还是先来了解一下其基本用法。

watch 的实现原理
// 定义响应式数据
const data = reactive({ foo: 1 });

// 侦听响应式数据的变化
watch(data, (value, oldValue) => {
  console.log('value--->', value);
  console.log('oldValue--->', oldValue);
})

上面就是一个使用 watch 的例子,可以看到,我们通过 watch 来对响应式数据进行侦听,当响应式数据变化的时候会执行我们定义的回调方法,该方法接收两个参数:value 和 oldValue,分别表示新值和旧值。通过上面的例子我们来分析一下如何去实现 watch。

对于我们当前的响应式系统来说,当响应式数据变化的时候,会执行被收集的副作用函数,这其实与 watch 比较相似,watch 是响应式数据变化的时候,执行回调方法。那我们可不可以实现,当响应式数据变化的时候,不去执行副作用函数,而是执行回调方法呢?当然可以,通过调度器就可以实现这一点,基于此我们先写出如下代码:

查看代码
const watch = (
  source: any, 
  callBack: (value: any, oldValue) => void
) => {
  effect(
    // 访问响应式数据
    () => source, 
    {
      scheduler: () => {
        // 响应式数据变化的时候会执行调度器中的回调函数
        callback(newValue, oldValue);
      }
    }
  );
} 

上面就是我们根据分析写出的伪代码,这段代码还存在比较多的问题,我们一个一个来分析,首先看 () => source,这是我们注册的副作用函数,副作用函数只有读取响应式数据,才能对其进行收集并触发响应。很显然这样去实现是做不到这一点的,我们还需要实现一个方法去读取 source 中的值:

查看代码
const traverse = (source: any, seen = new Set()) => {
  // 当 source 为 null 或者不是对象的时候直接返回
  if (source === null || typeof source !== 'object' || seen.has(source)) {
    return;
  }
  // 将已经访问过的数据添加到 seen 中,如果已经被访问过则不再访问
  // 防止循环引用产生的无限递归
  seen.add(source);
  // 遍历 source
  for(const key in source) {
    // 递归读取 source 中的元素
    traverse(source[key], seen);
  }
  return source;
}

我们定义了一个 traverse 方法,她的作用就是帮助副作用函数读取响应式数据中的所有属性,这样就将响应式数据中的所有属性与副作用函数关联起来了,这时当我们修改 source 中的任何一个值的时候就都能触发响应了。完善后的代码如下:

查看代码
const watch = (
  source: any, 
  callBack: (value: any, oldValue) => void
) => {
  effect(
    // 访问响应式数据
    () => traverse(source), 
    {
      scheduler: () => {
        // 响应式数据变化的时候会执行调度器中的回调函数
        callback(newValue, oldValue);
      }
    }
  );
} 

上面的代码还存在一个问题,就是回调函数 callBack 需要接收两个参数,分别是新值和旧值。这两个值我们要如何获取呢?我们可以参考 computed 的实现,computed 中缓存的那个 value 其实就是旧值,当响应式数据发生改变的时候重新执行 effectFn 得到的就是新值,基于此,我们可以写出如下代码:

查看代码
const watch = (
  source: any, 
  callback: (value: any, oldValue: any) => void
) => {
  // 定义新旧值
  let newValue, oldValue;
  const effectFn = effect(
    // 访问响应式数据
    () => traverse(source),
    {
      lazy: true, // 引入懒执行,这样就能拿到副作用函数 effectFn
      // 当响应式数据变化的时候执行调度器方法
      scheduler: () => {
        // 这时重新执行 effectFn 拿到的就是最新的值
        newValue = effectFn && effectFn();
        // 执行回调函数
        callback(newValue, oldValue);
        // 执行完之后将当前的新值赋给旧值
        oldValue = newValue;
      }
    }
  );
  // 初始化的时候第一次执行effectFn拿到的就是旧值
  oldValue = effectFn && effectFn();
}

可以看到,为了获取新值和旧值,我们使用了懒执行,其实新旧值的获取和 computed 的思路是差不多的,就是通过 lazy 拿到待执行的 effectFn。新旧值的获取都是通过执行 effectFn 来实现的。需要注意的是,初始化的时候旧值就是第一次调用 effectFn 拿到的,当执行完回调函数的时候,我们还需要将 newValue 赋值给 oldValue。那么现在我们的 watch 就可以正常工作了。但是还存在着一个小问题,就是 watch 接收的第一个参数 source,她不仅可以是对象也有可能是一个 getter:

const data = reactive({ foo: 1, bar: 2 });

watch(() => data.foo, (value: any, oldValue: any) => {
  console.log('new value----->', value);
  console.log('old value----->', oldValue);
});

可以看到,在上面的例子中,我们希望 watch 只对 data.foo 的改变做出响应,要支持 getter 也非常简单:

查看代码
const watch = (
  source: any, 
  callback: (value: any, oldValue: any) => void
) => {
  // 定义新旧值
  let newValue, oldValue;
  // 如果 source 是函数,则直接使用,反之则通过 traverse 进行读取
  const getter = typeof source === 'function' ? source : () => traverse(source);
  const effectFn = effect(
    () => getter(),
    {
      lazy: true, // 引入懒执行,这样就能拿到副作用函数 effectFn
      // 当响应式数据变化的时候执行调度器方法
      scheduler: () => {
        // 这时重新执行 effectFn 拿到的就是最新的值
        newValue = effectFn && effectFn();
        // 执行回调函数
        callback(newValue, oldValue);
        // 执行完之后将当前的新值赋给旧值
        oldValue = newValue;
      }
    }
  );
  // 初始化的时候第一次执行effectFn拿到的就是旧值
  oldValue = effectFn && effectFn();
}

可以看到,当 source 为一个函数的时候,我们直接使用该函数作为副作用函数,这样就能支持用户自定义 getter 了。

立即执行

上面实现的 watch 只支持当响应式数据发生改变的时候,执行回调函数。但是有时候我们需要 watch 在被定义之后就立即执行一次,比如:

const data = reactive({ foo: 1, bar: 2 });

watch(() => data.foo, (value: any, oldValue: any) => {
  console.log('new value----->', value);
  console.log('old value----->', oldValue);
}, { immediate: true });

// new value-----> 1
// old value-----> undefined

setTimeout(() => {
  data.foo = 2;
}, 1000);

// new value-----> 2
// old value-----> 1

可以看到,当 watch 被定义之后回调函数立即执行了一次。再次修改响应式数据,回调函数还会再次执行。要实现立即执行也非常简单:

查看代码
export const watch = (
  source: any, 
  callback: (value: any, oldValue: any) => void,
  options?: { immediate: boolean }
) => {
  let newValue, oldValue;
  // 判断是函数还是对象
  const getter = typeof source === 'function' ? source : () => traverse(source);
  // 将回调的逻辑抽离
  const job = () => {
    // 执行 effectFn 拿到新值
    newValue = effectFn && effectFn();
    // 执行回调函数
    callback(newValue, oldValue);
    // 修改旧值
    oldValue = newValue;
  }
  const effectFn = effect(
    () => getter(), // 访问值
    {
      lazy: true, // lazy 为true,就可以在获取新值的时候调用 effectFn
      scheduler: job 
    }
  );
  if (options && options.immediate) {
    // 如果 immediate 为 true,则立即执行 job
    job();
  } else {
    // 初始化的时候第一次执行effectFn拿到的就是旧值
    oldValue = effectFn && effectFn();
  }
}

可以看到,我们将原来 scheduler 中的逻辑全部抽离了出来,当 immediate 为 true 的时候,立即执行回调的逻辑即可。这里需要注意的是,立即执行时,oldValue 的值是 undefined。

onCleanup

通过前面的介绍,我们知道回调函数 callBack 会接收两个参数 value 和 oldValue。其实在 Vue 中,callBack 还接收第三个参数 onCleanup,该参数是一个方法:

oncleanUp: (fn: () => void) => void

官方给出的 oncleanUp 的介绍为:

用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求

单看这一句描述可能并不太清楚 oncleanUp 有什么作用,我们通过一个例子来解释一下:

// 定义响应式数据
const data = reactive({ bar: 1 });
// 请求结果
let result;

watch(data, async () => {
  // 调用接口请求数据
  const res = await getFoo();
  // 修改结果
  result = res;
});

// 修改响应式数据
data.bar = 2;
data.bar = 3;

如上述代码所示,我们通过 watch 侦听了响应式数据 data,当 data 改变的时候,会调用 geFoo 从服务端获取数据,然后将获取的结果赋值给 result。当我们连续修改 data.bar 的时候,会执行两次回调函数,也就是会发送两次网络请求,这样有可能会导致竞态问题的出现:

  • 第一次修改 data.bar = 2,会调用 geFoo 发送请求 A,这时还没有返回请求结果

  • 第二次修改 data.bar = 3,会再次调用 geFoo 发送请求 B,这时 A、B 都还没有返回结果

  • B 请求优先返回了结果,这时我们执行 result = res

  • A 请求最后才返回了结果,又要执行 result = res,导致请求 B 的结果被覆盖了。

因为请求 B 是后于 A 发送的,所以我们认为请求 B 得到的结果才是最新的值,而 A 返回的结果应该被清除掉,不应该覆盖 B 的结果。

我们可以通过一段代码来模拟上面说的场景:

查看代码
// 定义 getFoo 来模拟网络请求,传入的 number 为网络请求的响应时间
const getFoo = (timer: number) => new Promise((resolve) => {
  setTimeout(() => {
    resolve(timer);
  }, timer);
});

// 定义响应式数据
const data = reactive({ bar: 1 });
// 请求结果
let result;
// 响应时间
let timer = 3000;

watch(data, async () => {
  // 后面请求的响应时间小于前面请求的响应时间
  timer -= 1000;
  const res = await getFoo(timer);
  result = res;
  console.log('result--->', result);
});

data.bar = 2;
data.bar = 3;

在上面的代码中,我们通过 getFoo 来模拟网络请求的场景,她接收一个参数 timer,用来定义返回结果的时间。在 watch 的回调方法中,每次回调都会将 timer 减去 1000,这样后面请求的响应时间就会小于前面请求的响应时间。通过这种方式我们就模拟了上面的例子。那我们要怎么才能解决这个问题呢?

我们先来分析一下上面的例子,当我们连续修改 data.bar 的时候,会连续调用 watch 的回调方法,即连续通过 getFoo 发送网络请求,每次请求返回的时候,我们都会去修改 result 的值。但是,我们只想要最后一次请求返回的值,在她之前发送的请求,无论什么她们的结果何时返回,我们都希望这些结果被丢弃掉。对于上面的例子来说,发送了两次请求,当第二次请求发送的时候,我们如果能够让第一次的请求结果被清除掉就好了。所谓清除,就是在修改 result 的时候增加一个守卫,比如:

查看代码
// 定义 getFoo 来模拟网络请求,传入的 number 为网络请求的响应时间
const getFoo = (timer: number) => new Promise((resolve) => {
  setTimeout(() => {
    resolve(timer);
  }, timer);
});

// 定义响应式数据
const data = reactive({ bar: 1 });
// 请求结果
let result;
// 响应时间
let timer = 3000;

watch(data, async () => {
  // 后面请求的响应时间小于前面请求的响应时间
  timer -= 1000;
  // 是否被清除的标识
  let isCleanUp = false;

  // todo, 需要有一个钩子,每次执行回调的时候将上一次的 isCleanUp 置为 true

  const res = await getFoo(timer);
  // 增加守卫,如果被清除则不修改 result
  if (!isCleanUp) {
    result = res;
    console.log('result--->', result);
  }
});

data.bar = 2;
data.bar = 3;

在上面的代码中,我们增加了一个变量 isCleanUp,用来标识上一次请求结果是否被清除。如果能够有一个钩子,每次执行回调之前,都将上一次的 isCleanUp 置为 true 就好了,这样的话之前的请求结果返回的时候都会被 if(!isCleanUp) 过滤掉。 Vue 恰好提供了这样一个钩子,就是我们前面提到过的 oncleanUp,有了 oncleanUp,我们就可以实现这个需求了:

查看代码
const getFoo = (timer: number) => new Promise((resolve) => {
  setTimeout(() => {
    resolve(timer);
  }, timer);
});

const data = reactive({ bar: 1 });
let timer = 3000;

watch(data, async (newValue, oldValue, oncleanUp) => {
  timer -= 1000;
  // 是否被清除的标识
  let isCleanUp = false;
  // 通过钩子注册自定义的清除函数
  oncleanUp(() => {
    // 将 isCleanUp 修改为 true
    isCleanUp = true;
  });
  const res = await getFoo(timer);
  // 增加守卫,如果被清除则不修改 result
  if (!isCleanUp) {
    result = res;
    console.log('result--->', result);
  }
});

data.bar = 2;
data.bar = 3;

oncleanUp 的作用就是给用户提供了一个钩子,用户可以通过这个钩子注册一个函数。当下一次回调执行之前,会优先执行用户通过钩子注册的函数,然后再去执行回调。注意这里的下一次,对于上面的例子来说:

  • 修改 data.bar = 2,首先会判断是否存在上一次通过 oncleanUp 注册的清除函数,因为是第一次,所以不存在清除函数,接下来会执行一次回调,这时候会调用 oncleanUp 注册匿名函数 () => isCleanUp = true

  • 再次修改 data.bar = 3,首先会执行上一次通过 oncleanUp 注册的匿名函数,也就是执行 () => isCleanUp = true,这样就将上一次回调词法环境中的 isCleanUp 修改为了 true,当上一次回调中的 getFoo(timer) 返回结果的时候,这时 isCleanUp 为 true,就会被 if (!isCleanUp) 过滤掉了。

这里之所以能够通过 oncleanUp 实现清除操作,是因为形成了一个闭包,即通过 oncleanUp 注册的函数读取了外部的变量,对于本例来说,就是匿名函数 () => isCleanUp = true 读取了定义在外部的变量 isCleanUp。她们共同组成了一个词法环境,这样当下一次回调发生的时候,就能通过注册的函数修改这个词法环境中的变量了。

既然知道了 oncleanUp 的用法,我们接下来看看如何实现她:

查看代码
const watch = (
  source: any, 
  callback: (value: any, oldValue: any, oncleanUp: (fn: () => void) => void) => void,
  options?: { immediate: boolean }
) => {
  let newValue: any;
  let oldValue: any;
  // 用户自定义的清除函数
  let cleanup: undefined | (() => void);
  // 判断是函数还是对象
  const getter = typeof source === 'function' ? source : () => traverse(source);
  // oncleanUp 钩子,提供给用户注册清除函数
  const oncleanUp = (fn: () => void) => {
    // 将用户自定义的函数赋值给 cleanup
    cleanup = fn;
  }
  // 将回调的逻辑抽离
  const job = () => {
    newValue = effectFn && effectFn();
    // 如果 cleanup 被注册,则先执行 cleanup
    if (cleanup) {
      // 执行上一次注册的 cleanup
      cleanup();
    }
    // 执行回调函数,将oncleanUp传给用户使用
    callback(newValue, oldValue, oncleanUp);
    // 将旧值改为当前的新值
    oldValue = newValue;
  }
  const effectFn = effect(
    () => getter(), // 访问值
    {
      lazy: true, // lazy 为true,就可以在获取新值的时候调用 effectFn
      scheduler: () => {
        job();
      }
    }
  );
  // 立即执行
  if (options && options.immediate) {
    job();
  } else {
    oldValue = effectFn && effectFn();
  }
}

在上面的的代码中,我们增加了一个全局变量 cleanup 用来存储用户注册的清除函数。然后定义了一个名为 oncleanUp 的方法,该方法非常简单,就是将传入的函数赋值给全局变量 cleanup。在 job 中,执行回调函数 callback 之前,会先判断收否有注册的清除函数 cleanup,如果有,则先执行注册的清除函数。在回调函数 callback 中增加了第三个参数 oncleanUp,该方法提供给用户,以便用户能够注册清除函数。

那么至此,我们就实现了 watch 方法。

最后


本篇我们通过引入调度器的概念来介绍了 Vue 中非常重要的两个功能 computed 和 watch。随着本篇文章的结束,我们关于 Vue 中响应式系统的介绍也就告一段落了。其实 Vue 响应式系统非常复杂,我们在介绍的时候省略了很多细节,只讲解了基本原理,更多细节可以参考 Vue 的源码以及社区关于源码解读的文章。本篇文章的代码请戳。