Vue source code interpretation-data responsiveness principle (easy to understand)

1. Object change detection

Introduction of 1.1 API

1.1.1 Object.defineProperty()

In vue2.x, we often encounter the situation that when the value of the data changes, the referenced part of the value on the page will also be updated. So today we are going to unravel this magical veil.

Object.defineProperty() can be used to monitor the properties corresponding to an object. The monitoring types are mainly divided into value properties and accessor properties. It is the basic principle of vue2.x responsive data implementation.

1.1.2 Configuration of value attributes

Whether the target attribute is allowed to be deleted, traversed, overwritten, and configured for the attribute’s value.

let data = { }
Object.defineProperty(data, 'age', {
  configurable: false, // When deleted, silently fails
  writable: false, // Fails silently when overridden
  enumerable: false, // cannot be enumerated through for/in
  value: 23,//The value of this attribute
})
console.log(data.age); //23
delete data.age // Silent failure
console.log(data.age); //23
data.age=30 // Silent failure
console.log(data.age); //23
for (const key in data) {
  console.log(key,'key'); // Silent failure The age attribute cannot be accessed!
}

1.1.3 Configuration of accessor attributes

The target property is hijacked before operations such as access and assignment are completed.

let person = {
    _name:'Jone'
}
Object.defineProperty(person, 'name', {
    get: () => {
        console.log('GET');
        return person._name
    },
    set: (value) => {
        console.log('SET');
        person._name=value
    }
})
console.log(person.name); // 'GET' 'Jone'
person.name='Mike' // 'SET'

Through the above code, we wonder why we do not configure a value attribute but use a third-party attribute _name to complete it? The official explanation for this problem is:

The accessor attribute cannot be configured at the same time as (writable and value) in the value attribute.

Let’s think about it: If I configure a value attribute and a get accessor attribute for a property. So when we access this property, should the get accessor or the value prevail?

1.2 How to implement data hijacking

If we want to hijack the data in data in depth, we can use the depth-first search algorithm to achieve:

    • If the type of the value pointed to by the current key is Basic data type, use the Object.defineProperty() API to implement data hijacking.

    • If the type of the value pointed to by the current key is Object type in complex data types, the key-value pair in this Object needs to be hijacked.

1.3 Implement data hijacking

class Vue {
  constructor(rest) {
    let { data, watch } = rest
    this.$data = typeof data === 'function' ? data() : data
    this.initData(this.$data)
    // start recursion
    this.observe(this.$data)
  }
}

function observe(data) {
  newObserver(data)
}

class Observer {
  constructor(data) {
    this.walk(data)
  }
  walk(data) {
    for (const key in data) {
      reactive(data, key, data[key])
    }
  }
}

function reactive(object, key, val) {
  let isArray = val instanceof Array
  let isObject = val instanceof Object
  // If the object pointed to by the key is of array type, this section will not handle it yet.
  if(isArray) return
  // If the object pointed to by the key is of object type, then recurse on the object.
  if (isObject) {
    return observe(val)
  }

  //Data hijacking operation
  Object.defineProperty(object, key, {
    configurable: true,
    enumerable: true,
    get() {
      return val
    },
    set(value) {
      if (val !== value) {
        val = value
      }
    }
  })
}

Through the above code, it is not difficult for us to find that in the process of implementing recursive logic, it is not the call of the function itself, but the recursive logic is completed by connecting the first three functions. The figure below shows how three functions implement recursive logic:

83d10b2879aca53bc6744baa7f35e825.png

1.4 Why dependency collection is needed

In the above, we implemented a hijacking operation on the data. It does not work if we just hijack the data, because the function we need is to update the part that references the data when the data changes, so we also need to know the following Two contents:

  • Where the reactive data is referenced (template or computed property or elsewhere).

  • When the responsive data changes, the part that references the data is notified to be updated.

1.5 Where to collect and when to notify of updates

1.5.1 Where to collect

for example:

<template>
    <p>{<!-- -->{ name }}</p>
</template>

The value of the responsive data name is referenced in this template. In other words: In this template, the value of the name attribute of the responsive data is first accessed, and then the corresponding value is placed in the corresponding position of the template. Therefore, when the name attribute is accessed, the accessed location is marked. In other words: this location depends on the value of the name attribute, and this part of the logic needs to be collected.

By coincidence, isn’t the purpose of the get() accessor in the API to intercept when data is accessed? So we should perform dependency collection in the get() function.

