reactive and effect, dependency collection triggers dependencies

The project has been initialized through the previous article and integrated ts and jest. This article implements the reactive method in the reactive module in Vue3.

Prerequisite knowledge

If you are proficient in Map, Set, Proxy, and Reflect, you can skip this part directly.

Map

Map is a collection used to store key-value pairs and remember the original insertion order of the keys. The keys and values can be any type of data.

Initialize, add, get

let myMap = new Map()

myMap.set('name', 'wendZzoo')
myMap.set('age', 18)

myMap.get('name')
myMap.get('age')

A key in the Map can only appear once. It is unique in the Map collection. Repeated settings will be overwritten.

myMap.set('name', 'jack')

Map keys and values can be any type of data

myMap.set({<!-- -->name: 'wendZzoo'}, [{<!-- -->age: 18}])

Delete

let myMap = new Map()
myMap.set('name', 'Tom')
myMap.delete('name')

When the key data type is an object, you need to use the corresponding reference to delete the key-value pair

let myMap = new Map()
let key = [{<!-- -->name: 'Tom'}]
myMap.set(key, 'Hello')
myMap.delete(key)

// If using a different reference try deleting the key-value pair
// it won't work properly
// Because Map cannot recognize that these two references are the same key
myMap.set([{<!-- -->name: 'Tom'}], 'Hello')
myMap.delete([{<!-- -->name: 'Tom'}])

Set

Set is a collection data structure that allows storing unique values without duplicates. Set objects can store any type of value, including primitive types and object references.

let mySet = new Set()

mySet.add('wendZzoo')
mySet.add(18)
mySet.add({<!-- -->province: 'jiangsu', city: 'suzhou'})

Iterable

for (let key of mySet) {<!-- -->
    console.log(key)
}

Proxy

The Proxy object is used to create a proxy for an object to implement interception and customization of basic operations (such as property lookup, assignment, enumeration, function calling, etc.).

The premise of Vue’s responsiveness is the need for data hijacking. There are two ways to hijack property access in JS: getters/setters and Proxies. Vue 2 uses getters/setters purely to support the limitations of older browsers, while in Vue 3 Proxy is used to create responsive objects.

When creating a Proxy object, you need to provide two parameters: the target object target (the object being proxied) and a handler object handler (the method used to define the interception behavior).

Among them, the commonly used handler methods are get and set.

The handler.get() method is used to intercept the read attribute operation of the object. For complete usage, please refer to: MDN

It receives three parameters:

  1. target: target object
  2. property: the obtained property name
  3. receiver: Proxy or object inheriting Proxy
const obj = {<!-- -->name: 'wendZzoo', age: 18}
let myProxy = new Proxy(obj, {<!-- -->
    get: (target, property, receiver) => {<!-- -->
        console.log('Collect dependencies')
        return target[property]
    }
})

//Execute myProxy.name
//Execute myProxy.age

The handler.set() method is a capturer for setting attribute values. For complete usage reference: MDN

It receives four parameters

  1. target: target object
  2. property: the property name or Symbol to be set,
  3. value: new attribute value
  4. receiver: the object that was initially called. Usually the proxy itself, but the handler’s set method may also be called indirectly on the prototype chain or in other ways (so not necessarily the proxy itself)
const obj = {<!-- -->name: 'wendZzoo', age: 18}
let myProxy = new Proxy(obj, {<!-- -->
    get: (target, property, receiver) => {<!-- -->
        console.log('Collect dependencies')
        return target[property]
    },
    set: (target, property, value, receiver) => {<!-- -->
        console.log('trigger dependency')
        target[property] = value
        return true
    }
})
myProxy.name = 'Jack'
myProxy.age = 20

Proxy provides a mechanism to implement custom behaviors by intercepting and modifying the operations of the target object. Where the get and set methods print logs, that is where Vue3 implements dependency collection and triggers dependencies.

Reflect

Reflect is a built-in object that provides methods to intercept JS operations. This allows it to work perfectly with Proxy. Proxy provides the timing and location for object interception, and Reflect provides interception methods.

Reflect is not a constructor, so it cannot be called with new. It is more like a Math object, called as a function, and all its properties and methods are static.

Commonly used methods include get and set.

The Reflect.get method allows you to get the property value from an object. For complete usage reference: MDN

It receives three parameters:

  1. target: the target object that needs a value
  2. propertyKey: the key value of the value to be obtained
  3. receiver: If a getter is specified in the target object, the receiver is the this value when the getter is called.
let obj = {<!-- -->name: 'wendZzoo', age: 18}
Reflect.get(obj, 'name')
Reflect.get(obj, 'age')

The Reflect.set method allows setting properties on an object. For complete usage reference: MDN

It receives three parameters:

  1. target: the target object to set the attribute
  2. propertyKey: the name of the property being set
  3. value: set value
  4. Receiver: If a setter is encountered, the receiver is the this value when the setter is called.
let obj = {<!-- -->}
Reflect.set(obj, 'name', 'wendZzoo')

let arr = ['name', 'address']
Reflect.set(arr, 1, 'age')
Reflect.set(arr, 'length', 1)

Change directory

Create a new folder reactivity under src, and create new effect.ts and reactive.ts.

Delete the index.spec.ts used to verify the installation of jest in the previous article under the tests folder, and create a new effect.spec .ts and reactive.spec.ts.

reactive

First write a single test to clarify the required results, and then implement the function based on this requirement. Vue3’s reactive method returns a responsive proxy of an object. The proxy object is different from the source object, but it can have the same nested structure as the source object.

