likes
comments
collection
share

从零开始的手写vue3响应式 —— effect(3)

作者站长头像
站长
· 阅读数 19

前言

前面我们实现了调度器,那么接下来我们会利用这个调度器实现几个常见的api,computedwatch

1、lazy和computed

1.1 lazy

在讲计算属性之前,我们需要先了解下懒执行effect

我们目前设计的 effect 是立即执行的,但是有些时候我们并不希望他立即执行,而是希望它在我们需要的时候执行,就比如 computed。 因此我们可以通过在 options 添加一个懒执行的标识 lazy 来控制。

除了实现让副作用函数不立即执行的功能,我们还有一个问题需要去解决:

副作用函数什么时候执行?

之前实现 scheduler 的时候,我们就实现了 effect 调用完之后的返回值,就能拿到对应的副作用函数。这样我们就能手动调用副作用函数

但是,如果仅仅只是能手动调用副作用函数,这个意义并不是很大。但是如果我们把传递给 effect 的函数看作一个getter,那么这个getter能返回任何值。

比如:

const effectFn = effect(() => obj.foo + obj.bar, { lazy: true });
// value就是getter的返回值
const value = effectFn();

接着,我们来看测试用例:

it("lazy", () => {
    const obj = reactive({ foo: 1 });
    let dummy;
    const runner = effect(() => (dummy = obj.foo), { lazy: true });
    expect(dummy).toBe(undefined);

    expect(runner()).toBe(1);
    expect(dummy).toBe(1);
    obj.foo = 2;
    expect(dummy).toBe(2);
  });

实现:

class ReactiveEffect<T = any> {
  // ...
  run() {
    cleanupEffect(this);
    activeEffect = this;

    effectStack.push(this);
    //  新增
    const value = this.fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];

    //  新增
    return value;
  }
}

export interface ReactiveEffectOptions {
  //  新增
  lazy?: boolean;
  scheduler?: EffectScheduler;
}


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

  if (options) {
    Object.assign(_effect, options);
  }

  //  新增:
  if (!options || !options.lazy) {
    _effect.run();
  }
  // _effect.run();

  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner;
  runner.effect = _effect;

  return runner;
}

从零开始的手写vue3响应式 —— effect(3)

1.2 computed

基于上面实现的实现的可以懒执行的effect,我们可以初步实现 computed的功能。另外,通过 vue3 官方文档知道,我们需要通过 .value 才能拿到其值。

测试用例:

describe("reactivity/computed", () => {
  it("should return updated value", () => {
    const value = reactive({});
    const cValue = computed(() => value.foo);
    expect(cValue.value).toBe(undefined);
    value.foo = 1;
    expect(cValue.value).toBe(1);
  });
});

实现:

export type ComputedGetter<T> = (...args: any[]) => T;
export type ComputedSetter<T> = (v: T) => void;

class ComputedRefImpl<T> {
  private _value!: T;
  public readonly effect: ReactiveEffect<T>;

  constructor(getter: ComputedGetter<T>) {
    this.effect = new ReactiveEffect(getter);
  }

  get value() {
    this._value = this.effect.run();
    return this._value;
  }
}

export function computed<T>(getter: ComputedGetter<T>) {
  return new ComputedRefImpl(getter);
}

从零开始的手写vue3响应式 —— effect(3)

可以看到我们顺利的通过了最基础的测试,即懒执行。每次读取 .value 的时候才会进行计算并得到值

但是只完成了这点可远远称不上是 computed,因为它还有另外一个特点是 缓存。为了实现这点,我们需要加个标识 dirty 来标识是否需要重新计算。

测试用例:

describe("reactivity/computed", () => {
  it("should return updated value", () => {
    const value = reactive({});
    const cValue = computed(() => value.foo);
    expect(cValue.value).toBe(undefined);
    value.foo = 1;
    expect(cValue.value).toBe(1);
  });

  it("should compute lazily", () => {
    const value = reactive({});
    const getter = jest.fn(() => value.foo);
    const cValue = computed(getter);

    // lazy
    expect(getter).not.toHaveBeenCalled();

    expect(cValue.value).toBe(undefined);
    expect(getter).toHaveBeenCalledTimes(1);

    // should not compute again
    cValue.value;
    expect(getter).toHaveBeenCalledTimes(1);

    // should not compute until needed
    value.foo = 1;
    expect(getter).toHaveBeenCalledTimes(1);

    // now it should compute
    expect(cValue.value).toBe(1);
    expect(getter).toHaveBeenCalledTimes(2);

    // should not compute again
    cValue.value;
    expect(getter).toHaveBeenCalledTimes(2);
  });
});

实现:

class ComputedRefImpl<T> {
  private _value!: T;
  //  用来标识是否需要重新计算,true为需要
  public _dirty = true;
  public readonly effect: ReactiveEffect<T>;

  constructor(getter: ComputedGetter<T>) {
    //  新增一个scheduler,用于后面触发时能够重新计算
    this.effect = new ReactiveEffect(getter, () => {
      this._dirty = true;
    });
  }

  get value() {
    //  新增
    if (this._dirty) {
      this._value = this.effect.run();
      this._dirty = false;
    }
    return this._value;
  }
}

从零开始的手写vue3响应式 —— effect(3)

除此之外,我们还有一个问题需要解决:

it("should trigger effect", () => {
    const value = reactive({});
    const cValue = computed(() => value.foo);
    let dummy;
    effect(() => {
      dummy = cValue.value;
    });
    expect(dummy).toBe(undefined);
    value.foo = 1;
    expect(dummy).toBe(1);
});

从零开始的手写vue3响应式 —— effect(3)

通过上面的测试用例发现,如果我们试图在另一个副作用函数中去读取cValue.value,并且计算属性改变期望能够触发副作用函数的执行。但事实上这并没有触发。

通过分析,我们不难看出,computed 只收集了内部的 effect外层的effect不会被内层的响应式数据收集

所以,我们可以通过手动调用的方式,当读取计算属性的值时可以触发track函数收集,当计算属性依赖的响应式数据发生变化的时候,可以通过trigger函数触发响应

class ComputedRefImpl<T> {
  private _value!: T;
  public _dirty = true;
  public readonly effect: ReactiveEffect<T>;

  constructor(getter: ComputedGetter<T>) {
    this.effect = new ReactiveEffect(getter, () => {
      //  新增
      if (!this._dirty) {
        this._dirty = true;
        trigger(this, "value");
      }
    });
  }

  get value() {
    if (this._dirty) {
      this._value = this.effect.run();
      this._dirty = false;
    }
    // 新增
    track(this, "value");
    return this._value;
  }
}

从零开始的手写vue3响应式 —— effect(3)

2. watch

watch 相信大家都并不陌生,它主要是用于检测响应式数据,并数据发生变化的时候可以执行响应的回调。

另外,回调的时候还可以获取到新值和旧值

2.1 观测对象

先看下用法,注意到 watch 要接收两个参数,一个观测的对象,另一个是回调函数。

describe("watch", () => {
  it("for bar", () => {
    const data = reactive({ foo: 1 });
    const cb = jest.fn(() => {
    });
    watch(data, cb);
    //  一开始不会执行
    expect(cb).not.toHaveBeenCalled();
    data.foo++;
    //  触发之后会调用cb
    expect(cb).toHaveBeenCalledTimes(1);
  });
});

实现的思路是把观测对象和副作用函数建立联系,然后 callback 通过effectscheduler执行,之后响应式数据发生变化的时候就会执行callback

为了快速的通过这段用例,我们先对foo 这个属性做一个硬编码。

export function watch(source, cb) {
  // 先对foo做硬编码
  const getter = () => source.foo;
  const scheduler = cb;
  const effect = new ReactiveEffect(getter, scheduler);
  effect.run();
}

接下来的话,就要针对目标对象的不同属性也要做到同样的事情。而且还要包含嵌套的情况。不过目前我们的reactive 还没实现嵌套的功能,所以先暂且搁置。