1.5.2 When to notify of updates

When the value of reactive data changes, the logical part that references the data should be updated. Where do we know when responsive data changes? Is it possible to perform notification updates in the set() accessor?

1.6 Introduction to collecting dependencies

After analysis, we know that dependencies need to be collected in the get() function. So where are the collected dependencies stored? Should we consider storing dependencies in an array or an object?

function reactive(object, key, val) {
  let dep = []
  Object.defineProperty(object, key, {
    configurable: true,
    enumerable: true,
    get() {
      // Dependency collection place, Dep.target regards it as a dependency.
      dep.push(Dep.target)
      return val
    },
    set(value) {
      if (val !== value) {
        // Dependent triggering
        dep.forEach(cb=>cb())
        val = value
      }
    }
  })
}

Through the above code, we add a new array dep to store the collected dependencies. It is worth noting that because the get() and set() functions both reference the variable dep declared in their parent scope, a closure is formed.

But this way of writing has a lower degree of coupling. We can encapsulate a separate Dep class and let it be responsible for dependency collection.

class Dep {
  constructor() {
    //Dependency collection center
    this.subs = []
  }
  // Collection of dependencies
  add() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }
  // Trigger the callback function corresponding to the dependency
  update() {
    let subs = this.subs.slice()
    subs.forEach(watch => {
      //Trigger dependent callback function
      watch.run()
    })
  }
}

Then we transform the functions corresponding to actions that rely on collection.

function reactive(object, key, val) {
    let dep = new Dep()
    Object.defineProperty(object, key, {
        configurable: true,
        enumerable: true,
        get() {
            // Dependency collection place
            dep.append()
            return val
        },
        set(value) {
            if (val !== value) {
                // Dependent triggering
                dep.update(value)
                val = value
            }
        }
    })
}

1.7 Introduction to dependencies

1.7.1 What is dependency

Who is collected when reactive data is accessed. When the responsive data changes, notify who to update; this who is the dependency. Since reactive data may be referenced in templates or computed, we might as well encapsulate a class instance and collect the instance directly when collection is needed. When the responsive data changes, only one will be notified, and then he will notify other places for updates. Let’s give this instance a name, Watcher .

1.7.2 Dependent function encapsulation

    1. When reactive data is accessed, we need to instantiate an object that is the target of collection.

    2. When the responsive data changes, the instance object needs to notify the reference part to update.

// Dependency constructor
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm
    this.key = key
    this.cb = cb
    this.get()
  }
  get() {
    //Depends on collected objects
    Dep.target = this
    // The get function in Object.defineProperty will be called. After the call, dependencies are collected.
    this.vm[this.key]
    Dep.target = undefined
  }
  run() {
    // Ability to notify updates
    // In order to prevent errors in this pointer, we rebind this pointer.
    this.cb.apply(this.vm)
  }
}

1.8 Simulate the watch option in vue

In vue, a watch listener option is provided, its function is to execute the corresponding callback function when the responsive data changes. Combined with the previous logic, we encapsulate a watch option that belongs to us.

import { arrayProto } from './array.js'

class Vue {
  constructor(rest) {
    let { data, watch } = rest
    this.$data = typeof data === 'function' ? data() : data
    //Initialize responsive data
    this.initData(this.$data)
    for (const key in this.$data) {
      Object.defineProperty(this, key, {
        configurable: true,
        enumerable: true,
        get() {
          return this.$data[key]
        },
        set(value) {
          this.$data[key] = value
        }
      })
    }
    //Initialize all listeners
    this.initWatch(watch)

  }
  initData = () => {
    observe(this.$data)
  }

  initWatch(watch) {
    for (const key in watch) {
      this.$watch(key, watch[key])
    }
  }
  //Responsive data is referenced in the listener
  $watch(key, cb) {
    new Watcher(this, key, cb)
  }
}
// Dependent collection container
class Dep {
  constructor() {
    this.deps = []
  }
  // collect
  append() {
    if (Dep.target) {
      this.deps.push(Dep.target)
    }
  }
  // trigger
  update(newValue) {
    let subs = this.deps.slice()
    subs.forEach(watch => {
      watch.run(newValue)
    })

  }
}

// dependency
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm
    this.cb = cb
    this.key = key
    this.get()
  }
  get() {
    Dep.target = this
    this.vm[this.key]
    Dep.target = undefined
  }
  run(newValue) {
    // Pass the values before and after the change to the corresponding callback function.
    this.cb.call(this.vm, this.vm[this.key], newValue)
  }
}
// Recursive implementation
export function observe(data) {
  if (typeof data !== 'object') { return }
  newObserver(data)
}

