Reactive and Effect

Mini -vue3 synchronization code implementation click here

For all the mini-Vue3 articles click here

Directory creation

In the SRC directory, create a reActivity folder that holds code related to responsiveness. Create the Test folder under the ReActivity folder, which is used to hold the test code. In the Reactivity folder, create the effect.ts file and react. ts file.

Implement reactive

The listening data is changed. Procedure

In Vue3, reactive returns a proxy object, and when the properties of that object change, VUE knows that the change has taken place and notifys the corresponding view of the update. So if you want the data to change, the view will change. The most important thing is that we need to be able to listen for changes in data, and ES6’s ProxyAPI does just that.

// reactive.ts
export function reactive(raw) {
    return new Proxy(raw, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver)
            // This is where the key property of the target object is accessed
            // TODO relies on collection
            // track()
            return res
        },
        set(target, key, newValue, receiver) {
            // When the key property of the target object is modified, it comes here
            const res = Reflect.set(target, key, newValue, receiver)
            // TODO triggers dependencies
            // trigger()
            return res
        }
    })
}
Copy the code

The ProxyAPI returns a proxy object that can be modified to map to the raw object. We can do dependency collection in the getter function that is triggered when accessing a property of the proxy object; When modifying a value of the proxy object triggers a setter action, we can trigger dependencies in that setter function.

test

Create the reactive. Spec. ts file in the test directory

/ / reactive. Spec. Ts file
import { reactive } from ".. /reactive";

describe("reactive".() = > {
  it("happy path".() = > {
    const obj = {
      age: 18};const proxyObj: any = reactive(obj);
    // The object returned is not the same object as the object passed in
    expect(obj).not.toBe(proxyObj);
    // Modify the proxy object
    proxyObj.age++;
    // The age property of the proxy object is 19
    expect(proxyObj.age).toBe(19);
    // The age property of the original object obj also becomes 19
    expect(obj.age).toBe(19);
  });
});
Copy the code

Run yarn test and find that the test passes. There is no problem with the code.

Implement dependency collection and trigger dependencies

Before implementing dependency collection and triggering dependencies, you need to know what a dependency is. Reactive functions are now able to listen to data being fetched and modified. Once the data has been modified, we need to re-execute functions that depend on that data so that the data changes and the view changes. We can simply refer to functions that need to be re-executed when data changes as dependencies. We need to collect these functions so that we can tell them to re-execute when the data changes.

To collect dependencies, you must choose an appropriate data structure to store them. When the data changes, we can get the object that changed and the key property of that object that actually changed. So we can use a WeakMap to save the dependency. We can take the object with data changes as the key of WeakMap, and the corresponding Value is a Map object. The key of the Map is the property of the listener, and the value is the dependency of the collected property.

Having solved the problem of relying on the selection of data structures collected, we still have one problem left unsolvedHow do we get the currently executing function?When a function accesses properties of a reactive object, getter operations are performed. How do we get the currently executing function from getter operations? At this point we can use a global variableactiveEffectTo help us implement getting the currently executing function. We simply assign the function to the global variable before executing itactiveEffect, so that when we do the getter, we can putactiveEffectCollected as dependencies.

/ / effect. Ts file
// Store dependent weakMap
const targetMap = new WeakMap(a);// Save the global variables of the currently executing function
let activeEffect = null;

