#Vue

Introduction

Effect functions can be nested. But why is this design choice made?

Nested Effects

effect(function effectFn1() {
  effect(function effectFn2() { /* ... */ })
    /* ... */
})

In the code above, effectFn1 is nested within effectFn2. The execution of effectFn1 will trigger the execution of effectFn2. So, when do we encounter nested effects? Take Vue.js, for example; Vue.js’s rendering function is executed within an effect.

When components are nested, for instance, when the Foo component renders the Bar component:

// Bar component
const Bar = {
  render() {/* ... */ },
}

// Foo component renders the Bar component
const Foo = {
  render() {
    return <Bar /> }// JSX syntax
}

Nested effects occur in this scenario. It can be visualized as follows:

effect(() => {
  Foo.render()
// Nested
  effect(() => {
    Bar.render()}
)}
)

The effect function can be nested, meaning an effect function can contain another effect function. When an outer effect function depends on reactive data created inside an inner effect function, the inner effect function is automatically tracked, ensuring that the outer effect function is executed when the inner effect function changes.

This nested structure of effect functions establishes a dependency chain, ensuring that when reactive data changes, all the effect functions dependent on it are triggered, thereby maintaining the responsiveness of the application.

The “effect stack” in Vue 3’s internal implementation is crucial. Vue uses an effect stack to track the currently executing effect functions, similar to a function call stack. This stack manages the execution order and dependency relationships of effect functions.

Now, let’s consider an example of a nested effect function without using a stack structure. However, it cannot achieve the desired nesting functionality. Let’s assume we have two reactive data, count1 and count2, where the value of count2 depends on the value of count1. We can use nested effect functions to establish this dependency relationship.

// Original data
const data = { foo: true, bar: true };

// Proxy object
const obj = new Proxy(data, {
  get(target, key) {
    console.log(`Reading property: ${key}`);
    return target[key];
  }
});

// Global variables
let temp1, temp2;

// effectFn1 is nested within effectFn2
effect(function effectFn1() {
  console.log('effectFn1 executed');

  effect(function effectFn2() {
    console.log('effectFn2 executed');
    // Access obj.bar property within effectFn2
    temp2 = obj.bar;
  });

  // Access obj.foo property within effectFn1
  temp1 = obj.foo;
});

effectFn1 is the outer effect function, which depends on the value of obj.foo. It contains an innerEffect within it. The inner effect function, effectFn2, depends on the value of obj.bar. When we modify obj.foo, the outer effect function should be triggered and output the value of obj.foo. When we modify obj.bar, the inner effect function should be triggered and output the value of obj.bar.

We use the global variable activeEffect to store the effect functions registered through the effect function. This means that at any given time, activeEffect stores only one effect function.

// Use a global variable to store the currently active effect function
let activeEffect;

function effect(fn) {
  // Define the effect function
  const effectFn = () => {
    // Call the cleanup function; specific implementation needs to be provided based on requirements
    cleanup(effectFn);
    // Assign the effect function to activeEffect
    activeEffect = effectFn;
    // Execute the effect function
    fn();
    // Store the dependencies of the current effect function in effectFn.deps (Needs to be implemented based on the actual logic)
    effectFn.deps = []; // Set dependencies collection based on the actual logic
  };
  
  // Execute the effect function
  effectFn();
}

However, by only using a single variable for storage without employing a stack structure, when nested effects occur, the execution of the inner effect function will overwrite the value of activeEffect. It will never be restored to its original value. If there is a reactive data performing dependency collection, even if it is read within the outer effect function, the collected effect functions will all be from the inner effect function. In other words, when I read obj.foo, activeEffect still refers to the value of innerEffect, and only the innerEffect is triggered.

To solve this issue, we need an effect function stack called effectStack. When an effect function is executed, the current effect function is pushed onto the stack. After the execution of the effect function is completed, it is popped from the stack. The activeEffect is always set to the top effect function on the stack. This ensures that a reactive data will only collect the effect function that directly reads its value, preventing mutual interference:

// Use a global variable to store the currently active effect function
let activeEffect;

// Effect stack
const effectStack = [];

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // Call the cleanup function; specific implementation needs to be provided based on requirements
    activeEffect = effectFn;

    // Push the current effect function onto the stack
    effectStack.push(effectFn);

    fn();

    // After the execution of the current effect function is completed, pop it from the stack, and restore activeEffect to its previous value
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // Initialize the dependencies collection of the effect function
  effectFn.deps = [];

  // Execute the effect function
  effectFn();
}

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.


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.
```