class Observer {
  constructor(data) {
    if (Array.isArray(data)) {
      //Arrays need to be processed separately
    } else {
      this.walk(data)
    }
  }
  walk(data) {
    for (const key in data) {
      if (typeof data[key] === 'object') {
        observe(data[key])
      }
      reactive(data, key, data[key])
    }
  }
}

function reactive(object, key, val) {
  let dep = new Dep()
  Object.defineProperty(object, key, {
    configurable: true,
    enumerable: true,
    get() {
      // Dependency collection place
      dep.append()
      return val
    },
    set(value) {
      if (val !== value) {
        // Dependent triggering
        dep.update(value)
        val = value
      }
    }
  })
}

// Test Data
let vm = new Vue({
  data() {
    return {
      name: 'zs',
      age: 23,
      sex: 'nan',
      hobby: [1, [2], 3, 4]
    }
  },
  watch: {
    age() {
      console.log('age has changed, hahaha');
    },
    hobby() {
      console.log('hobby has changed, hobby', this.hobby);
    },
  }
})
vm.name = 'll' // name changed, hhh
console.log(vm, 'vv');
vm.$data.hobby.push()

2. Array change detection

2.1 How to implement data hijacking

If we want to hijack data for each element in the array, we can still achieve it recursively through the built-in API Object.defineProperty(). However, if the user declares an array with 100 elements, will it take up a lot of memory when recursively hijacking the array? Therefore, Vue does not use this method when hijacking the array.

The so-called hijacking of an array means that when elements in the array are accessed (or modified), the outside world can detect it.

When an element in the array is accessed, the variable name pointing to the array will definitely be notified, so we still implement the operation of hijacking the access of the array in get. So how to realize the function that when the elements in the array are modified, it can also be perceived by the outside world? Can we do this by hijacking instance methods that can change the elements in the array?

2.2 Array hijacking

As we know from the above, we need to hijack the 7 instance methods in the prototype object that can change the data in the array. It is necessary to realize the hijacking of the array and complete its corresponding functions.

const ArrayProto = Array.prototype
export const array = Object.create(ArrayProto)
// 7 methods that were hijacked
let methods = [ 'push', 'pop','shift', 'unshift','splice','sort', 'reverse']
// Hijack 7 methods
methods.forEach(method => {
  array[method] = function (...arg) {
    // The functions of the 7 instance methods in the original instance object also need to be implemented.
    let result = ArrayProto[method].apply(this, arg)
    
    // For newly added data, responsive processing is required
    let data
    switch (method) {
      case 'push':
      case 'unshift':
        data = arg
        break
      case 'splice':
        data = arg[2]
    }
    data & amp; & amp; data.forEach(v => {
      observe(v)
    })
    return result
  }
})

The following figure is a detailed display of the code logic:

9e9970c50d71ae3b5a97a9752f156a9e.png

2.3 Where to collect and when to notify of updates

For the Object type, when the key of the Object is accessed, the dependency will be collected; when the value corresponding to the key of the Object changes, the update will be notified. So we collect dependencies and notify updates in the get() and set() functions respectively.

The same is true for arrays. Dependencies are collected in get() and updated when the elements in the array change. Notifying updates when an element changes is very easy to understand. Why is the dependency collection still in get()? Let’s give an example:

{
    list: [1, 2, 3, 4, 5, 6]
}

For the above array list, if we want to get any value in the array, we must go through the key of list. right? Therefore, when we get the elements in the array list, the corresponding get() will definitely be triggered. If the array uses the seven instance methods we rewritten, then notification updates need to be performed in those seven instance methods.

function reactive(object, key, val) {
  let dep = new Dep()
  Object.defineProperty(object, key, {
    configurable: true,
    enumerable: true,
    get() {
      dep.append()
      //Array dependency collection place
      return val
    },
    set(value) {
      if (val !== value) {
        dep.update(value)
        val = value
      }
    }
  })
}
methods.forEach(method => {
  arrayProto[method] = function (...arg) {
    //The original method of the array must also be mapped
    let result = ArrayProto[method].apply(this, arg)
    let data
    switch (method) {
      case 'push':
      case 'unshift':
        data = arg
        break
      case 'splice':
        data = arg[2]
    }
    data & amp; & amp; data.forEach(v => {
      observe(v)
    })
    //When the array elements change, notify the corresponding dependencies to update
    
  }
})

