vue source code understanding - change detection

1. Change detection

How to implement data responsive system in Vue to achieve data-driven view.

change detection

One of the biggest features of vue is data-driven view

What is a data-driven view? We can understand the data as a state, and the view is what users can intuitively see. The page is not a constant layer. It changes dynamically. It may be caused by user operation or background data. No matter what it causes, we collectively call the state change from the previous state to the latter state, The page should change accordingly, so we can get the following formula: * * UI = render(state)**

In the formula: status state is the input and the page UI output. Once the status input changes, the page output also changes. We call this feature data-driven view.

OK, after we have the basic concept, we will divide the above formula into three parts: state, render() and UI. We know that the state and UI are set by the user, but the constant is the render(). Therefore, Vue plays the role of render(). When Vue finds that the state changes, after a series of processing, it finally reflects the changes on the UI.

start

Object change detection

let car = {}
        let val = 3000
        Object.defineProperty(car, 'price', {
            enumerable: true,
            configurable: true,
            get() {
                console.log('price The property of was read')
                return val
            },
            set(newval) {
                console.log('price The properties of have been modified')
                val = newval
            }
        })
//Through object The defineproperty () method defines a price attribute for car, and uses get() and set() to intercept the reading and writing of this attribute respectively. get() and set() will be triggered whenever the attribute is read or written 

Note: enumerable controls whether it can be deleted

configurable controls whether enumeration (loop traversal) is possible

How to make all attributes of car observable?

export class Observer {
            constructor(value) {
                this.value = value
// Add a new value__ ob__ Property with the value of the Observer instance
// It is equivalent to marking value to indicate that it has been converted into a response, so as to avoid repeated operations
                def(value, '__ob__', this)
                if (Array.isArray(value)) {
                    // Logic when value is an array
                    // ...
                } else {
                    this.walk(value)
                }
            }

            walk(obj:Object) {
                const keys = Object.keys(obj)
                for (let i = 0; i < keys.length; i++) {
                    defineReactive(obj, keys[i])
                }
            }
        }
        /*
         * Make an object observable
         * @param { Object } obj object
         * @param { String } key key of object
         * @param { Any } val The value of a key of the object
         */
        function defineReactive(obj, key, val) {
            // If only obj and key are passed, then val = obj[key]
            if (arguments.length === 2) {
                val = obj[key]
            }
            if (typeof val === 'object') {
                new Observer(val)
            }
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get() {
                    console.log(`${key}Property was read`);
                    return val;
                },
                set(newVal) {
                    if (val === newVal) {
                        return
                    }
                    console.log(`${key}Property has been modified`);
                    val = newVal;
                }
            })
        }
        let car = new Observer({
            'brand': 'BMW',
            'price': 3000
        })

In the above code, we define the observer class, which is used to convert a normal object into an observable object.

And add a new one to value__ ob__ Property with the value of the Observer instance. This operation is equivalent to marking value, indicating that it has been converted into a response, so as to avoid repeated operations

Then judge the data type. Only the data of object type will call walk to convert each attribute into the form of getter/setter to detect changes. Finally, in defineReactive, when the passed in attribute value is still an object, new observer (val) is used to recurse the sub attributes, so that we can convert all attributes (including sub attributes) in the object into the form of getter/setter to detect changes. In other words, as long as we transfer an object to observer, it will become an observable and responsive object.

Dependency collection

What is dependency collection?

As mentioned above, we can achieve data observability, so that we can know when and what changes have taken place. When the data changes, we can notify the view to update, but there are many views. Who should be notified to change? You can't refresh all views after one data changes, so you should update who uses this data in the view

In other words, we call "who uses this data" as "who relies on this data". We build a dependency array for each data (because a data may be used in multiple places). We put who depends on this data (that is, who uses this data) into this dependency array, Then, when the data changes, we will go to its corresponding dependency array, notify each dependency once, and tell them: "the data you depend on has changed, you should update!". This process is dependent collection

