Vue3源码学习2——响应式(computed、watch)

152 阅读8分钟

继上一篇reactive和ref的响应式之后,这一篇来参考Vue3源码,实现简易版本的computed和watch

computed(计算并带有缓存的响应式)

使用方法

Vue3中,我们一般针对响应式数据生成computed计算属性,一般用一个带返回值的回调函数表示

当对应的属性值变化的时候,计算属性被触发,并返回新的值

const { reactive, effect, computed } = Vue;

const obj = reactive({
  name: "张三",
});

const computedObj = computed(() => {
  return "姓名:" + obj.name;
});

effect(() => {
  document.querySelector("#app").innerText = computedObj.value;
});

setTimeout(() => {
  obj.name = "李四";
}, 2000);

分析

effect和响应式reactive在之前都已经实现了,现在剩下的就是三个问题

  • computed在初次创建时候调用
  • computed中涉及的响应式数据的key对应的值变化时,重新计算,调用副作用函数

前两点都和effect类似,目前看来可以参考effect的实现

至于computed怎么知道响应式的值变化,也是需要关注的一个点,即缓存性

源码简化版实现

创建&初次调用

computed的实现使用了ComputedRefImpl类,并且在创建的时候保证传入的getter方法一定是函数

export function computed(getterOrOptions) {
  let getter;

  // 这里确保computed传入的参数一定是function
  const onlyGetter = isFunction(getterOrOptions);

  if (onlyGetter) {
    getter = getterOrOptions;
  }

  const cRef = new ComputedRefImpl(getter);
  return cRef;
}

computed的初次调用比较简单,直接可以参考effect的依赖收集,读取value直接调用一次effect方法即可

export class ComputedRefImpl<T> {
  public dep?: Dep = undefined;
  private _value!: T;

  public readonly effect: ReactiveEffect<T>;
  public readonly __v_isRef = true;

  constructor(getter) {
    this.effect = new ReactiveEffect(getter);
    this.effect.computed = this;
  }

  get value() {
    trackRefValue(this);

    this._value = this.effect.run();

    return this._value;
  }
}

调度器&脏状态

实现computed的响应性,Vue3源码中使用了调度器,在ReactiveEffect里添加了第二个参数sheduler

export class ReactiveEffect<T = any> {
  computed?: ComputedRefImpl<T>;

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null
  ) {}
  
  ......
 }

调度器的意义在于触发依赖的时候会有一次判断,如果有调度器,则触发调度器,否则触发普通的依赖

export function triggerEffect(effect: ReactiveEffect) {
  if (effect.scheduler) {
    effect.scheduler();
  } else {
    effect.run();
  }
}

除了调度器,computed还添加了一个脏状态,初始是true

触发get方法的时候,因为脏状态为true,会调用一次effect,并将脏状态调整为false,方便下次set的时候调用调度器

脏状态流程.png

export class ComputedRefImpl<T> {
  ......
  
  public _dirty = true

  constructor(getter) {
    this.effect = new ReactiveEffect(getter, () => {
        if (!this._dirty) {
            this._dirty = true;
            triggerRefValue(this);
        }
    });
    ......
  }

  get value() {
    ......

    if (this._dirty) {
      this._dirty = false;
      this._value = this.effect.run();
    }

    return this._value;
  }
}

缓存性

computed的缓存性意味着,如果有多次修改数据,effect应该将它们合并,只取最后一次的修改

但是就目前的代码实现,如果我们在effect中做两次DOM操作,则会陷入死循环

死循环的原因

出现这个死循环主要是因为triggerEffects时,effect包括了计算属性非计算属性的。

当我们执行计算属性的effect,会调用scheduler并更改dirty的状态,而因为我们DOM操作使用了computed的value,在get方法中又有一次effect的执行,这里又遍历执行了所有的effects,也包括计算属性非计算属性的,也就是在这里出现了死循环

避免死循环

当我们知道了死循环是因为effect重复执行了计算属性,那我们要做的就是把计算属性的effect和非计算属性的effect分开执行先计算属性,后非计算属性,即可成功处理

