In daily development, you often come across situations like this:

Define an empty object (or array), call the back-end interface, get the data, assign it to the object (or array), and then display the data on the page:

new ViewComponent({ el: '#app', data: function () { return { good: {} } }, methods: { getGoodData () { let _this = this setTimeout(function () { _this.good = { id: '8faw8cs4fw9760zt7tnesini4qup5hid', name: 'iPhoneX', cpuNum: 1, memory: 1073741824, brand: 'iPhone', category: 'phone', color: }, changeName () {this.good.name = 'new iPhonex'}}, render: Function () {// create a new DOM let children = [this.createElement('div', {class: 'item' }, this.good.name), this.createElement('div', { class: 'item' }, this.good.brand), this.createElement('div', { class: 'item' }, this.good.cpuNum), this.createElement('div', { class: 'item' }, this.good.memory), this.createElement('div', { class: 'item' }, this.good.category), this.createElement('div', { class: 'item' }, this.good.color), this.createElement('div', { class: 'item' }, this.good.region), ] return this.createElement('div', { class: 'good-detail' }, [ this.createElement('button', { on: { click: CreateElement ('div', {class: 'detail'}, children)])}, mounted: function () { this.getGoodData() } })Copy the code

In addition, some properties on this object will be changed, for example we may have a rename function, which in the callback will reassign good.name, as in the changeName code above

Sometimes, we will also extend some attributes on the object ourselves, such as the methods -> getGoodData attribute in the code above

_this.good.region = 'Beijing'Copy the code

After setTimeout has passed for 1 second, the existing properties on Good are rendered, but the extended region in getGoodData is not displayed. Click the button to trigger changeName to change good.name

Because we don’t support this yet, in this section we’ll take the code a step further by allowing the object to extend properties and trigger component re-rendering when properties are modified

We will recall the component initialization and then trigger the render again update process: initialization, traverse the inside of the data, props, and so on data, through defineReactive, it changed to response data, and then collected through renderAndCollectDependencies dependency

Note: At the beginning, only one of our data values was good for an empty object, so when we initialize good, only good has the corresponding updateFns. But because good is an empty object, there is no key value in good, so when we recursively iterate through the property and add the response, nothing is iterated

The dependency will only be collected when good is called by this.good. The dependency will not be collected when name is called by this.good

To verify that good is responsive, we can create good in render and put it on the page:

    return this.createElement('div', { class: 'good-detail' }, 
      [
        this.createElement('div', { class: 'detail' }, children),
        this.good
      ]
    )
Copy the code

The effect is as follows:

Object Object at the bottom is good’s rendering on the page

After render is executed, the component then executes into mounted hook, which calls getGoodData to retrieve the data and re-assigns good

Since good is responsive, assigning a value to good triggers the corresponding dependency of good, namely the execution of the component render function update. When the component is re-rendered, the new good object can be obtained in the render, and its value can naturally be displayed in the DOM

However, these values are only assigned to good, and the attribute in good is not responsive, so we click the button to trigger changeName, although the name is changed: This.good. name = ‘new iPhonex’, but good.name is not handled by defineReactive, so it is a normal attribute of good, not a reactive attribute. No dependencies are collected, so there is no updateFns

Since the problem here, naturally, we do the judgment in the attribute set, if you find is to assign the value of the object types (or an array type), all the properties in the object that is about to depth traversal (depth through the array under various) to be redefined as responsive note: the situation of the array we will discuss in the next section

function defineReactive(data, key, val) { let updateFns = [] Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { if (curExecUpdate && ! updateFns.find(fn => fn === curExecUpdate)) { updateFns.push(curExecUpdate) } return val }, set: Function (newVal) {val = newVal; Will depth traversal object in all property the if (isObject (newVal) | | isArray (newVal)) {setDataReactive (newVal)} for (let I = 0; i < updateFns.length; i++) { updateFns[i]() } } }) }Copy the code

Notice that in the set hook, we added the operation to make all properties in newVal reactive setDataReactive(newVal)

After this, refresh and look at the effect, it still doesn’t seem to work

Our intention is to expect a click on the rename to trigger:

    changeName () {
      this.good.name = 'new iPhonex'
    }
Copy the code

This triggers the set hook for Name, then finds the updateFns for name, and iterates through the updates

But that’s where the problem is, is there any component rendering function that we want in updateFns?

The answer is no

Because we only collect render functions in updateFns when the component calls init initialization, and then even if we call name to trigger its GET hook, curExecUpdate is null and nothing is collected

So, the timing of our dependency collection is problematic

< span style = “box-color: RGB (74, 74, 74); color: RGB (74, 74, 74); font-size: 14px! Important; white-space: inherit! Important;”

Watcher.prototype.addDep = function addDep (dep) { var id = dep.id; if (! this.newDepIds.has(id)) { this.newDepIds.add(id); this.newDeps.push(dep); // If the watcher to be added is associated with the deP, this judgment is true if (! this.depIds.has(id)) { dep.addSub(this); }}};Copy the code

Let’s follow Vue’s lead:

ViewComponent.prototype.renderAndCollectDependencies = function () { this.update = this.update.bind(this) this.update() let fn = this.$options.mounted if (fn && isFunction(fn)) { fn.call(this) } } ViewComponent.prototype.update = function () {/ / clean up old DOM let oldRenderedElement = this. RenderedElement | | this. El enclosing renderedElement = this. And _render the if () (oldRenderedElement) { let parent = oldRenderedElement.parentNode let sibling = oldRenderedElement.nextElementSibling parent.removeChild(oldRenderedElement) if (! this.renderedElement) return if (sibling) { parent.insertBefore(this.renderedElement, sibling) } else { parent.appendChild(this.renderedElement) } } } ViewComponent.prototype._render = function () { pushCurExecUpdateToStack(this.update) let renderedElement = this.render() popCurExecUpdateFromStack() return renderedElement }Copy the code

There are three major changes to the code above:

  1. Remove the pushCurExecUpdateToStack and popCurExecUpdateFromStack renderAndCollectDependencies
  2. ViewComponent) prototype) render changed to and _render update
  3. And _render method has been added, it calls the developer render their own definition, and used pushCurExecUpdateToStack and popCurExecUpdateFromStack surrounded, so it did every time rendering, collect all rely on, We also have logic to determine if dependencies are duplicated, so we don’t collect dependencies twice

After changing the code, we refresh the page and click the rename button, we get the desired effect (I checked, nuggets seems to be unable to insert the video, unfortunately), the effect can only rely on your imagination, or finally look at the source CODE I give you can also

This. Good. Name = ‘new iPhonex’, good get hook, good. Name set hook

The timing of the appearance of this region is obviously wrong. We assign the region attribute to the object after we get the data in getGoodData. We should come out immediately after the value is assigned. Instead of clicking on rename to trigger changeName, which is what we’re going to solve next

Vue solves this problem by providing a $set method, which is called if you need to add new attributes to objects in the initialized data

$set = $set = $set = $set = $set = $set = $set = $set = $set = $set = $set

This.good. Region = this.good. Region = this.good

If you change this property, it triggers its set hook, which then iterates through the updateFns functions. So we can update the DOM but if we want to add a property that we don’t have on Good, like a region, when we add a region we also want to iterate over some updateFns and update the DOM but in our current implementation, the property and updateFns correspond one to one, Good doesn’t even have a region attribute, and it’s unlikely to have a corresponding updateFns

In order to solve this problem, we need to design another type of updateFns according to Vue. This type of updateFns does not correspond to properties, but to objects. Suppose data is written in the component definition as follows:

Let myComp = {data: function () {return {basicTypeAttr: 'basic type ', referenceTypeAttr: {key1:' object type value1', key2: 'Object Type value2'}}}}Copy the code

In our current code, for the myComp component, after initData is executed, there will be four updateFns corresponding to basicTypeAttr, referenceTypeAttr, key1, key2

ReferenceTypeAttr is a data attribute of the referenceTypeAttr type. Create an updateFns corresponding to it. This updateFns will be stored on the referenceTypeAttr object as an attribute:

ReferenceTypeAttr: {key1: 'value1', key2:' value2', _updateFns: updateFns}Copy the code

Of course, this property is just a convenience for us to retrieve the updateFns from the object later to update the component. It should not be provided to developers, so its Enumerable should be set to False

And in addition to that, I’ve added a method called set to catch this new keyvalue that’s being added to an object, To update the DOM, fetch the object to which the attribute is to be added in the set, fetch _updateFns from that object, and execute it

And that’s the principle that seemed, when I looked at it earlier, pretty reasonable

A few days ago when I was debugging ata breakpoint, I tried to console all the DEPs on a component to see which data keys it corresponds to and which objects it corresponds to:

In the figure above, the console with a number behind it is deP related, and that number is its ID

I found that the updateFns corresponding to this object and the updateFns corresponding to the key (in this case referenceTypeAttr) of the upper object should be the same function, and both should be the update that needs to be triggered when this property is modified. For example, deP 8 is the deP of an object. Dep 7 and DEP 8 are both applied to the same key on the same object, which is testObject

From a space-saving point of view, it would be best if there were a way to combine dePs with ids 7 and 8, because these two dePs correspond to directly reassigning to testObjects and adding, deleting, or modifying properties on testObjects. Therefore, testObject dependencies should be triggered to update

But the author doesn’t do this, perhaps because of the grammatical support at the language level? Or something else that’s hard to deal with? For example, when you change a property at a very deep level in an object, it’s not a good idea to notify each parent object layer by layer, right? This question is purely my personal guess, if there is any mistake, welcome to point out, also welcome everyone to discuss

So much nonsense, we still follow the idea of Vue, see how to achieve

The first thing we need to change is the setDataReactive function. Since we only considered adding the corresponding updateFns to each attribute, the function of this function is to deeply traverse each attribute. When traversing each attribute, every time we call defineReactive, Create your own updateFns for this property in the scope of this call, which is a classic use of closures

So along the same lines, if we were to create an updateFns for each object in data, we would have to iterate over an object, call a function once, and create an updateFns for that object in that function’s scope, again using closures

Now let’s modify setDataReactive to be the “some function” we mentioned above.

How do you do that? The answer can be found in the call relationship between setDataReactive and defineReactive

SetDataReactive deeply traverses each property of the object, and calls defineReactive each time an attribute is traversed. This reduces the dimension of setDataReactive traversal. When iterating over an object, it does not go deep inside it and passes the value directly to setDataReactive. When setDataReactive determines that the value is an object, it calls defineReactive

The nice thing about this is that each call to defineReactive is actually a call to each level of objects encountered in data, which gives you an opportunity to create your own updateFns for each object

function setDataReactive (data) {
  let updateFns = []
  Object.defineProperty(data, '_updateFns', {
    value: updateFns,
    enumerable: false,
    writable: true,
    configurable: true
  })
  let keys = Object.keys(data)
  for (let i = 0; i < keys.length; i++) {
    let v = data[keys[i]]
    defineReactive(data, keys[i], v)
  }
  return updateFns
}
Copy the code

In this code, the setDataReactive updateFns corresponds to the object iterated over. In the for loop, we can see that the previous judgment is removed:

  if (isObject(val)) {
    setDataReactive(val)
  }
Copy the code

As we’ll see in a moment, this paragraph is moved into defineReactive

The other thing to notice is that we’re going to put updateFns back, so what do we do back? Let’s take a quick look at defineReactive to see what we’re going back to:

let curExecUpdate = null function defineReactive(data, key, Val) {let updateFns = [] let objectUpdateFns if (isObject(val)) {objectUpdateFns = setDataReactive(val) } Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { if (curExecUpdate && ! updateFns.find(fn => fn === curExecUpdate)) { updateFns.push(curExecUpdate) } if (objectUpdateFns && curExecUpdate && ! objectUpdateFns.find(fn => fn === curExecUpdate)) { objectUpdateFns.push(curExecUpdate) } return val }, set: function (newVal) { val = newVal if (isObject(newVal) || isArray(newVal)) { objectUpdateFns = setDataReactive(newVal) } for (let i = 0; i < updateFns.length; i++) { updateFns[i]() } } }) }Copy the code

You can see above defineProperty that’s what we did when we decided the value iterated was an object

Also, we’ve assigned the return value of setDataReactive to a variable called objectUpdateFns, which, as you can see from the name of the variable, is actually the updateFns of the object itself, Let’s use the myComp example above to illustrate the relationship between objectUpdateFns and updateFns:

Let myComp = {data: function () {return {basicTypeAttr: 'basic type ', referenceTypeAttr: {// referenceTypeAttr has its own "updateFns attribute" key1: 'object type value1', key2:' Object type value2', _updateFns: []}}}}Copy the code

ReferenceTypeAttr _updateFns corresponds to objectUpdateFns. When set is executed, this. Update is placed in the updateFns object. When we talk about set execution, we’re going to put this.update inside the _updateFns, and we’ll make it clear when we talk about set execution

The object I’m going to call objectUpdateFns actually corresponds to childob.dep in Vue

I also added this paragraph to the defineProperty get hook:

if (objectUpdateFns && curExecUpdate && ! objectUpdateFns.find(fn => fn === curExecUpdate)) { objectUpdateFns.push(curExecUpdate) }Copy the code

ReferenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr = referenceTypeAttr

if (curExecUpdate && ! updateFns.find(fn => fn === curExecUpdate))Copy the code

Put myComp’s update in updateFns and there it is

if (objectUpdateFns && curExecUpdate && ! objectUpdateFns.find(fn => fn === curExecUpdate))Copy the code

ObjectUpdateFns will only receive setDataReactive if value is an object, otherwise undefined, so for referenceTypeAttr, which is the key of the object, this condition will be true, Add the dependency to objectUpdateFns, which will be triggered in $set

The last thing we need to mention here is the set hook modification. We notice that inside the set hook, we also receive the return value of setDataReactive with objectUpdateFns, which is used to assign a new object to a property, This new object generates a new objectUpdateFns case

To summarize the experience of writing this code: when I just looked at the Vue source code, I passed

observe -> new Observer -> walk -> defineReactive
Copy the code

When I follow up step by step, I only know how to follow the author’s ideas, so I can’t feel the design ideas. When I really realize such a function and find this problem, I have to say that in setDataReactive, DEFINE a variable objectUpdateFns that maps to the object to form a closure. The design is very clever

This completes the changes to setDataReactive and defineReactive. We’ve mentioned $set several times in the process and can now pull it out because we’re adding a new method, so this is pure new code:

ViewComponent.prototype.$set = function (target, key, val) { if (key in target) { target[key] = val return } let updateFns = target._updateFns if (! updateFns) { target[key] = val return } defineReactive(target, key, val) for (let i = 0; i < updateFns.length; i++) { updateFns[i]() } }Copy the code

The most important part of the code is the final traversal of defineReactive, which adds a reactive property named key and value to the target object, and updateFns, which updates the DOM. Target. _updateFns is actually objectUpdateFns in defineReactive

Target [key] = val triggers the set hook for the key, and iterates through the DOM updateFns for the key

At this point, the value of object type, responsive support is almost complete, and we can revisit the getGoodData execution flow from the example at the beginning of this article

getGoodData () { let _this = this setTimeout(function () { _this.good = { id: '8faw8cs4fw9760zt7tnesini4qup5hid', name: 'iPhoneX', cpuNum: 1, memory: 1073741824, brand: 'iPhone', category: 'phone', color: 'black'} _this. $set (_this. Good, 'region', 'Beijing')}, 1000)}Copy the code

Let’s focus on the flow inside setTimeout

Because we’re assigning to good as an object type, we need to recursively iterate through each property, add set and GET hooks to it, and also add _updateFns to good itself. So this _updateFns is a reference to the updateFns of this new Good object, which at this point is an empty array and I’m going to go ahead and execute the updateFns hook function in the set hook for Good, and notice that the updateFns correspond to the good property, UpdateFns (); render (); render (); render (); render ();

this.good.name
Copy the code

This will trigger their get hooks, collecting dependencies, in this case good’s get hook:

if (curExecUpdate && ! updateFns.find(fn => fn === curExecUpdate)) { updateFns.push(curExecUpdate) } if (objectUpdateFns && curExecUpdate && ! objectUpdateFns.find(fn => fn === curExecUpdate)) { objectUpdateFns.push(curExecUpdate) }Copy the code

For Good, the App update is already in its updateFns, so the first if judgment is false

In the second if judgment, objectUpdateFns is actually the updateFns of the Good object, but note that this objectUpdateFns is not the component initialization object:

  data: function () {
    return {
      good: {}
    }
  },
Copy the code

The objectUpdateFns of the good object, but the newly assigned Good object:

        _this.good = {
          id: '8faw8cs4fw9760zt7tnesini4qup5hid',
          name: 'iPhoneX',
          cpuNum: 1,
          memory: 1073741824,
          brand: 'iPhone',
          category: 'phone',
          color: 'black'
        }
Copy the code

ObjectUpdateFns, which is what this line of code in set hook does:

        objectUpdateFns = setDataReactive(newVal)
Copy the code

That is, objectUpdateFns will change depending on which object you assign the property to

After executing this. Good hook, this. Good. Name hook is executed

After the render function completes, update the rest of the DOM, delete the old DOM, and add the new DOM. Note that the new DOM does not have this.good.region in it yet

Proceed:

_this.$set(_this. Good, 'region', 'Beijing ')Copy the code

The first step is to get the updateFns of the target object via target._updateFns, which is the updateFns of _this.good

Add the region attribute to good via defineReactive(target, key, val), and the region is reactive

When iterating over the this.update call to updateFns, you start creating the DOM and then incidentally collecting dependencies

In fact, the dependencies (this.update) for good and its sub-attributes have been collected in the previous round of execution. Therefore, this update mainly collects dependencies for region attributes in case of region modification

At this point, the procedures in the App component’s getGoodData are analyzed

Finally, we can see that although we can implement the current functionality, the code is very redundant, and some areas are very confusing. For example, the relationship between updateFns and objectUpdateFns is confusing. The code that iterates through the updateFns execution is repetitive

So, in Vue, the author encapsulates an object called Dep to abstract. We have already mentioned the term dependency several times before, but Dep in Vue actually stands for dependency. In the next section, we will separate this part out to make the code clearer

In addition, there is another problem. In the getGoodData method, we assign good once and set a new region property to good. This results in two consecutive DOM updates, but there is no need. Updating the DOM every time you set it (usually at a component level) is a bad idea and a big drain on performance, so we will implement asynchronous updating to solve this problem after all the content in the reactive part is finished

The complete code