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 theobj.text
property. - When the
obj.text
value is modified, it triggers the write operation of theobj.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.
// 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.
```
Comments