export function track(target, key) {
  // Get the map of the object through target
  let depsMap = targetMap.get(target);
    
  if(! depsMap) {// If there is no depsMap, create one and save it in targetMap
    depsMap = new Map(a); targetMap.set(target, depsMap); }// Get the deP object from depsMap by key
  let dep = depsMap.get(key);

  if(! dep) {// If dep is undefined, create one and save it to depsMap
    dep = new Set(a); depsMap.set(key, dep); }// If the current activeEffect has a value, the activeEffect is collected as a dependency
  if(activeEffect) { dep.add(activeEffect); }}export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if(! depsMap)return;
  const dep = depsMap.get(key);
  if(! dep)return;
  // Iterate over all dependencies in the DEP, then execute
  for (const effect of dep) {
    // In later implementations, the collected dependencies will have run methods, which execute the functions that really need to be executedeffect.run(); }}Copy the code

Once you’ve implemented track and trigger, you need to call track in the getter and trigger in the setter

// reactive.ts
import { track, trigger } from "./effect";

export function reactive(raw) {
  return new Proxy(raw, {
    get(target, key, receiver) {
        // Collect dependencies
      track(target, key);

      return Reflect.get(target, key, receiver);
    },
    set(target, key, newValue, receiver) {
      const res = Reflect.set(target, key, newValue, receiver);
      // Trigger dependencies
      trigger(target, key);
      returnres; }}); }Copy the code

Implement effect function

The initial function to be realized by the effect function is shown by testing. Create the effect.spect.ts file in the test directory

// effect.spec.ts
import { reactive } from ".. /reactive";
import { effect } from ".. /effect";

describe("effect".() = > {
  it("happy path".() = > {
    const obj = reactive({
      age: 18});let nextAge;
    effect(() = > {
      nextAge = obj.age + 1;
    });
    // Parameters passed to the effect function are executed.
    expect(nextAge).toBe(19);
    // When the reactive object changes
    obj.age++;
    // The parameters passed to the effect function are re-executed
    expect(nextAge).toBe(20);
  });
});
Copy the code
// effect.ts
let activeEffect: ReactiveEffect;
class ReactiveEffect {
  // This parameter is used to save the fn parameter of the effect function
  private _fn;
  constructor(fn) {
    this._fn = fn;
  }

  run() {
    // Assign activeEffect to the current effect before executing the function so that dependencies can be collected later
    activeEffect = this;
    return this._fn(); }}export function effect(fn) {
  When the effect function is executed, an instance of ReactiveEffect is created
  const _effect = new ReactiveEffect(fn);
  // Execute the instance's run method
  _effect.run();
}
Copy the code

The effect function does two main things: it creates an instance of ReactiveEffect and calls the run method of that instance. The run method assigns the current effect instance to the global activeEffect variable before executing the passed FN function. During the execution of fn function, the getter operation is triggered because obj. Age is accessed, and the track function is executed in the getter operation for dependency collection. And when we change the value of obj. Age, that will trigger the setter, which will trigger the trigger function, which will re-execute the dependency function, fn, so nextAge will be 20.

Yarn test After the test case is executed, the current logic is normal.

Implement the effect function to return runner

The effect function returns a Runner function, which is essentially the run method of the effect instance.

// effect.spec.ts
   it("runner".() = > {
    The effect function returns a runner function, which is actually the run method of the effect instance
    let foo = 10;
    const runner: any = effect(() = > {
      foo++;
      return "foo";
    });
    expect(foo).toBe(11);

    const r = runner();

    expect(r).toBe("foo");
    expect(foo).toBe(12);
  });
Copy the code
// effect.ts.export functino effect(fn){...// Use bind to bind this in the run method to an _effect instance
    const runner = _effect.run.bind(_effect)
    return runner
}
Copy the code

Implement the effect function passed into the scheduler

The effect function can take the first options argument. If options has a scheduler property and the property is a function, the first effect fn argument is executed the first time, and then the scheduler function is re-executed if the reactive data changes, rather than the first effect FN argument.

// effect.spec.ts. it("scheduler".() = > {
    When scheduler arguments are passed, the first execution is still the first effct argument fn
    // Scheduler functions are executed when data changes
    let dummy;
    let run: any;
    // Define a scheduler function
    const scheduler = jest.fn(() = > {
      run = runner;
    });
    const obj = reactive({ foo: 1 });
    const runner = effect(
      () = > {
        dummy = obj.foo;
      },
      {
        scheduler,
      }
    );
    // The scheduler is not called at first
    expect(scheduler).not.toHaveBeenCalled();
    // the effect function's first argument, fn, is called
    expect(dummy).toBe(1);

    // When obJ sends changes
    obj.foo++;
    // Call the scheduler function once
    expect(scheduler).toHaveBeenCalledTimes(1);
    // The first argument of effect will not be executed
    expect(dummy).toBe(1);
    // The run function is actually the runner function that returns
    run();
    expect(dummy).toBe(2);
  });
Copy the code
// effect.ts
class ReactiveEffect {
    private _fn
    constructor(fn, public scheduler) {
        this._fn = fn
        this.scheduler = scheduler
    }
    ...
}

export function effect(fn, options: any = {}) {
    const _effect = new ReactiveEffect(fn, options.scheduler)
    ...
}

export function trigger(target, key) {...for (const effect of dep) {
        // When a dependency is triggered, determine if the scheduler is a function. If so, execute effect.
        // If not, execute the effect.run method
        if (typeof effect.scheduler === 'function') {
            effect.scheduler()
        } else {
            effect.run()
        }
    }
}

Copy the code

To implement a scheduler function, first add a scheduler instance attribute to the ReactiveEffect class. When the Effect function is called and the scheduler method is passed, Pass in the Scheduler method as a ReactiveEffect parameter and save it. When trigger is executed, it determines whether the effect’s scheduler attribute is a function. If so, the function is executed. If not, the run method is normally executed.

