Vue

Introduction

When we talk about race conditions, it typically refers to a concurrency problem in multi-process or multi-threaded programming. However, in frontend development, we might not directly encounter multi-threaded programming frequently, but we often face similar situations related to race conditions. A common example is in asynchronous programming, especially when dealing with asynchronous events, callback functions, or Promises.

For instance, consider the following asynchronous code:

let data;

function fetchData() {
  setTimeout(() => {
    data = 'Fetched data';
  }, 1000);
}

fetchData();
console.log(data); // Outputs undefined

In this example, the fetchData function is asynchronous, and it assigns the data to the data variable after 1 second. However, due to JavaScript’s single-threaded nature, the fetchData function waits in the main thread’s event queue for 1 second. Within this 1 second, the console.log(data) statement executes immediately, and at that point, the value of data is undefined because the fetchData function has not completed yet.

In asynchronous programming, due to the non-blocking nature of the code, similar race condition issues can arise. When dealing with asynchronous operations, it’s crucial to ensure data consistency and correctness, avoiding accessing or modifying related data before the asynchronous operation is completed.

Race Conditions and Reactivity

So, how are race conditions related to reactivity?

Consider the following example:

let finalData;
watch(obj, async () => {
  // Send and wait for a network request
  const res = await fetch('/path/to/request');
  // Assign the request result to data
  finalData = res;
});

In this code snippet, we use the watch function to observe changes to the obj object. Every time the obj object changes, a network request, such as an API call, is sent. After the data request is successful, the result is assigned to the finalData variable. At first glance, this code might seem fine. However, upon closer inspection, you’ll realize that this code can lead to race condition problems. Let’s assume we modify a field of the obj object for the first time, triggering the callback function and sending the first request A. As time passes, before the result of request A returns, we modify a field of the obj object again, triggering the second request B. Now, both request A and request B are in progress. Which request will return its result first? We don’t know. If request B completes before request A, the finalData variable will store the result of request B, making request A’s result outdated.

Comparison

However, because request B was sent later, we consider its data as the “latest.” Request A is deemed “expired,” and its result should be invalidated. By ensuring that request B’s result is considered the latest, we can prevent errors caused by race conditions. Essentially, what we need is a way to expire side effects. To illustrate this concept further, let’s replicate the scenario using the watch function in Vue.js to see how Vue.js helps developers address this problem. Later, we’ll attempt to implement this functionality ourselves.

watch(obj, async (newValue, oldValue, onInvalidate) => {
  // Define a flag to indicate whether the current side effect has expired, initially set to false (not expired)
  let expired = false;
  
  // Call the onInvalidate() function to register an expiration callback
  onInvalidate(() => {
    // When expired, set the expired flag to true
    expired = true;
  });

  // Send a network request
  const res = await fetch('/path/to/request');

  // Perform subsequent operations only if the side effect has not expired
  if (!expired) {
    finalData = res;
    // Subsequent operations...
  }
});

As shown in the code above, before sending the request, we define an expired flag variable to indicate whether the current side effect has expired. We then call the onInvalidate function to register an expiration callback. When the side effect expires, the expired flag is set to true. Finally, we use the request result only if the side effect has not expired, effectively avoiding the issue described earlier.

Comparison


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

Previously, we discussed the effect function, which is used to register side-effect functions. It allows specifying options parameters, such as the scheduler to control the timing and manner of side-effect function execution. We also explored the track function for dependency tracking and the trigger function to re-execute side-effect functions. Combining these concepts, we can implement a fundamental and distinctive feature of Vue.js – computed properties.

Computed Properties and the lazy Option

In Vue.js, the effect function is used to create reactive side-effect functions. By default, side-effect functions passed to effect are executed immediately. For example, in the code below, the side-effect function passed to the effect function is executed immediately:

effect(() => {
  console.log(obj.foo);
});

