Vue responsiveness principle and essence – realizing a complete responsive system

Article directory

  • Preface
  • Responsive
    • The nature of responsiveness
    • Basic implementation and working principle
    • Perfect responsiveness

Foreword

The code idea of this article comes from the Vue3.0 source code, and part of the understanding comes from the book “Vue.js Design and Implementation” by Huo Chunyang. Interested friends can buy and read it by themselves. You can clearly feel the author’s deep understanding and intention of Vue. It is rich in very comprehensive Vue knowledge points. I highly recommend it to everyone.

Responsive

The essence of responsiveness

In order to prevent some friends from not knowing the side effect function, I will introduce a side effect function before starting the lecture. Side-effect functions, as the name suggests, refer to functions that produce side effects. For example, if a function modifies a global variable, it will produce a side effect, which is a side effect function (for more details, you can check the related concepts of pure functions and side effects by yourself).

let value = 1 // global variable

function foo() {<!-- -->
  value = 100 // Modify global variables
}

What is responsiveness? I believe everyone can answer it. The so-called responsiveness is nothing more than automatically updating the page when the data changes. This answer is okay, but it is not the essence of responsiveness. Here is a conclusion for everyone, The essence of responsiveness: when the page data changes, relevant functions will be automatically run.

For example, we have the following obj object, and an effect side effect function. In the side effect function effect, the body is obtained and a text is added to it. The text content is obj.name. The final effect will be displayed in the browser” chenyq”.

const obj = {<!-- --> name: "chenyq" }

function effect() {<!-- -->
  document.body.innerText = obj.name
}
effect(obj)

At this time, we modify the value of obj.name, and we can find that the explicit content in the page has not changed, and is still “chenyq”.

//Modify the name attribute
obj.name = "abc"

Now that we have modified the value of obj.name, we expect that the page can also be updated synchronously. If this goal can be achieved, then the obj object is a responsive data. So how can we accomplish this goal? If we call the effect function again after modifying the obj.name attribute, the data in the page will change accordingly.

obj.name = "abc"
//Call the effect function again after modification
effect(obj)

However, we expect to be able to call the effect function automatically instead of calling it manually ourselves. Through this example, we can fully understand the essence of responsiveness, and why the essence of responsiveness is to automatically run related functions when the page data changes. But we currently call it manually, not automatically.

Basic implementation and working principle

Continuing from the above, how do we make obj into a responsive data? In other words, how do we automatically run the related functions of obj? In fact, we can start from the following two ideas:

  • When the effect function is executed, the get (read) operation of obj.name will be triggered.
  • When the name attribute is modified, the set operation of obj.name will be triggered.

If we can intercept the get and set operations of the obj object, then we can achieve it. The specific method is: when we trigger the get operation by obj.name, we can store the effect function in a bucket. Because there may be more than one effect function, we need to store it in a bucket; when we trigger the set operation At that time, we will take out all related functions from the bucket and execute them.

Now we turn the problem into how to intercept the get and set operations of an object. I believe everyone will react quickly. We can achieve this through Object.defineProperty or Proxy. In Vue.js2, it is responsive through the Object.defineProperty function, and ES6 adds a new proxy object Proxy, which has more advantages than Object.defineProperty. We can implement it through the Proxy proxy object, Vue.js3 It is also implemented using Proxy.

Next, we will use Proxy to implement it based on the above ideas. First, we create a bucket to store side effect functions. Why do I use Set instead of array for the bucket here? This is because we may not only trigger the get operation once. When get is triggered, the effect function will be added to the bucket. If it is an array, when get is triggered again We will add the effect function to the bucket; in this way, two identical effect functions are stored in the array. When the set operation is triggered, the same function will be called twice. Unless the array is deduplicated, there will be a problem of calling a function multiple times, so use a Set collection to ensure that the stored effect functions are not repeated.

// Bucket for storing side effect functions
const bucket = new Set()

Then define a raw data data, and use Proxy to proxy the raw data data. In the proxy object, the get and set methods are used to intercept the reading and setting operations respectively. In the get operation, the effect function is added to the bucket, and then the attribute value is returned; in the set operation, the attribute value is set, and the side effect functions in the bucket are traversed and executed.