The single test can be written like this: reactive.spec.ts

import {<!-- --> reactive } from "../reactivity/reactive";

describe("reactive", () => {<!-- -->
  it("happy path", () => {<!-- -->
    let original = {<!-- --> foo: 1 };
    let data = reactive(original);

    expect(data).not.toBe(original);
    expect(data.foo).toBe(1);
  });
});

Based on these two assertions, the current reactive method is implemented. Vue3 is implemented using Proxy.

reactive.ts

export function reactive(raw) {<!-- -->
  return new Proxy(raw, {<!-- -->
    get: (target, key) => {<!-- -->
      let res = Reflect.get(target, key);
      // TODO dependency collection
      return res;
    },
    set: (target, key, value) => {<!-- -->
      let res = Reflect.set(target, key, value);
      // TODO trigger dependency
      return res;
    },
  });
}

Run reactive single test to verify whether the method is implemented correctly. Execute yarn test reactive

effect

This API is not mentioned separately on the official website. You can find it in the in-depth responsive system article in the advanced topic.

effect is directly translated as effect, which means to make it work. This makes it the function we pass in, so the function of effect is the function we pass in. To take effect, that is, to execute this function.

Usage examples

import {<!-- --> reactive, effect } from "vue";

let user = reactive({<!-- -->
  age: 10,
});

let nextAge;

function setAge() {<!-- -->
  effect(() => {<!-- -->
    nextAge = user.age + 1;
  });
  console.log(nextAge);
}

function updateAge() {<!-- -->
  user.age + + ;
  console.log(nextAge);
}

When effect is not used to act on nextAge, the updateAge method is directly triggered, and the output nextAge is undefined ;

Call setAge, execute the function in effect and assign a value to nextAge, and age in responsive data user code> changes, nextAge also continues to execute the function in effect.

Single test

Then the single test of effect can be written like this:

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

describe("effect", () => {<!-- -->
  it("happy path", () => {<!-- -->
    let user = reactive({<!-- -->
      age: 10,
    });
    let nextAge;
    effect(() => {<!-- -->
      nextAge = user.age + 1;
    });
    expect(nextAge).toBe(11);
  });
});

The effect method receives a method and executes it.

effect.ts

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

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

Execute the passed in fn parameters by extracting it into a Class class.

Then execute all single tests to verify whether they are successful and execute yarn test

Dependency collection

Modify the effect single test and add an assertion to determine whether nextAge is also updated when age changes?

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

describe("effect", () => {<!-- -->
  it("happy path", () => {<!-- -->
    let user = reactive({<!-- -->
      age: 10,
    });
    let nextAge;
    effect(() => {<!-- -->
      nextAge = user.age + 1;
    });
    expect(nextAge).toBe(11);

    // + + + updater
    user.age + + ;
    expect(nextAge).toBe(12);
  });
});

When executing the single test, it was found that it failed because Proxy did not implement dependency collection and triggering dependencies, that is, there were two TODOs in reactive.ts.

However, we must first understand what dependence is?

Quoting official examples:

let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0=2
console.log(A2) // Still 3

When we change A0, A2 does not update automatically.

So how do we do this in JavaScript? First, in order to be able to rerun the calculated code to update A2, we need to wrap it into a function:

let A2

function update() {<!-- -->
  A2 = A0 + A1
}

Then, we need to define a few terms:

  • The update() function has a side effect, or simply an effect, because it changes the state of the program.
  • A0 and A1 are considered dependencies of this action because their values are used to perform this action. Therefore, this function can also be said to be a subscriber that it depends on.

Therefore, we can boldly and informally say that dependence refers to the dependence of an observer (usually a view or a side effect function) on data. When an observer needs access to specific data, it becomes a dependency on that data.

What about Dependency Collection?

Dependency collection is used to track and manage data dependencies. Often used to implement reactive systems, where changes in data automatically trigger related update operations.

When data changes, related views or operations can be automatically updated to keep the data and interface synchronized. Dependent collection can help us establish the association between data and views to ensure that data changes are automatically reflected in the views.

From a code level, when reading an object, that is, during a get operation, dependencies are collected and the target object, key in the object, and Dep instance are associated and mapped.

Define the dependency collection method track in effect.ts.

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

let targetMap = new Map();
export function track(target, key) {<!-- -->
  // target -> key -> dep
  let depMap = targetMap.get(target);
  if (!depMap) {<!-- --> // init
    depMap = new Map();
    targetMap.set(target, depMap);
  }
  let dep = depMap.get(key);
  if (!dep) {<!-- --> // init
    dep = new Set();
    depMap.set(key, dep);
  }
  dep.add(reactiveEffect);
}

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

Trigger dependencies

Dependencies are triggered when setting object properties, that is, when performing a set operation. Execute all function functions in the Set structure of the dep mounted on each attribute.

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

At this point, execute all single tests again, yarn test

Summary

  1. Start with a single test to clarify the functions of the function methods that need to be implemented.
  2. To distribute and implement function points, that is, to split function points, a simple version of the reactive method is initially implemented. It only requires that the original data and the data after the proxy are different, but the data structure must be the same, like a deep copy.
  3. Through the Class class, the effect method can self-execute its passed function parameters.
  4. Dependency collection, mapping data relationships through two Map structures and one Set structure, and storing all fn in dep. The effct instance is obtained through a global variable reactiveEffect. When dependencies are triggered later, each item in dep is directly executed.
  5. Trigger dependency, obtain dep through mapping relationship, because dep is a Set structure, iterable, and loop each execution.