2.4 Collection and triggering of dependencies

2.4.1 Where are the collected dependencies stored

As far as Object is concerned, the dependency collection center is placed in a dep. Can the dependencies of array collection also be placed in a dep?

function reactive(object, key, val) {
  // The dependency collection center we maintain, for Object.
  let dep = new Dep()
  Object.defineProperty(object, key, {
    configurable: true,
    enumerable: true,
    get() {
      // collect
      dep.append()
      return val
    },
    set(value) {
      if (val !== value) {
        //Notification update
        dep.update(value)
        val = value
      }
    }
  })
}

Since Object’s dependency collection and notification updates are in the same scope, taking advantage of the fact that the data in the function closure will not be garbage collected after the function is finished running, we use the get() function and set () The function Parent scope maintains a dependency center (dep). This way dep can reside in memory.

After the analysis in the previous section, the dependencies of the array are collected in the get() function, but the notification update of the array is performed in the interceptor. So can we imitate the way Object collects dependencies and notifies updates, and maintain a dependency center in its parent scope? This location is exactly in the Observer class.

import { arrayProto } from './array.js'
class Observer {
  constructor(data) {
    // Is it more appropriate to place the dependency center of the array here?
    this.dep = new Dep()
    if (Array.isArray(data)) {
      //Reason 1
      // If it is an array, the prototype object we wrote will overwrite the original prototype object of the array.
      // The prototype object we wrote needs to access the dependency center.
      data.__proto__ = arrayProto
    } else {
      this.walk(data)
    }
  }
  walk(data) {
    // Omit...
    //Reason 2
    // Hijack the key of Object. In this function, you need to access the dependency center.
    reactive(data, key, data[key])
    // Omit...
  }
}

2.4.2 Collect dependencies

From the above analysis, we can know that for arrays, dependency collection should be performed in the get() function. Triggering dependency updates is done in the interceptor method.

Here we need to think about a few questions first:

    1. If the data has already been hijacked, does it need to be hijacked a second time? Where is the most appropriate place to write this function?

    2. How do you know that the data has been hijacked?

answer:

    1. Definitely not needed. We can write the logic to determine whether the target data has been hijacked in the observe() function. Please think about why? Because the observe() function is the first function called when implementing data hijacking recursively. In other words: If you want a certain data to be hijacked, you must call the observe() function. For example, the following code.

methods.forEach(method => {
  arrayProto[method] = function (...arg) {
    // . . . . . . Omit some code
    data & amp; & amp; data.forEach(v => {
      // Recursively hijack newly added elements to the array.
      observe(v)
    })
  }
})
    1. We can add a unique identifier to the hijacked data. Please think about where to add this unique identifier to the data? Should be the Observer class. Because if the Observer class can be executed, then data must be a complex data type. We can first give data a unique identifier, and then traverse the complex data type data. During the traversal process, if the value is a complex data type, the data in the value part will be hijacked recursively. If it is a simple data type, the reactive() function will be entered to perform the hijacking operation!

//Add __ob__ attribute to each responsive data
function def(obj, key, value, enumerable) {
  Object.defineProperty(obj, key, {
    value: value,
    enumerable: enumerable || false,
    writable: true,
    configurable: true
  })
}
// Recursive start function
export function observe(data) {
  // If it is not a complex data type, return directly
  if (typeof data !== 'object') { return }

  // Whether the unique identifier __ob__ specified by us exists in the target data attribute.
  if (Object.hasOwn(data, '__ob__') & amp; & amp; data['__ob__'] instanceof Observer) {
    // If it exists, no secondary monitoring will be performed.
    return
  } else {
    // If it doesn't exist. This indicates that the data has not been hijacked yet.
    newObserver(data)
  }
}
class Observer {
  constructor(data) {
    this.dep=new Dep()
    // Uniquely identifies
    def(data, '__ob__', this)
    if (Array.isArray(data)) {
      data.__proto__ = arrayProto
    }
  }
  walk(data) {
    for (const key in data) {
      // If the value is a complex type, the value part needs to be hijacked recursively.
      if (typeof data[key] === 'object') {
        observe(data[key])
      }else{
        reactive(data, key, data[key])
      }
    }
  }
}
function reactive(object, key, val) {
  // . . . . . . Omit
  //Perform hijacking operation
}

2.4.3 Access the dependency center in the interceptor