const data = {<!-- --> name: "chenyq" }
const obj = new Proxy(data, {<!-- -->
  get(target, key) {<!-- -->
    //Add side effect function to bucket
    bucket.add(effect)
    // Return attribute value
    return target[key]
  },

  set(target, key, newVal) {<!-- -->
    //Set attribute value
    target[key] = newVal
    // Traverse and execute the side effect functions in the bucket
    bucket.forEach(fn => fn())
    return true
  }
})

In this way, we have implemented a responsive data. We can test it through setTimeout and modify the obj.name attribute after waiting for one second.

function effect() {<!-- -->
  document.body.innerText = obj.name
}
// Execute the side effect function and trigger reading
effect()

// Modify the obj.name attribute after 1 second
setTimeout(() => {<!-- -->
  obj.name = "abc"
}, 1000)

Run the above code and find that we can get the desired effect. So far we have implemented the simplest responsiveness, but it still has problems and flaws. For example, the function name of the effect function added to the bucket is hard-coded, which is not versatile and flexible. But the main purpose here is not to implement responsiveness, but to help everyone understand responsiveness and how responsiveness works.

Perfect responsiveness

The above responsive system has flaws and is not perfect enough. Now we try to build a more complete responsive system.

The code below is the responsiveness we have implemented.

//original data
const data = {<!-- --> name: "chenyq" };
// Bucket to store side effect functions
const bucket = new Set();
const obj = new Proxy(data, {<!-- -->
  get(target, key) {<!-- -->
    //Add side effect function to bucket
    bucket.add(effect);
    // Return attribute value
    return target[key];
  },

  set(target, key, newVal) {<!-- -->
    //Set attribute value
    target[key] = newVal;
    // Traverse and execute the side effect functions in the bucket
    bucket.forEach((fn) => fn());
    return true;
  },
});

function effect() {<!-- -->
  document.body.innerText = obj.name;
}
// Execute the side effect function and trigger reading
effect();

// Modify the obj.name attribute after 1 second
setTimeout(() => {<!-- -->
  obj.name = "abc";
}, 1000);

In the above code, we have hard-coded the function name of effect. If the side effect function is not called effect, then this code will not work correctly, and there is no way to collect the side effects into the bucket. Now that we think about it, how do we collect the side effect function? Even if the side effect function effect is an anonymous function, can we still collect the side effect function effect into the bucket normally?

In response to the above problems, we can define a global variable activeEffect, whose initial value is undefined, to represent the side effect function currently being executed. When calling effect to register a side effect function, assign the side effect function fn to activeEffect, then call the fn function normally. After the call is completed, modify activeEffect back to undefined, as shown below:

let activeEffect;
function effect(fn) {<!-- -->
  activeEffect = fn;
  fn();
  activeEffect = undefined;
}

At the same time, the get interceptor in Proxy also needs to be modified accordingly. When activeEffect has a value, the side effect functions stored in activeEffect are collected into the bucket.

const obj = new Proxy(data, {<!-- -->
  get(target, key) {<!-- -->
    // Collect the side effect functions stored in activeEffect into buckets
    if (activeEffect) bucket.add(activeEffect);
    return target[key];
  },

  set(target, key, newVal) {<!-- -->
    target[key] = newVal;
    bucket.forEach((fn) => fn());
    return true;
  },
});

Now we can use the effect function as shown below, calling effect passing in a function, even an anonymous function. When the effect function is executed, the received function parameter fn will first be assigned to activeEffect. Then execute the incoming fn function. When the fn function is executed, the properties of obj will be read, which will trigger the get operation of the proxy object Proxy. In the get interceptor, when activeEffect has a value, the side effect function stored in activeEffect will be added to the bucket, so that the response system does not rely on the name of the side effect function.

//Execute the side effect function and trigger reading
effect(() => {<!-- -->
  document.body.innerText = obj.name;
});

However, our responsive system still has loopholes. For example, the data source obj has two attributes: name and age. When we directly modify age or set a non-existent attribute, the side effect function will still be executed once. In the following code, effect is related to the obj.name property. What we expect is that the effect function will be re-executed only when the properties on which the effect function depends are manipulated. Instead of like now, operating other properties will also execute the effect function that only depends on obj.name. In theory, the age and notExist properties have no relationship with the side effect function, so the operation in the timer should not trigger the side effect function. implement.