export function triggerEffects(dep: Dep) {
  // 先执行计算属性的effect
  Array.from(dep).forEach((effect) => {
    effect.computed && triggerEffect(effect);
  });

  // 再执行非计算属性的effect
  Array.from(dep).forEach((effect) => {
    !effect.computed && triggerEffect(effect);
  });
}

小结

computed的核心包括几个部分

  • 调度器:依赖于triggerEffect里的第二个参数scheduler_dirty脏数据状态
  • 依次执行effect:先执行计算属性的effect,后执行非计算属性的,防止死循环

watch(数据监听)

使用方法

Vue3中,watch是用来动态监听数据变化的,这一点和computed有点像

但是不一样的是,当变化的时候,我们可以拿到数据的新值老值,同时做一些副作用操作

此外,我们还可以根据自己的需要,配置是否深度监听数据(例如针对对象/数组),或者是否在数据初始化的时候就执行一次副作用

const { reactive, effect, watch } = Vue;

const obj = reactive({
  name: "张三",
});

watch(
  // 因为obj是响应式数据,所以不用函数返回
  obj.name,
  (value, oldValue) => {
    console.log("watch run");
    console.log(`value is ${JSON.stringify(value)}`);
  },
  {
    immediate: true,
  }
);

setTimeout(() => {
  obj.name = "李四";
}, 2000);

分析

watch分为三个步骤

  1. 创建响应式数据
  2. 修改响应式数据
  3. 触发watch的监听副作用

这里主要关心的应该是如何触发,以及watch的一些配置项(深度监听、初次创建时候立即调用)

其中初次创建时候立即调用,computed里面用的是_dirty脏数据状态实现的,这里可以考虑下是否也一样

源码简化版实现

调度器scheduler

在阅读watch源码后发现,和computed对比发现,代码中也包含了调度器scheduler

调度器主要包括两个部分

  1. 懒执行
  2. 调度器本身
懒执行

懒执行很简单,只要添加一个lazy参数就行,如果lazy则不立即执行effect副作用

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  const _effect = new ReactiveEffect(fn);

  if (!options || !options.lazy) {
    // 完成第一次run执行
    _effect.run();
  }
}
调度器

调度有点像交通里的红绿灯,起到控制执行顺序逻辑的作用

调整执行顺序

在JS里,代码的执行是单线程的,按照从上往下的顺序依次执行,如果想改变执行的顺序,就得使用JS任务队列,将某些语句调整成微任务/宏任务

watch的源码里的scheduler用了一个job方法,job方法里的一个核心就是把原来的同步代码封装成了Promise.resolve的微任务

例如,我们有一个响应式数据,写了如下代码

const { reactive, effect, queuePreFlushCb } = Vue;

const obj = reactive({ count: 1 });

effect(() => {
    console.log(obj.count);
})

obj.count = 2;

console.log("代码结束");

按照effect的调用逻辑和js的单线程,运行结果应该是1、2、代码结束

如果希望按照1、代码结束、2这样的顺序执行(即triggerEffect最后执行),则可以在effect中添加一个scheduler并放入一个异步方法(例如setTimeout

因为执行effect的时候,如果有scheduler,则执行scheduler,否则才是执行effect本身

effect(
  () => {
    console.log(obj.count);
  },
  {
    scheduler: () => {
      setTimeout(() => {
        console.log(obj.count);
      }, 1000);
    },
  }
);

为了让我们自己传入的scheduler可以合并到effect中,我们需要做一个合并操作,把传入的options中的值和原先的合并

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  ......
  
  if (options) {
    // extend本质是Object.assign方法
    extend(_effect, options);
  }

  ......
}
调整执行规则

这里的执行规则指的是合并多次effect副作用

例如,如果我们想多次改变响应式数据,我们其实只关心最开始的值最后一次改变之后的值,至于中间的值我们并不在意

阅读源码可以发现,使用一个封装的queuePreFlushCb方法,可以实现忽略中间的数值改变

