Starting from single testing, improve the underlying API effect function in the Vue3 source code

Based on the effect method implemented in the previous article, based on the single test in the Vue3 source code, three functions of this method are improved, namely:

  1. runner: effect can return a self-executing parameter runner function
  2. scheduler: effect supports adding the scheduler function in the second parameter option
  3. stop: effect Adds stop function

runner

Single test

Add test cases about runner in the effect.spec.ts file.

it("should be return runner when call effect", () => {<!-- -->
  let foo = 1;
  const runner = effect(() => {<!-- -->
    foo + + ;
    return "foo";
  });

  expect(foo).toBe(2);

  const r = runner();
  expect(foo).toBe(3);
  expect(r).toBe("foo");
});

The meaning of the above test case is that the function inside effect will execute itself once, and the value of foo becomes 2. effect is an executable function runner. When runner is executed, the effect internal function will also be executed, so The value of foo will increment to 3 again, and the return value of runner is the return value of the effect internal function.

Implementation

The effect function needs to be able to return its input parameter execution function, and the internal execution function can return.

class ReactiveEffect {<!-- -->
  private _fn: any;
  constructor(fn) {<!-- -->
    this._fn = fn;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }
}

export function effect(fn) {<!-- -->
  let _effect = new ReactiveEffect(fn);
  _effect.run();

  const runner = _effect.run.bind(_effect)
  return runner;
}

It should be noted that there is a problem pointed by this, and the current instance needs to be bound when returning the _effect.run function.

Verification

Execute yarn test effect

scheduler

Single test

it("scheduler", () => {<!-- -->
  let dummy;
  let run: any;
  const scheduler = jest.fn(() => {<!-- -->
    run = runner;
  });
  const obj = reactive({<!-- --> foo: 1 });
  const runner = effect(
    () => {<!-- -->
      dummy = obj.foo;
    },
    {<!-- -->
      scheduler,
    }
  );
  expect(scheduler).not.toHaveBeenCalled();
  expect(dummy).toBe(1);

  // should be called on first trigger
  obj.foo + + ;
  expect(scheduler).toHaveBeenCalled();
  // should not run yet
  expect(dummy).toBe(1);
  // manually run
  run();
  // should have run
  expect(dummy).toBe(2);
});

The meaning of the above test case code is: the effect method receives the second parameter, which is an option list object, one of which is scheduler, which is a function. Here, jest.fn is used to simulate a function that assigns the variable run to the runner function. During the first execution, the scheduler function is not called and executed. The first parameter function of effect is executed automatically, so dummy is assigned a value of 1. ;When the responsive object changes, that is, when obj.foo + + , scheduler will be executed, but the value of dummy is still 1, It means that the first parameter function is not executed; run is executed, that is, when the effect return function runner is executed, the first parameter function is executed. Because obj.foo + + , dummy becomes 2.

The requirements included in scheduler can be summarized:

  1. Give a fn of a scheduler through the second parameter of effect
  2. When effect is executed for the first time, the first parameter function is executed.
  3. When the reactive object triggers the set operation, the function will not be executed, but the scheduler will be executed.
  4. When runner is executed, function will be executed again

Implementation

The first is that the effect function can receive a second object parameter.

export function effect(fn, options: any = {<!-- -->}) {<!-- -->
  let _effect = new ReactiveEffect(fn, options.scheduler);
  _effect.run();

  const runner = _effect.run.bind(_effect)
  return runner;
}

The Class class must also receive scheduler accordingly.

class ReactiveEffect {<!-- -->
  private _fn: any;
  public scheduler: Function | undefined;
  constructor(fn, scheduler) {<!-- -->
    this._fn = fn;
    this.scheduler = scheduler;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }
}

When the responsive object triggers the set operation, that is, when the dependency is triggered, in the trigger method, execute scheduler and only need to determine whether there is scheduler, executed when it exists.

export function trigger(target, key) {<!-- -->
  let depMap = targetMap.get(target);
  let dep = depMap.get(key);
  for (const effect of dep) {<!-- -->
    if (effect.scheduler) {<!-- -->
      effect.scheduler();
    } else {<!-- -->
      effect.run();
    }
  }
}

Verification

stop

Single test

import {<!-- --> effect, stop } from "../reactivity/effect";

it("stop", () => {<!-- -->
  let dummy;
  const obj = reactive({<!-- --> prop: 1 });
  const runner = effect(() => {<!-- -->
    dummy = obj.prop;
  });
  obj.prop = 2;
  expect(dummy).toBe(2);
  stop(runner);
  obj.prop = 3;
  expect(dummy).toBe(2);

  // stopped effect should still be manually callable
  runner();
  expect(dummy).toBe(3);
});