Who uses this data is actually who gets the data, and the getter attribute will be triggered when the observable data is obtained, so we can collect this dependency in the getter. Similarly, when the data changes, the setter property will be triggered, so we can notify the dependency update in the setter.

To sum up, collect dependencies in getter s and notify dependency updates in setter s

Where are the dependencies collected???

We build a dependency array for each data, and we put whoever depends on the data into the dependency array. Using only an array to store dependencies seems to be a little lacking in function and the code is too coupled. We should expand the function of dependency array. A better way is to establish a dependency manager for each data to manage all the dependencies of the data.

export default class Dep {
  constructor () {
    this.subs = []
  }

  addSub (sub) {
    this.subs.push(sub)
  }
  // Delete a dependency
  removeSub (sub) {
    remove(this.subs, sub)
  }
  // Add a dependency
  depend () {
    if (window.target) {
      this.addSub(window.target)
    }
  }
  // Notify all dependent updates
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

/**
 * Remove an item from an array
 */
export function remove (arr, item) {
  if (arr.length) {
    const index = arr.indexOf(item)
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}

In the above dependency manager Dep class, we first initialize a sub array to store dependencies, and define several instance methods to add, delete and notify dependencies

With the dependency manager, we can collect dependencies in the getter and notify dependency updates in the setter. The code is as follows:

function defineReactive(obj, key, val) {
           if (arguments.length === 2) {
               val = obj[key]
           }
           if (typeof val === 'object') {
               new Observer(val)
           }
           const dep = new Dep() //Instantiate a dependency manager to generate a dependency management array dep
           Object.defineProperty(obj, key, {
               enumerable: true,
               configurable: true,
               get() {
                   dep.depend() // Collect dependencies in Getters
                   return val;
               },
               set(newVal) {
                   if (val === newVal) {
                       return
                   }
                   val = newVal;
                   dep.notify() // Notify dependency updates in setter
               }
           })
       }

In the above code, we call the dep.depend() method to collect dependencies in getter, and call dep.notify() in setter to notify all dependent updates.

Who is the dependency???

Through the previous chapter, we understand what dependency is? When to collect dependencies? And where are the collected dependencies stored? So who are the dependencies we collect?

Although we have been saying that "Whoever uses this data is dependent", this is only at the oral level, so how to describe this "who" in the code?

In fact, Vue also implements a class called Watcher, and the instance of Watcher class is the "who" we mentioned above. In other words, we create a Watcher instance for those who use data and those who rely on it. When the data changes later, we do not directly notify the dependency update, but notify the corresponding Watch instance, and the Watcher instance notifies the real view.

export default class Watcher {
  constructor (vm,expOrFn,cb) {
    this.vm = vm;
    this.cb = cb;
    this.getter = parsePath(expOrFn)
    this.value = this.get()
  }
  get () {
    window.target = this;
    const vm = this.vm
    let value = this.getter.call(vm, vm)
    window.target = undefined;
    return value
  }
  update () {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

/**
 * Parse simple path.
 * Put a shape like 'data a. The value represented by the string path of B.C 'is taken from the real data object
 * For example:
 * data = {a:{b:{c:2}}}
 * parsePath('a.b.c')(data)  // 2
 */
const bailRE = /[^\w.$]/
export function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
  1. When the Watcher class is instantiated, its constructor will be executed first;
  2. The this. is called in the constructor. Get() instance method;
  3. In the get() method, first pass window Target = this assigns the instance itself to a unique global object window Target, and then let value = this getter. Call (VM, VM) to obtain the dependent data. The purpose of obtaining the dependent data is to trigger the getter above the data. As we said above, dep.dependent() will be called in the getter to collect the dependencies, and dep.dependent() will be used to get the attached window The value on target and store it in the dependency array. At the end of the get() method, window Target is released.
  4. When the data changes, it triggers the setter of the data and calls the dep.notify() method in setter. In the dep.notify() method, it traverses all the dependency (i.e. watcher instances) and executes the dependent update() method, that is, update() in the Watcher class, and calls the updated callback function of the data in the update() method to update the view.

To sum up, watcher first sets himself to the globally unique specified location (window.target), and then reads the data. Because the data is read, the getter of this data will be triggered. Then, the watcher currently reading data will be read from the globally unique location in the getter, and the Watcher will be collected in Dep. After collection, when the data changes, a notification will be sent to each Watcher in the Dep. In this way, watcher can actively subscribe to any data change.

Insufficient:

Although we passed object The defineproperty method realizes the observability of object data, but this method can only observe the value and setting value of object data. When we add a pair of new key / values to object data or delete a pair of existing key / values, it cannot be observed, resulting in the inability to notify dependencies when we add or delete values from object data, Cannot drive view for reactive update.

Of course, Vue has also noticed this. In order to solve this problem, Vue has added two global APIs: Vue Set and Vue Delete, the implementation principles of these two APIs will be discussed later when learning the global API

Summary:

First, we use object The defineproperty method realizes the observability of object data and encapsulates the Observer class, so that we can easily convert all properties (including sub properties) in object data into getter / setter to detect changes.

Next, we learned what is dependency collection? We also know that we collect dependencies in getter s, notify dependency updates in setter s, and encapsulate dependency manager Dep to store the collected dependencies.

Finally, we create a Watcher instance for each dependency. When the data changes, we notify the Watcher instance and let the Watcher instance do the real update operation.

The whole process is roughly as follows:

  1. Data is converted into getter/setter form through observer to track changes.
  2. When the outside world reads data through the Watcher, the getter will be triggered to add the Watcher to the dependency.
  3. When the data changes, the setter will be triggered to send a notification to the dependency (i.e. Watcher) in Dep.
  4. After receiving the notification, Watcher will send a notification to the outside world. After the change notification is sent to the outside world, it may trigger view update or a user's callback function.

Array change detection

In the previous article, we introduced the change detection method of Object data. In this article, we will take a look at how to detect the change Vue of Array data.

Why do Object data and Array data have two different change detection methods?

This is because we use the Object method on the Object prototype provided by JS for Object data Defineproperty, and this method is based on the Object prototype, so Array cannot use this method, so we need to design another change detection mechanism for Array data.

Although a new change detection mechanism is designed for Array data, its basic idea remains the same. That is: the dependency is still collected when the data is obtained, and the dependency update is notified when the data changes.

Let's see how Vue detects changes in Array data through the source code.

Similarly, the idea is the same as that of the object. First, collect the places where Array data is used as dependencies.

So how to collect???

In fact, the dependent collection method of Array data is the same as that of Object data, which is collected in getters. Then the problem comes, not that Array cannot use Object Defineproperty method? If you can't use it, how can you collect dependencies in getters?

Then let's recall whether it is written like this in the data of components during development:

data(){
  return {
    arr:[1,2,3]
  }
}

The data of arr always exists in an object data object, and we also said that whoever uses the data is dependent. If you want to use the data of arr, do you have to obtain the ARR data from the object data object first, and obtaining the ARR data from the object data object will naturally trigger the getter of arr, so we can collect the dependencies in the getter.

To sum up, Array data is still collected in getter s.

How to make Array data observable???

When detecting Object data changes, we first make the Object data observable, that is, we can know when the data has been read and when it has changed. Similarly, for Array data, we have to make it observable. At present, we have completed half of the observability, that is, we only know when the Array data is read, and we can't know when it changes. Then let's solve this problem: how do we know when the Array data changes?

analysis:

Object changes are tracked through setters. Only when a data changes, the setter on the data will be triggered. But there is no setter for Array data. What should I do?

Let's imagine that if we want to change the Array data, we must operate the Array, and there are so many methods to operate the Array provided in JS. We can rewrite these methods and add some other functions without changing the original functions, such as the following example:

let arr=[1,2,3]
        arr.push(4)
        Array.prototype.newPush=function(val){
            console.log('arr It was modified')
            this.push(val)
        }
        arr.newPush(4)

In the above example, we define a new newPush method for the native push method of the array. This newPush method calls the native push method internally, which ensures that the new newPush method has the same functions as the native push method, and we can also do other things inside the new newPush method, such as notifying changes.

Isn't it clever? That's what Vue does internally.

Array method Interceptor:

An array method interceptor is created in Vue, which intercepts the array instance and array Between prototypes, some methods of operating arrays are rewritten in the interceptor. When the array instance uses the operating array method, it actually uses the rewritten method in the interceptor instead of array Native methods on prototype.

After sorting, there are seven methods in the Array prototype that can change the contents of the Array itself: push, pop, shift, unshift, splice, sort and reverse. Then the interceptor code in the source code is as follows:

const arrayProto = Array.prototype
// Create an object as an interceptor
export const arrayMethods = Object.create(arrayProto)

// Seven methods to change the contents of the array itself
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]      // Cache native method
  Object.defineProperty(arrayMethods, method, {
    enumerable: false,
    configurable: true,
    writable: true,
    value:function mutator(...args){
      const result = original.apply(this, args)
      return result
    }
  })
})

In the above code, first create an empty object arrayMethods inherited from the Array prototype, and then use object. On arrayMethods The defineproperty method encapsulates seven methods that can change the Array itself one by one. Finally, when we use the push method, we actually use arrayMethods Push, and arrayMethods Push is the encapsulated new function mutator. Later, the real label executes the function mutator, and the mutator function internally executes the original function, which is Array The corresponding native method on the prototype. Then, we can do other things in the mutator function, such as sending change notifications.

Using interceptors:

In the figure in the previous section, it is not enough for us to do a good job of interceptor. We also need to mount it to the array instance and array Between prototypes, so that the interceptor can take effect.

In fact, it's not difficult to mount. We just need to upload the data__ proto__ Property can be set to interceptor arrayMethods. The source code implementation is as follows:

export class Observer {
  constructor (value) {
    this.value = value
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}
// Capability test: Judgment__ proto__ Available because some browsers do not support this property
export const hasProto = '__proto__' in {}

const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

/**
 * Augment an target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}

/**
 * Augment an target Object or Array by defining
 * hidden properties.
 */
/* istanbul ignore next */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

The above code first determines whether the browser supports__ proto__, If it is supported, call the protofragment function to add value__ proto__ = arrayMethods; If it is not supported, call the copyfragment function to add the seven method loops rewritten in the interceptor to value.

After the interceptor takes effect, when the Array data changes again, we can notify the interceptor of the change, that is, now we can know when the Array data changes. OK, we have completed the observability of Array data.

Collection dependencies:

Where are the dependencies collected???

As we said, array data dependencies are also collected in getters, and adding getters / setters to array data is done in the Observer class, so we should also collect dependencies in the Observer class. The source code is as follows:

export class Observer {
  constructor (value) {
    this.value = value
    this.dep = new Dep()    // Instantiate a dependency manager to collect array dependencies
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}

In the above code, a dependency manager is instantiated in the Observer class to collect array dependencies

How to collect dependencies???

, array dependencies are also collected in getters, so how to collect them in getters? One thing to note here is that the dependency manager is defined in the Observer class, and we need to collect dependencies in the getter, that is, we must be able to access the dependency manager in the Observer class in the getter before we can save the dependencies. The source code does this:

function defineReactive (obj,key,val) {
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      if (childOb) {
        childOb.dep.depend()
      }
      return val;
    },
    set(newVal){
      if(val === newVal){
        return
      }
      val = newVal;
      dep.notify()   // Notify dependency updates in setter
    }
  })
}