const data = {<!-- --> name: "chenyq", age: 18 };

effect(() => {<!-- -->
  console.log("effect is running"); // Will be executed twice
  document.body.innerText = obj.name;
});

setTimeout(() => {<!-- -->
  obj.age = 19; // Manipulate other attributes
  // obj.notExist = "abc"; or operate non-existing properties
}, 1000);

In fact, the reason for the above problem is that the collected side effect function effect does not have a clear relationship with the target attribute being operated on. In order to solve this problem, we need to redesign the bucket and directly use a Set collection. There is no way to clearly describe the relationship between the effect and the operated target. When reading attributes, no matter which attribute is read, the implementation effect is actually the same, and the side effect function will be collected into the bucket; when setting the attribute, no matter which attribute is set, the side effect function will be collected. The side effect function effect in the bucket is taken out and executed.

Let’s take a look at the code executed by the side effect function effect. There are three roles in this code:

  • The proxy object being operated on: obj
  • The field/attribute being operated on: name
  • Side effect function registered using effect: effectFn
effect(function effectFn() {<!-- -->
  document.body.innerText = obj.name;
});

We can represent these three roles respectively: use target to represent the original object of the proxy object, use key to represent the attribute being operated on, and use effectFn to represent the side effect function to be registered. Then these three roles can be established as follows The relationship shown is a tree structure.

target
└── key
└── effectFn

The following situations may also exist (for easier understanding, you can look at other examples):

  • There are two side-effect functions that operate on the same attribute (both functions depend on the same attribute), then their relationship is expressed as follows:
effect(function effectFn1() {<!-- -->
  document.body.innerText = obj.name;
});
effect(function effectFn2() {<!-- -->
  document.body.innerText = obj.name;
});
target
└── name
└── effectFn1
        └── effectFn2
  • A side effect function operates two attributes at the same time, then its relationship is expressed as follows:
effect(function effectFn() {<!-- -->
obj.name;
  obj.age;
});
target
└── name
└── effectFn
    └── age
        └── effectFn
  • Two different side effect functions read the properties of two different objects respectively, then their relationship is expressed as follows:
effect(function effectFn1() {<!-- -->
obj1.name;
});
effect(function effectFn2() {<!-- -->
  obj2.age;
});
target1
└── name
└── effectFn1
target2
    └── age
        └── effectFn2

According to this structure, we can directly establish a clear relationship between the side-effect function and the properties of the operated object under any circumstances. We create a bucket using WeekMap instead of Set. The specific method is as follows:

  • WeekMap consists of a target corresponding to a Map: key –> target, value –> Map, and the original object of each operated object will correspond to a Map;
  • The Map consists of a key corresponding to a Set: key –> key (field/attribute being operated), value –> Set. Each attribute of the object corresponds to a Set collection, and the Set stores the side effect functions of the current attribute. .

Their relationship is as follows:

WeekMap
└── target1 --> Map1
└── target2 --> Map2
└──...
    └── target3 --> Map3
                    └── key1 --> Set1
                    └── key2 --> Set2
                    └──...
                    └── key3 --> Set3
                                  └── effectFn1
                                  └── effectFn2
                                  └── effectFn3
                                  └──...

Next, we will implement this new bucket in the code and modify the Proxy’s get/set interceptor:

const obj = new Proxy(data, {<!-- -->
  get(target, key) {<!-- -->
    // No activeEffect, return directly
    if (!activeEffect) return target[key];
    // Remove depsMap from the bucket according to target
    let depsMap = bucket.get(target);
    // If depsMap does not exist, then you need to create a depsMap associated with it
    if (!depsMap) bucket.set(target, (depsMap = new Map()));

    // Then according to the key, take out deps from depsMap. deps is a Set collection, which stores all the side effect functions related to the current key.
    let deps = depsMap.get(key);
    // If deps does not exist, create a deps and add it to depsMap
    if (!deps) depsMap.set(key, (deps = new Set()));
    //Finally add the currently activated side effect function to the bucket
    deps.add(activeEffect);

    // Return attribute value
    return target[key];
  },

  set(target, key, newVal) {<!-- -->
    //Set attribute value
    target[key] = newVal;
    // Remove depsMap from the bucket according to target
    const depsMap = bucket.get(target);
    if (!depsMap) return;

    // Get the side effect function related to key
    const deps = depsMap.get(key);
    //Execute side effect function
    deps & amp; & amp; deps.forEach((fn) => fn());
  },
});