it("for different property", () => {
    const data = reactive({
      foo: 1,
      bar: 2,
    });
    const cb = jest.fn(() => {});
    watch(data, cb);
    expect(cb).not.toHaveBeenCalled();
    data.foo++;
    expect(cb).toHaveBeenCalledTimes(1);
    data.bar++;
    expect(cb).toHaveBeenCalledTimes(2);
  });

然后实现的思路是,我们需要一个函数去遍历读取对象的每个值,触发依赖收集

function traverse(value, seen = new Set()) {
  // 如果是非对象,null以及被读取过的就不需要再继续了
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 为什么需要记录读取过的值,是因为如果对象是循环引用的,就会造成死循环
  seen.add(value);

  for (const key in value) {
    traverse(value[key], seen);
  }

  return value;
}

export function watch(source, cb) {
  // const getter = () => source.foo;
  // 遍历去读取值
  const getter = () => traverse(source);
  const scheduler = cb;
  const effect = new ReactiveEffect(getter, scheduler);
  effect.run();
}

2.2 观测getter

watch的用法即使有好几种,就比如

const a = reactive({foo: 1});
const b = reactive({foo: 2});
//  默认深度监听
watch(a, () => {});
//  监听getter
watch(() => a.foo, () => {});
//  监听多个响应式对象
watch([a, b], () => {});

仔细观测的话就会发现,这跟我一开始硬编码的情况一样嘛。只不过做个兼容就好了。

export function watch(source, cb) {
  // const getter = () => source.foo;
  // const getter = () => traverse(source);
  let getter;

  if (typeof source === "function") {
    getter = source;
  } else if (typeof source === "object") {
    getter = () => traverse(source);
  } else if (Array.isArray(source)) {
    //  ...
  }

  const scheduler = cb;
  const effect = new ReactiveEffect(getter, scheduler);
  effect.run();
}

2.3 回调当中获取新值与旧值

先看用例

it("should get newVal and oldVal in cb", () => {
    const data = reactive({ foo: 1 });
    let n;
    let o;
    const cb = jest.fn((newVal, oldVal) => {
      n = newVal;
      o = oldVal;
    });
    watch(() => data.foo, cb);
    expect(cb).not.toHaveBeenCalled();
    data.foo++;
    //  触发之后获取新旧值
    expect(cb).toHaveBeenCalledTimes(1);
    expect(n).toBe(2);
    expect(o).toBe(1);
  });

这里的话,我们需要对之前的watch稍微改一下,因为我们需要手动调用effect

然后一开始利用返回的 runner 手动调用取得旧值。然后后续在 scheduler 中继续调用 runner 获取新值,然后在回调中塞进去。

export function watch(source, cb) {
  // const getter = () => source.foo;
  // const getter = () => traverse(source);
  let getter;

  if (typeof source === "function") {
    getter = source;
  } else if (typeof source === "object") {
    getter = () => traverse(source);
  } else if (Array.isArray(source)) {
    //  ...
  }
  
  //  const scheduler = cb; 
  //  const effect = new ReactiveEffect(getter, scheduler); 
  //  effect.run();

  let newVal, oldVal;

  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      newVal = runner();
      cb(newVal, oldVal);
      oldVal = newVal;
    },
  });

  oldVal = runner();
}

2.4 立即执行

立即执行的话其实就是把 scheduler 先执行一次,除此之外没啥特点。

不过要注意的是,立即执行之后,oldValundefined,只有 newVal 才有值。这点要稍微留意一下。

it("should get newVal when immediate", () => {
    const data = reactive({ foo: 1 });
    let n;
    let o;
    const cb = jest.fn((newVal, oldVal) => {
      n = newVal;
      o = oldVal;
    });
    watch(() => data.foo, cb, { immediate: true });
    expect(cb).toHaveBeenCalledTimes(1);
    expect(n).toBe(1);
    expect(o).toBe(undefined);
    data.foo++;
    expect(cb).toHaveBeenCalledTimes(2);
    expect(n).toBe(2);
    expect(o).toBe(1);
  });