After the above analysis, it is not difficult for us to find that: in the Observer class, we add a new attribute of __ob__ for all current complex data types, and its value is the current Observer The current instance object. However, is there a dependency collection center in theObserver instance object? Is this dependency collection center set up to collect array dependencies?

Because we have injected a new attribute into each complex data type that can access the array dependency center, in the interceptor, as long as we get the __ob__ attribute of the array instance object, we can get the array’s Dependence center.

Then the question comes again: Do we need to write a method to get the __ob__ attribute in the array? Can I reuse existing functions?

The answer is: absolutely. Looking at all the functions we have encapsulated, it is not difficult to find that the ones that reference more __ob__ attributes include the observe() function and

Observer class. We added the __ob__ attribute to the complex data type in the Observer class, so we considered changing observe() twice.

function. In the interceptor, get the object pointed to by the __ob__ attribute of the array.

// Transformation
export function observe(data) {
  if (typeof data !== 'object') {
    return
  }

  let ob
  if (Object.hasOwn(data, "__ob__") & amp; & amp; data instanceof Observer) {
    // If attribute __ob__ exists, return its value.
    ob = data['__ob__']
  } else {
    // Of course we also need to consider compatibility with the previous logic. If the complex type has no attribute __ob__,
    // This proves that the complex type of data has not yet undergone responsive hijacking operations and needs to enter the next
    // link, hijack complex data types.
    ob = new Observer(data)
  }
  return ob
}

class Observer {
  constructor(data) {
    // data must be a complex data type
    //Dependency center
    this.dep = new Dep()
    // Manually add the __ob__ attribute for complex data types.
    // The instance object of the array already has a dependency center
    def(data, '__ob__', this)

    if (Array.isArray(data)) {
      // Overwrite the prototype object that comes with the array with the overwritten prototype object
      data.__proto__ = arrayProto
    }
    // Omit ......
  }
}
// Omit...
}

// We add the __ob__ attribute to each responsive data,
export function def(obj, key, value, enumerable) {
  Object.defineProperty(obj, key, {
    value: value,
    enumerable: enumerable || false,
    writable: true,
    configurable: true
  })
}

We manually added an __ob__ attribute to the data of each complex data type through the def() function. The value of __ob__ is the current Observer instance object. This instance object There is a dependency center in dep. Added an __ob__ from the instance attribute of this array.

Among the 7 rewritten instance methods, we can get the __ob__ attribute in the array through this to get the dependency center of the array, so as to notify!

methods.forEach(method => {
  arrayProto[method] = function (...arg) {
    //The original method of the array must also be mapped
    let result = ArrayProto[method].apply(this, arg)
    let data
    switch (method) {
      case 'push':
      case 'unshift':
        data = arg
        break
      case 'splice':
        data = arg[2]
    }
    data & amp; & amp; data.forEach(v => {
      observe(v)
    })
    // We hang the dependency center of the array in the array instance attribute, and the dependency center can be obtained through this.
    this.__ob__.dep.update(...arg)
    return result
  }
})

2.5 Detecting changes in elements in the array

If the current element is of array type, in addition to changing its prototype object, we can perform in-depth detection by traversing the elements in the array.

class Observer {
  constructor(data) {
    this.dep = new Dep()
    def(data, '__ob__', this)
    if (Array.isArray(data)) {
      //Change the prototype object of the array
      data.__proto__ = arrayProto
      // If it's an array, iterate over it.
      this.addressArray(data)
    } else {
      this.walk(data)
    }
  }

  addressArray(data) {
    for (const v of data) {
      // Perform deep recursion on the values in the array
      observe(v)
    }
  }
  // Omit...
}

2.6 Detecting changes in new elements

For newly added elements to the array, it is very necessary for us to monitor them.

methods.forEach(method => {
  arrayProto[method] = function (...arg) {
    //The original method of the array must also be mapped
    let result = ArrayProto[method].apply(this, arg)
    let data
    
    switch (method) {
      case 'push':
      case 'unshift':
        data = arg
        break
      case 'splice':
        data = arg[2]
    }
    // data & amp; & amp; data.forEach(v => {
    // observe(v)
    // })
    //Modify detection method
    this.__ob__.addressArray(arg)
    // Trigger dependency update
    this.__ob__.dep.update(...arg)
    return result
  }
})

The knowledge points of the article match the official knowledge files, and you can further learn relevant knowledge. Vue entry skill treeHomepageOverview 39489 people are learning the system