Next we are encapsulating the above code. A good approach is to encapsulate the logic in get and set into a separate function. In the get operation, we can encapsulate the logic of collecting dependencies into buckets into a track function to represent the meaning of tracking; in the set operation, we encapsulate the operation of triggering the side effect function into a trigger to represent the trigger. mean.

const obj = new Proxy(data, {<!-- -->
  get(target, key) {<!-- -->
    // Collect side effect functions into buckets
    track(target, key);
    // Return attribute value
    return target[key];
  },

  set(target, key, newVal) {<!-- -->
    //Set attribute value
    target[key] = newVal;
    // Take the side effect function from the bucket and execute it
    trigger(target, key);
  },
});

function track(target, key) {<!-- -->
  // No activeEffect, return directly
  if (!activeEffect) return target[key];
  //Retrieve depsMap from the bucket according to target
  let depsMap = bucket.get(target);
  // If depsMap does not exist, then you need to create a depsMap associated with it
  if (!depsMap) bucket.set(target, (depsMap = new Map()));

  // Then according to the key, take out deps from depsMap. deps is a Set collection, which stores all the side effect functions related to the current key.
  let deps = depsMap.get(key);
  // If deps does not exist, create a deps and add it to depsMap
  if (!deps) depsMap.set(key, (deps = new Set()));
  //Finally add the currently activated side effect function to the bucket
  deps.add(activeEffect);
}

function trigger(target, key) {<!-- -->
  //Retrieve depsMap from the bucket according to target
  const depsMap = bucket.get(target);
  if (!depsMap) return;

  // Get the side effect function related to key
  const effects = depsMap.get(key);
  //Execute side effect function
  effects & amp; & amp; effects.forEach((fn) => fn());
}

Now we have implemented a basically complete reactive system. In fact, the reactive system will be more complex. For example, what are the effects of branch switching of the ternary operator? How to deal with the legacy side-effect functions? How to avoid infinite recursive loops? Questions I will wait for a series of updates in the following articles. Anyway, we have now implemented a relatively complete responsive system, and finally I will give you the final code of this article.

const data = { name: "chenyq", age: 18 };
const bucket = new WeakMap();
const obj = new Proxy(data, {
  get(target, key) {
    // Collect side effect functions into buckets
    track(target, key);
    // Return attribute value
    return target[key];
  },

  set(target, key, newVal) {
    //Set attribute value
    target[key] = newVal;
    // Take the side effect function from the bucket and execute it
    trigger(target, key);
  },
});

function track(target, key) {
  // No activeEffect, return directly
  if (!activeEffect) return target[key];
  // Remove depsMap from the bucket according to target
  let depsMap = bucket.get(target);
  // If depsMap does not exist, then you need to create a depsMap associated with it
  if (!depsMap) bucket.set(target, (depsMap = new Map()));

  // Then according to the key, take out deps from depsMap. deps is a Set collection, which stores all the side effect functions related to the current key.
  let deps = depsMap.get(key);
  // If deps does not exist, create a deps and add it to depsMap
  if (!deps) depsMap.set(key, (deps = new Set()));
  //Finally add the currently activated side effect function to the bucket
  deps.add(activeEffect);
}

function trigger(target, key) {
  // Remove depsMap from the bucket according to target
  const depsMap = bucket.get(target);
  if (!depsMap) return;

  // Get the side effect function related to key
  const effects = depsMap.get(key);
  //Execute side effect function
  effects & amp; & amp; effects.forEach((fn) => fn());
}

let activeEffect;
function effect(fn) {<!-- -->
  activeEffect = fn;
  fn();
  activeEffect = undefined;
}

// test part
// Execute the side effect function and trigger reading
effect(() => {
  document.body.innerText = obj.name;
});

// Modify the obj.name attribute after 1 second
setTimeout(() => {
  obj.name = "abc";
  // obj.age = 19;
  // obj.notExist = "abc";
}, 1000);