interface WatchOptions {
  immediate?: boolean;
}

export function watch(source: any, cb: Function, options: WatchOptions = {}) {
  // const getter = () => source.foo;
  // const getter = () => traverse(source);
  let getter;

  if (typeof source === "function") {
    getter = source;
  } else if (typeof source === "object") {
    getter = () => traverse(source);
  } else if (Array.isArray(source)) {
    //  ...
  }

  let newVal, oldVal;

  const job = () => {
    newVal = runner();
    cb(newVal, oldVal);
    oldVal = newVal;
  };

  const runner = effect(getter, {
    lazy: true,
    scheduler: job,
  });

  if (options.immediate) {
    job();
  } else {
    oldVal = runner();
  }
}

2.5 副作用清除

在vue的官方文档中有这么一段描述:

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

这个出现的背景我们来稍微解释一下,如果你通过 watch 发起了两次网络请求,第一次请求返回了数据A,第二次返回了数据B,但是B比A先返回了。实际上你需要的是最新的数据B,这时候A就要被抛弃,不然会先出现竞态问题。

然后我们来做一个稍微复杂点的单测:

it("should cleanup", () => {
    jest.useFakeTimers();
    let finalData;

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

    const fn = jest.fn((res) => {
      finalData = res;
    });
    
    //  模拟请求
    function mockRequest(time = 100) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(time);
        }, time);
      });
    }

    watch(
      () => data.foo,
      async (newVal, oldVal, onInvalidate) => {
        let expired = false;
        onInvalidate(() => {
          expired = true;
        });

        const res = await mockRequest((4 - newVal) * 100);
        
        //  如果没有过期的会,就会调用
        if (!expired) {
          fn(res);
          expect(fn).toBeCalledTimes(1);
          expect(finalData).toEqual(0);
        }
      }
    );
    
    //  总共调用三次,最后一次调用是最快返回的
    data.foo++;
    setTimeout(() => {
      data.foo++;
    }, 50);
    setTimeout(() => {
      data.foo++;
    }, 100);

    jest.runAllTimers();
  });

预期的流程是这样的:

请求A -> expiredA = false;
请求B -> expiredB = false; expiredA = true; 
请求C -> expiredC = false; expiredB = true; expiredA = true; 

响应C -> expiredC = false -> 赋值
响应B -> expiredB = true -> 抛弃
响应A -> expiredA = true -> 抛弃

我们需要做的就是要把用户传进来的第三个回调函数存起来。然后在每次调用cb之前再调用一次。用这边的例子来举例就是,第N次调用的时候,它会先把第N-1次的onInvalidate触发,然后再执行watch的callback,这样就不会影响到自己。

export function watch(source: any, cb: Function, options: WatchOptions = {}) {
  // const getter = () => source.foo;
  // const getter = () => traverse(source);
  let getter;

  if (typeof source === "function") {
    getter = source;
  } else if (typeof source === "object") {
    getter = () => traverse(source);
  } else if (Array.isArray(source)) {
    //  ...
  }

  let newVal, oldVal;

  //  用来存储用户注册的过期回调
  let cleanup;

  function onInvalidate(fn) {
    cleanup = fn;
  }

  const job = () => {
    newVal = runner();

    //  第二次开始才会触发
    if (cleanup) {
      cleanup();
    }

    //  第一次触发的时候只进行赋值
    cb(newVal, oldVal, onInvalidate);
    oldVal = newVal;
  };

  const runner = effect(getter, {
    lazy: true,
    scheduler: job,
  });

  if (options.immediate) {
    job();
  } else {
    oldVal = runner();
  }
}

从零开始的手写vue3响应式 —— effect(3)

3. 总结

至此,effect 这部分已经算结束了。可以看到 effect 函数与响应式数据结合十分的巧妙,另外它的可调度性对 computedwatch 十分的重要,两者都依赖于它来实现。

转载自:https://juejin.cn/post/7151440894472749070
评论
请登录