If you have any thoughts on my blog or articles and you want to let me know, you can either post a comment below(public) or tell me via this i_kkkp@163.com

The Role and Implementation of Vue.js Reactive System

Introduction

The concept of reactivity is not difficult to understand. It refers to triggering certain events when JavaScript operates on an object or a value. This is achieved by implementing a reactive system, where operations elicit specific responses.

Reactive Data and Side Effect Functions

Side effect functions refer to functions that produce side effects. Consider the following code snippet:

function effect() {
  document.body.innerText = 'hello vue3';
}

When the effect function is executed, it sets the text content of the body. Any function other than effect can read or modify the body’s text content. In other words, the execution of the effect function directly or indirectly affects the execution of other functions, indicating that the effect function has side effects.

Side effect functions are common and can impact various aspects, such as the effectiveness of “tree shaking” in webpack, a topic we discussed earlier. I won’t delve into this here.

Side effects can easily occur; for example, if a function modifies a global variable, it creates a side effect, as shown in the following code:

// Global variable
let val = 1;
function effect() {
  val = 2; // Modifying a global variable creates a side effect
}

Understanding side effect functions, let’s now discuss reactive data. Suppose a property of an object is read within a side effect function:

const obj = { text: 'hello world' };
function effect() {
  // The execution of the effect function reads obj.text
  document.body.innerText = obj.text;
}

The side effect function effect sets the innerText property of the body element to obj.text. When the value of obj.text changes, we want the side effect function effect to re-execute.

Our approach is as follows: we want to store the effect function in a bucket when reading the obj.text value, and when setting the obj.text value, we want to retrieve the effect function from the bucket and execute it.

Basic Implementation of Reactive Data

How can we make obj reactive data? By observing, we find that:

  • When the side effect function effect is executed, it triggers the read operation of the obj.text property.
  • When the obj.text value is modified, it triggers the write operation of the obj.text property.

We need to intercept the read and write operations of an object property.

Before ES2015, this could only be done using the Object.defineProperty function, which was also the approach used in Vue.js 2. In ES2015 and later, we can use the Proxy object to achieve this, and this is the approach adopted in Vue.js 3.

function createReactiveObject(target, proxyMap, baseHandlers) {
  // The core is the proxy
  // The goal is to detect user get or set actions
  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }

  const proxy = new Proxy(target, baseHandlers);

  // Store the created proxy,
  proxyMap.set(target, proxy);
  return proxy;
}

Here, we create a reactive object based on the Proxy object. We have a proxyMap, which is a container for storing various types of proxies. We define get and set intercept functions to intercept read and write operations.

Designing a Comprehensive Reactive System

From the above examples, it’s clear that the workflow of a reactive system is as follows:

  • When a read operation occurs, collect the side effect functions into a “bucket”.
  • When a write operation occurs, retrieve the side effect functions from the “bucket” and execute them.

Next, I’ll explain the principles through a simple implementation of a reactive system.

We know that the Proxy object can accept an object with getters and setters for handling get or set operations. Therefore, we can create a baseHandlers to manage getters and setters.

// baseHandlers
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver);

    if (!isReadonly) {
      // Collect dependencies when triggering get
      track(target, "get", key);
    }

    return res;
  };
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);

    // Trigger dependencies when setting values
    trigger(target, "set", key);

    return result;
  };
}

We also need to establish a clear link between side effect functions and the target fields being operated upon. For instance, when reading a property, it doesn’t matter which property is being read; all side effect functions should be collected into the “bucket.” Similarly, when setting a property, regardless of which property is being set, the side effect functions from the “bucket” should be retrieved and executed. There is no explicit connection between side effect functions and the operated fields. The solution is simple: we need to establish a connection between side effect functions and the operated fields. This requires redesigning the data structure of the “bucket” and cannot be as simple as using a Set type for the “bucket.”

We use WeakMap to implement the bucket for storing effects as discussed earlier. If you are not familiar with the characteristics of the WeakMap class, you can learn more about it here.

WeakMap

// Map to store different types of proxies
export const reactiveMap = new WeakMap();
export const readonlyMap = new WeakMap();
export const shallowReadonlyMap = new WeakMap();

Once we have defined the buckets as above, we can proceed to implement the core part of the reactive system, which is the proxy:

function createReactiveObject(target, proxyMap, baseHandlers) {
  // The core is the proxy
  // The goal is to detect user get or set actions

  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }
  
  const proxy = new Proxy(target, baseHandlers);

  // Store the created proxy
  proxyMap.set(target, proxy);
  return proxy;
}

For the previous track and trigger functions:

export function track(target, type, key) {
  if (!isTracking()) {
    return;
  }
  console.log(`Trigger track -> target: ${target} type:${type} key:${key}`);
  // 1. Find the corresponding dep based on the target
  // Initialize depsMap if it's the first time
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    // Initialize depsMap logic
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);

  if (!dep) {
    dep = createDep();
    depsMap.set(key, dep);
  }

  trackEffects(dep);
}

export function trigger(target, type, key) {
  // 1. Collect all deps and put them into deps array,
  // which will be processed later
  let deps: Array<any> = [];

  const depsMap = targetMap.get(target);

  if (!depsMap) return;
  // Currently only GET type is implemented
  // for get type, just retrieve it
  const dep = depsMap.get(key);

  // Collect into deps array
  deps.push(dep);

  const effects: Array<any> = [];
  deps.forEach((dep) => {
    // Destructure dep to get the stored effects
    effects.push(...dep);
  });
  // The purpose here is to have only one dep that contains all effects
  // Currently, it should be reused for the triggerEffects function
  triggerEffects(createDep(effects));
}

Note: This article is a translated version of the original post. For the most accurate and up-to-date information, please refer to the original source.
```

Nested Effects and Effect Stack Webpack Custom Loader/Plugin

Comments