/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 * Try to create a 0bserver instance for value. If the creation is successful, directly return the newly created Observer instance.
 * If an Observer instance already exists for Value, it is returned directly
 */
export function observe (value, asRootData){
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

In the above code, we first try to create an Observer instance for the obtained data arr through the observe function. In the observe function, we first judge whether there is any error in the current incoming data__ ob__ Attribute, because it was said in the previous article that if the data has__ ob__ Property, indicating that it has been converted into responsive. If not, it indicates that the data is not responsive. Then call new Observer(value) to convert it into responsive and return the Observer instance corresponding to the data.

In the defineReactive function, first get the Observer instance childOb corresponding to the data, then call the dependency manager on the Observer instance in getter, and then rely on the collection.

So far, dependencies have been collected and stored. How can we notify dependencies?

In fact, it's not difficult. As mentioned earlier, we should notify dependencies in the interceptor. To notify dependencies, we must first be able to access dependencies. It is not difficult to access dependencies, because we only need to access the data value that is converted into a response, because the data on the vaule__ ob__ It is the corresponding Observer class instance. With the Observer class instance, we can access the dependency manager above it, and then just call the dep.notify() method of the dependency manager to notify the dependency update. The source code is as follows:

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    // notify change
    ob.dep.notify()
    return result
  })
})