effect(
  () =>
    console.log(obj.count);
  },
  {
    scheduler: () => {
      queuePreFlushCb(() => console.log("scheduler", obj.count));
    },
  }
);

看一下queuePreFlushCb可以发现,这个方法是把传入的方法存入一个队列中,然后循环把方法用Promise.resolve.then执行,即把同步方法转成微任务,放在所有同步方法之后

因为修改数据的方法是同步的,所以自然在执行的时候,已经是最后一次改变了数值之后的最新值了,中间的值不会输出

const resolvedPromise = Promise.resolve() as Promise<any>;

export function queuePreFlushCb(cb: Function) {
  queueCb(cb, pendingPreFlushCbs);
}

function queueCb(cb: Function, pendingQueue: Function[]) {
  pendingQueue.push(cb);
  queueFlush();
}

function queueFlush() {
  if (!isFlushPending) {
    isFlushPending = true;
    currentFlushPromise = resolvedPromise.then(flushJobs);
  }
}

watch基本框架

watch基本的包括三部分

  1. 响应式数据
  2. 回调函数(参数包括老值和新值)
  3. 配置项(懒加载、深度监听等等)

参考Vue3源码,可以搭建这样一个框架流程

export interface WatchOptions<immediate = boolean> {
  immediate?: immediate;
  deep?: boolean;
}

// 源码中watch的本质就是doWatch方法
export function watch(source, cb: Function, options?: WatchOptions) {
  return doWatch(source, cb, options);
}

function doWatch(
  source,
  cb: Function,
  { immediate, deep }: WatchOptions = EMPTY_OBJ
) {
  let getter: () => any;

  // 如果是响应式数据,getter返回的是响应式数据的值
  if (isReactive(source)) {
    getter = () => source;
    deep = true;
  } else {
    getter = () => {};
  }

  if (cb && deep) {
    // 这个地方理解成浅拷贝即可
    const baseGetter = getter;
    getter = () => baseGetter();
  }

  let oldValue = {};

  // 本质上为了拿到newValue
  const job = () => {
    // 如果有回调方法,要触发该方法,并且通过执行一次effect拿到新的值,并把新值老值做更新
    if (cb) {
      const newValue = effect.run();
      if (deep || hasChanged(newValue, oldValue)) {
        cb(newValue, oldValue);
        oldValue = newValue;
      }
    }
  };

  // 调度器
  let scheduler = () => queuePreFlushCb(job);

  const effect = new ReactiveEffect(getter, scheduler);

  /* 
   * 如果有回调函数,需要立即执行,直接执行一次job操作回调,不然的话只更新老值
   * 如果没有回调函数,只执行一次副作用
   */
  if (cb) {
    if (immediate) {
      job();
    } else {
      oldValue = effect.run();
    }
  } else {
    effect.run();
  }

  return () => {
    effect.stop();
  };
}

但是如果运行这个框架,还会有一个问题,就是当修改值的时候,没有触发watch的监听effect

没有触发的核心原因是:没有一个收集依赖的地方

watch的依赖收集

对于响应式数据,依赖收集其实本质上是依靠get方法触发的,所以在Vue源码中,可以看到一个叫traverse的方法,这个方法不做任何处理,只是对传入的值以及其中的每一个属性做读取,从而达到收集依赖的目的

export function traverse(value: unknown) {
  if (!isObject(value)) {
    return value;
  }

  for (const key in value as object) {
    traverse((value as object)[key]);
  }

  return value;
}

相应的,可以在watch的getter中做一下traverse

function doWatch(
  source,
  cb: Function,
  { immediate, deep }: WatchOptions = EMPTY_OBJ
) {
  let getter: () => any;

  ......

  if (cb && deep) {
    const baseGetter = getter;
    getter = () => traverse(baseGetter());
  }
  
  ......
}

这样watch就可以被响应式数据的更改触发了

小结

watch的核心包括

  • 调度器:核心是queuePreFlushCb改变执行调度逻辑,并通过一些逻辑判断执行策略
  • 依赖收集:通过遍历原先响应式数据的getter收集依赖