it("onStop", () => {<!-- -->
  const onStop = jest.fn();
  const runner = effect(() => {<!-- -->}, {<!-- --> onStop });
  stop(runner);
  expect(onStop).toHaveBeenCalled();
});

The stop function has two test cases, corresponding to different functions. Let’s analyze them one by one.

In "stop", the function within effect executes itself once, so the first assertion is that dummy is the value 2 assigned above; execute stop method, stop method is an externally exposed method from effect. It receives the runner function as a parameter, even if the response is updated formula object, the function inside effect is not executed, and dummy is still 2; execute runner again to resume execution of effect Inner function, dummy becomes 3.

In summary, stop can prevent the execution of functions within effect.

In "onStop", the effect function receives the second parameter. There is a property in the object which is onStop, and it receives a function. When executed When stop, the onStop function will be executed.

Implementation

When triggering dependencies, the trigger method loops through all effect methods in dep. If you need to prevent execution, you can start from dep to delete the item.

First, the stop method receives the runner function as a parameter.

export function stop(runner) {<!-- -->
  runner.effect.stop();
}

By mounting an effect instance on the runner function, you can obtain the stop method defined in the Class class.

class ReactiveEffect {<!-- -->
  private _fn: any;
  public scheduler: Function | undefined;
  constructor(fn, scheduler) {<!-- -->
    this._fn = fn;
    this.scheduler = scheduler;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }
  stop() {<!-- -->}
}

export function effect(fn, options: any = {<!-- -->}) {<!-- -->
  let _effect = new ReactiveEffect(fn, options.scheduler);
  extend(_effect, options);

  _effect.run();

  const runner: any = _effect.run.bind(_effect);
  runner.effect = _effect; // Mount effect instance

  return runner;
}

So how to delete an item from dep that needs to be prevented from execution?

In the track method, dep.add(reactiveEffect) creates the dep Set structure and effect instance relationship, but there is no mapping relationship between instances and dep in the Class class, so you can define a Class class The code>deps array is used to store all dep of the instance. When the stop method needs to be called, the dep will be deleted. effect instance method.

class ReactiveEffect {<!-- -->
  private _fn: any;
  public scheduler: Function | undefined;
  deps = [];
  constructor(fn, scheduler) {<!-- -->
    this._fn = fn;
    this.scheduler = scheduler;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }

  stop() {<!-- -->
    this.deps.forEach((dep: any) => {<!-- -->
      dep.delete(this);
    });
  }
}

export function track(target, key) {<!-- -->
  ...
  dep.add(reactiveEffect);
reactiveEffect.deps.push(dep); // Store deps
}

Verification

Optimization

Although the single test passed, there is room for optimization in the code. Let’s refactor it.

The logic in the stop method can be extracted into a separate function.

class ReactiveEffect {<!-- -->
...
  stop() {<!-- -->
    cleanupEffect(this);
  }
}

function cleanupEffect(effect) {<!-- -->
  effect.deps.forEach((dep: any) => {<!-- -->
    dep.delete(effect);
  });
}

For performance optimization, when the user keeps calling the stop method, it will cause the loop to be traversed for no reason. Therefore, a flag bit can be set to determine whether the delete operation has been called and executed.

class ReactiveEffect {<!-- -->
  private _fn: any;
  public scheduler: Function | undefined;
  deps = [];
  active = true;
  constructor(fn, scheduler) {<!-- -->
    this._fn = fn;
    this.scheduler = scheduler;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }
  stop() {<!-- -->
    if (this.active) {<!-- -->
      cleanupEffect(this);
      this.active = false;
    }
  }
}

After refactoring, you need to perform unit testing again to ensure that the function is not damaged.

Implementation

To implement the second function onStop of stop.

First, mount the onStop method on the effect instance.

export function effect(fn, options: any = {<!-- -->}) {<!-- -->
  let _effect = new ReactiveEffect(fn, options.scheduler);
  _effect.onStop = options.onStop

  _effect.run();

  const runner: any = _effect.run.bind(_effect);
  runner.effect = _effect;

  return runner;
}

When stop is executed, the onStop function will be executed.

class ReactiveEffect {<!-- -->
  private _fn: any;
  public scheduler: Function | undefined;
  deps = [];
  active = true;
  onStop?: () => void;
  constructor(fn, scheduler) {<!-- -->
    this._fn = fn;
    this.scheduler = scheduler;
  }
  run() {<!-- -->
    reactiveEffect = this;
    return this._fn();
  }