Implement the stop function

The stop function receives a runner. After calling the stop function, the first parameter fn of the effect function will not be re-executed when the responsive data that Runner depends on changes again. In other words, we need to eliminate the effect of runner in all DEP dependencies.

// effect.spec.ts
it("stop".() = > {
    let dummy;
    const obj = reactive({ prop: 1 });
    const runner = effect(() = > {
      dummy = obj.prop;
    });
    // Obj. prop changes before stop
    obj.prop = 2;
    // dummy also changes accordingly
    expect(dummy).toBe(2);
    // After calling stop
    stop(runner);
    // dummy does not change when obj.prop is changed
    obj.prop = 3;
    expect(dummy).toBe(2);
    runner();
    expect(dummy).toBe(3);
  });
Copy the code
// effect.ts
class ReactiveEffect {
    // Add the DEps attribute, which is used to reverse collect the DEP
    deps: any[] = []
    // This property is used to record whether stop has been called
    active = true.// Add the stop method
    stop() {
        // this effect is called only if stop has not been called
        if (this.active) {
            this.deps.forEach(dep= > {
                if (dep.has(this)) {
                    dep.delete(this)}})this.active = false}}}export function effect(fn, options: any = {}) {...const runner: any = _effect.run.bind(_effect)
    // Mount the _effect instance as an attribute of runner, so that we can get the effect instance from Runner
    runner.effect = _effect
    return runner
}

export function stop(runner) {
    runner.effect.stop()
}

export function track(target, key) {...if (activeEffect) {
        dep.add(activeEffect)
        // effect Collects DEPs in reverse
        activeEffect.deps.push(dep)
    }
}

Copy the code

To implement the stop method, the key is to get the effect instance from the runner, so you can mount the Effect instance to the Runner when you return to the runner. Add a stop method to the ReactiveEffect class. This method basically iterates through the DEPS array to determine whether the current Effect instance is collected in the DEP, and if so, delete it. The stop method simply calls the Stop method of the Runner’s Effect instance. At the same time, when collecting dependencies, you cannot simply collect effects as dependencies, but also need to conduct effect collection DEP operation.

In the test, it was found that when the stop function was called, if the data of the responsive object was changed by obj. Prop = 1, the FN parameter of the effect function would not be re-executed. However, when you modify the reactive data by obj.prop++, you can see that the FN argument of the effect function is reexecuted. This is because obj.prop++ is actually equal to obj.prop = obj.prop+ 1. Therefore, the getter method of obj.prop is accessed before the assignment, and the dependency collection is re-performed. Therefore, when the assignment is made, the dependency is triggered, and the effect cleared by the stop function is collected in the dependency, so the fn parameter of the effect function is re-executed.

The key to solving this problem is to determine whether dependencies should be collected at this time, if they should be collected only then, and if they don’t need to be collected at all. So we can use a global variable to control whether or not dependency collection is currently required.

// effect.ts
let shouldTrack = false

class ReactiveEffect {...run() {
        activeEffect = this
        Getters via the run method need to be collected by dependencies
        shouldTrack = true
        const res = this._fn()
        shouldTrack = false
        return res
    }
}

export function track(target, key) {
    if(! shouldTrack)return. }Copy the code

We can set a shouldTrack variable to control this, and need to collect dependencies when the getter is triggered by the effect.run method. So shouldTrack should be set to true before the run method executes this._fn(), so that when this._fn() fires the getter, the dependency collection should be normal because shouldTrack is true. When this._fn() is done, set shouldTrack to false. This way, when other operations trigger a dependency collection, shouldTrack is false, so no dependency collection is done.

Implement the onStop method

When effect is called, the second argument passed to effect, options, can have an onStop method that is called back when stop is called

// effect.spec.ts
it("onStop".() = > {
    const obj = reactive({
      foo: 1});const onStop = jest.fn();
    let dummy;
    const runner = effect(
      () = > {
        dummy = obj.foo;
      },
      {
        onStop,
      }
    );
    // After stop is called, onStop is executed
    stop(runner);
    expect(onStop).toBeCalledTimes(1);
  });
Copy the code
// effect.ts
class ReactiveEffect {
    ...
    onStop?: () = > void.stop() {
        if (this.active) {
            ...
            this.active = false
            this.onStop && this.onStop()
        }
    }
}

export function effect(fn, options:any = {}) {...// Add attributes on options to _effect
    Object.assign(_effect, options)
    ...
}
Copy the code

The onStop method is relatively easy to implement. You only need to call the function when the stop method is executed.