In the above code, because our interceptor is attached to the prototype of array data, this in the interceptor is the data value. Get the Observer class instance on value, so you can call the dep.notify() method of the dependency manager on the Observer class instance to notify the dependency.

OK, the above basically completes the change detection of Array data.

Depth detection:

All the Array type data change detection mentioned above only refers to the detection of the change of the Array itself, such as adding an element to the Array or deleting an element in the Array. In Vue, the data change detection realized by both Object type data and Array type data is depth detection. The so-called depth detection is not only to detect the change of the data itself, It also detects the changes of all sub data in the data. for instance:

let arr = [
  {
    name:'NLRX',
    age:'18'
  }
]

The array contains an object. If a property of the object changes, it should also be detected. This is depth detection.

The implementation is relatively simple. The source code is as follows:

export class Observer {
  value: any;
  dep: Dep;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)   // Converts all elements in the array into detectable responses
    } else {
      this.walk(value)
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

export function observe (value, asRootData){
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

In the above code, for Array data, the observeArray() method is called, which will traverse each element in the Array, and then convert each element into detectable responsive data by calling the observe function.

For the corresponding object data, in the previous article, we have performed recursive operations in the defineReactive function.

Detection of new elements in array

We can convert all the existing elements in the array into detectable responsive data, but if we add an element to the array, we also need to convert the new element into detectable responsive data.

This is easy to implement. We just need to get the new element and call the observe function to transform it. We know that there are three methods to add elements to the array: push, unshift and splice. We only need to process the three methods separately, get the new elements, and then convert them. The source code is as follows:

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args   // If it is a push or unshift method, the passed in parameter is the new element
        break
      case 'splice':
        inserted = args.slice(2) // If it is a splice method, the new element is the one with subscript 2 in the passed in parameter list
        break
    }
    if (inserted) ob.observeArray(inserted) // Call the observe function to convert the new element into a response
    // notify change
    ob.dep.notify()
    return result
  })
})

Insufficient:

As we said earlier, array change detection is realized through interceptors, that is, as long as the array is operated through the method on the array prototype, we can detect it. However, don't forget that in our daily development, we can also operate data through the subscript of the array, as follows:

let arr = [1,2,3]
arr[0] = 5;       // Modify the data in the array through the array subscript
arr.length = 0    // Empty the array by modifying the length of the array

Using the operation method in the above example to modify the array is undetectable. Similarly, Vue has also noticed this problem. In order to solve this problem, Vue has added two global APIs: Vue Set and Vue Delete, the implementation principles of these two APIs will be discussed later when learning the global API

Summary:

In this article, firstly, we analyze the dependency collection of Array data in getter; Secondly, we found that it is easy for us to know when the Array data is accessed, but it is difficult for us to know when it is modified. In order to solve this problem, we created an Array method interceptor to successfully make the Array data observable. Then we deeply analyze the dependency collection of Array and how to notify dependency when data changes; Finally, we find that Vue not only detects the change of the Array itself, but also detects the change of each element and new elements in the Array. We also analyze its implementation principle.

The above is the change detection and analysis of Array data.

Keywords: Javascript Front-end Vue.js

Added by Sheen on Tue, 25 Jan 2022 00:46:59 +0200