  stop() {<!-- -->
    if (this.active) {<!-- -->
      cleanupEffect(this);
      if (this.onStop) {<!-- -->
        this.onStop();
      }
      this.active = false;
    }
  }
}

Verification

Optimization

The second parameter options of the effect method may have many options, and each time it is mounted to the instance through _effect.onStop = options.onStop It is not elegant, so this piece of logic can be extracted and used as a public method.

Create a new folder shared under src and create a new index.ts

export const extend = Object.assign;

Then in effect, you can use the extend method to express it more semantically.

export function effect(fn, options: any = {<!-- -->}) {<!-- -->
  let _effect = new ReactiveEffect(fn, options.scheduler);
  extend(_effect, options);

  _effect.run();

  const runner: any = _effect.run.bind(_effect);
  runner.effect = _effect;

  return runner;
}

After refactoring, execute yarn test effect again to verify whether the function is damaged.

Verification

Finally, all unit tests need to be executed to verify whether the new functions damage the original code. Execute yarn test

When executing the reactive single test, the above error occurred, indicating that reactiveEffect may be undefined and does not exist deps.

In reactive.spec.ts, the core functions of reactive are simply tested. At this time, the effect method is not involved. The assignment of reactiveEffect is triggered when effect executes itself, so it is in the initial undefined state.

export function track(target, key) {<!-- -->
  ...
  if (!reactiveEffect) return; // Boundary processing
  dep.add(reactiveEffect);
  reactiveEffect.deps.push(dep);
}

Finally, it was verified again, the test passed, and the function was successfully completed.

Updated on 2023/11/13

Modify stop single test

Based on the original, modify the stop test case in effect.

it("stop", () => {
  let dummy;
  const obj = reactive({ prop: 1 });
  const runner = effect(() => {
    dummy = obj.prop;
  });
  obj.prop = 2;
  expect(dummy).toBe(2);
  stop(runner);

  // obj.prop = 3;
  obj.prop + + ;
  expect(dummy).toBe(2);

  // stopped effect should still be manually callable
  runner();
  expect(dummy).toBe(3);
});

Run single test yarn test effect

Error report analysis

Briefly analyze the reasons for the error.

obj.prop + + can be understood as obj.prop = obj.prop + 1, there are get and set Two operations, triggering the get operation will re-collect dependencies, causing the cleanupEffect method in stop to delete all effect and become invalid.

Implementation

Knowing that the root cause is that the get operation is triggered first and the function in effect is re-executed, that is, the track method is called. The logic that needs to be improved should be Start this way. We can define a global variable shouldTrack to determine whether the track operation is required.

let reactiveEffect;
let shouldTrack; // definition

export function track(target, key) {
  ...
if(!shouldTrack) return // Direct return without dependency collection
  if (!reactiveEffect) return;
  dep.add(reactiveEffect);
  reactiveEffect.deps.push(dep);
}

When assigning a value, the set operation is triggered, the trigger function is executed, and the final call is the run method in the Class class ReactiveEffect . The run method originally directly returns the execution result of the parameter function. Here you need to judge the situation of stop, which can be judged based on active .

If the active value is assigned to false after calling the stop method, then fn will be returned directly;

If the stop method is not called, first set shouldTrack to true, which means that track can be called, and then execute fn, and return the execution result, but a reset operation is required before returning. Set shouldTrack to false, because if stop, the run function will directly return and will not set shouldTrack to true , then when track, it will go to !shouldTrack and directly return and no longer collect dependencies.

run() {
  if (!this.active) {
    return this._fn();
  }

  shouldTrack = true;
  reactiveEffect = this;

  const result = this._fn();
  shouldTrack = false;

  return result;
}

Refactoring

For boundary judgment of shouldTrack and reactiveEffect in track, you can refer to the top of the track function body and encapsulate a separate function synthesis these two judgments.

Dependency collection can be optimized here. When reactiveEffect exists in dep, it will no longer be collected repeatedly.

export function track(target, key) {
  if (!isTracking()) return;

  ...

  if (dep.has(reactiveEffect)) return;
  dep.add(reactiveEffect);
  reactiveEffect.deps.push(dep);
}

function isTracking() {
  return shouldTrack & amp; & amp; reactiveEffect !== undefined;
}

Debugging

Modify the single test and use a simpler single test to clearly see the above process through debugging.

it("stop", () => {
  let dummy;
  const obj = reactive({ prop: 1 });
  const runner = effect(() => {
    dummy = obj.prop;
  });
  stop(runner);
 
  obj.prop + + ;
  expect(dummy).toBe(1);
});

Here is a video explanation for a more vivid understanding. View the video details.