Realize the readonly

Readonly returns a proxy object, but it can only read the value, not change it. Since ReadOnly cannot modify values, there is no need for dependency collection and no need to trigger dependencies.

Implementing ReadOnly is actually relatively simple.

// reactive.ts
export function readonly(raw) {
    return new Proxy(raw, {
        get(target, key) {
            return Reflect.get(target, key)
        },
        set(target) {
            console.warn(`${target} is a readonly can not be set`)
            return true}})}Copy the code

You can see that readOnly’s implementation code actually reuses reactive code. So we need to refactor the code a little bit. Create the basehandlers.ts file

// baseHandlers.ts
import { track, trigger } from "./effect";

function createGetter(isReadonly = false) {
    return function get(target, key, receiver) {
        if(! isReadonly) {const res = Reflect.get(target, key, receiver)
            // If it is not readonly, dependency collection is performed
            track(target, key)
        }
        return res
    }
}

function createSetter(isReadonly = false) {
    return set(target, key, newValue, receiver) {
        const res = Reflect(target, key, newValue, receiver)
        if (!readonly) {
            // If it is not readonly, the dependency is triggered
            trigger(target, key)
        }
        return res
    }
}

const get = createGetter()
const set = createSetter()
const readonlyGet = createGetter(true)

export const mutableHandlers = {
    get,
    set
}
export const readonlyHandlers = {
    get: readonlyGet,
    set(target) {
        console.warn(`${target} is a readonly can not be set`);
        return true; }},Copy the code
// reactive.ts
import { mutableHandlers, readonlyHandlers } from "./baseHandlers";

function createReactiveObj(raw, baseHanlders) {
    if(raw ! = =null && typeofraw ! = ='object') {
        console.error(`${raw} must be a object`)}return new Proxy(raw, baseHandlers)
}

export function reactive(raw) {
    return createReactiveObj(raw, mutableHandlers)
}

export function readonly(raw) {
    return createReactiveObj(raw, readonlyHandlers)
}
Copy the code

As you can see, after refactoring the code above, we reused a lot of the code so that we could reuse the previous code when implementing new features later.

Implement isReadonly, isReactive, and isProxy

IsReadonly Is used to determine whether an object is a Readonly object, returning true if it is, and false if it is not. IsReactive Is used to check whether an object is a reactive object, and returns true if it is, and false if it is not.

IsProxy is used to check whether an object is reactive or readonly and returns true if it is reactive or false if it is not.

// effect.ts
export const enum ReactiveFlags = {
    IS_READONLY = '__v_isReadonly'
    IS_REACTIVE = '__v_isReactive'
}

export function isReadonly(value) {
    // Use it here!! Because value could be a normal object that returns undefined
    // So pass!! Convert undefined to Boolean
    return!!!!! value[ReactiveFlags.IS_READONLY] }export function isReactive(value) {
    return!!!!! value[ReactiveFlags.IS_REACTIVE] }export function isProxy(value) {
    return isReactive(value) || isReadonly(value)
}

// baseHandlers.ts
import { ReactiveFlags } from "./reactive";
function crateGetter(isReadonly = false) {
    return function get(target, key, receiver) {
        if (key === ReactiveFlags.IS_READONLY) {
          // If key === reactiveFlags. IS_READONLY returns isReadonly
            return isReadonly;
        }
        if (key === ReactiveFlags.IS_REACTIVE) {
            return! isReadonly } ... }; }Copy the code

Implement shallowReadonly and shallowReactive

The difference between shallowReadonly and ReadOnly is that when a property of ReadOnly is an object, that object is also of type ReadOnly, whereas when a property of shallowReadonly is an object, the object of that property is not of type ReadOnly. The difference between shallowReactive and reactive is the same. In the above implementation, we do not deal with the fact that the property is also an object.

// baseHandler.ts
import { reactive, ReactiveFlags, readonly } from "./reactive";

function createGetter(isReadonly = false, isShallow = false) {
    return get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver)
        ...
        if(! isReadonly) { track(target, key) }// Determine whether res is an object and if so is not shallow.
        // Readonly or reactive the res object.
        if(res ! = =null && typeof res === 'object' && !isShallow) {
            return isReadonly ? readonly(res) : reactive(res)
        }
        return res
    }
}

const shallowReactiveGet = createGetter(false.true);
const shallowReadonlyGet = createGetter(true.true);

export const shallowReactiveHandlers = {
  get: shallowReactiveGet,
  set,
};
export const shallowReadonlyHandlers = Object.assign({}, readonlyHandlers, {
  get: shallowReadonlyGet,
});
Copy the code