However, in certain cases, we want the side-effect function to execute only when needed, not immediately. A typical scenario is with computed properties. To achieve this delayed execution, we can add a lazy property to the options object and set it to true. When lazy is true, the side-effect function is not executed during initialization but only when necessary. The modified code looks like this:

effect(
  // This function will not execute immediately
  () => {
    console.log(obj.foo);
  },
  // options
  {
    lazy: true
  }
);

In the implementation, the side-effect function effectFn is returned as the result of the effect function. This means that when we call the effect function, we get the corresponding side-effect function and can manually execute it when needed. This mechanism gives us more control, allowing us to decide when to trigger the execution of the side-effect function rather than executing it immediately.

This design pattern is particularly suitable for specific scenarios like computed properties. In computed properties, we might want to trigger the side-effect function’s execution at a specific moment rather than immediately during initialization. By returning the side-effect function from the effect function, we can flexibly control when the side-effect function is executed to meet different requirements in various scenarios.

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // Set options and dependencies for the side-effect function
  effectFn.options = options;
  effectFn.deps = [];

  // Execute the side-effect function only if it's not lazy
  if (!options.lazy) {
    effectFn();
  }

  // Return the side-effect function as the result
  return effectFn;
}

In this code, the effect function’s second parameter is an options object, where the lazy property is set to true. This means that the side-effect function passed to effect will be executed only when necessary, such as when accessing a computed property. The lazy property allows us to control the immediate execution of the side-effect function.

Now that we have achieved lazy computation through computed properties, how do we implement data caching?

function computed(getter) {
  // value is used to cache the last computed value
  let value;
  // dirty flag indicates whether a recalculation is needed; if true, it means "dirty" and needs computation
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true
  });

  const obj = {
    get value() {
      // Compute the value only if it's "dirty," and cache the computed value in the value variable
      if (dirty) {
        value = effectFn();
        // Set dirty to false, so the cached value can be used directly next time
        dirty = false;
      }
      return value;
    }
  };

  return obj;
}

With lazy computation resolved, the value is calculated only when it is truly needed, executing effectFn only when necessary. Additionally, a dirty flag is introduced to indicate whether the current computation needs to be recalculated. If dirty is true, the value is recalculated, and the dirty flag is set to false so that the cached value can be used directly next time.

Implementation Principle of watch

The concept of watch essentially involves observing a reactive data and executing the corresponding callback function when the data changes. For example:

watch(obj, () => {
  console.log('Data changed');
})
// Modifying the reactive data triggers the execution of the callback function
obj.foo++

Suppose obj is a reactive data, watched using the watch function with a provided callback function. When modifying the reactive data’s value, the callback function is triggered. In fact, the implementation of watch essentially utilizes the effect and the options.scheduler option, as shown in the following code:

effect(() => {
  console.log(obj.foo)
}, {
scheduler() {
// The scheduler function is executed when obj.foo's value changes
}
})

In a side-effect function accessing the reactive data obj.foo, based on the previous discussion, we know that this establishes a connection between the side-effect function and the reactive data. When the reactive data changes, the side-effect function is re-executed. However, there is an exception: if the side-effect function has a scheduler option, when the reactive data changes, the scheduler function is executed instead of directly triggering the side-effect function. From this perspective, the scheduler function acts as a callback function, and the implementation of watch utilizes this characteristic.

Below is the simplest implementation of the watch function:

// The watch function receives two parameters: source (reactive data) and cb (callback function)
function watch(source, cb) {
  effect(
    // Trigger a read operation to establish a connection
    () => source.foo,
    {
      scheduler: scheduler(),
      // Call the callback function cb when the data changes
      fn: () => {
        cb();
      },
    }
  );
}

In this code, we first define an original data object named data, which contains a property foo with an initial value of 1. Next, we create a proxy object obj using Proxy, intercepting operations on the data.

When obj.foo++ is executed, the set interceptor of Proxy is triggered. In the set interceptor, we first set the property value on the target object and then call the watch function, passing obj and a callback function. In the watch function, we use a hypothetical effect function (which might be provided by a framework) to listen for data changes.


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

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