preface

This article starts from a simple two-way binding, and gradually upgrades to a responsive system implemented by defineProperty and Proxy respectively. It pays attention to the ideas and key details, hoping to be helpful to you.

1. Minimalist bidirectional binding

Let’s start with the simplest two-way binding:

// html
<input type="text" id="input">
<span id="span"></span>

// js
let input = document.getElementById('input')
let span = document.getElementById('span')
input.addEventListener('keyup'.function(e) {
  span.innerHTML = e.target.value
})
Copy the code

This seems to work fine, but we want to be data-driven, not directly manipulating the DOM:

// Manipulate obj data to drive updates
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
Object.defineProperty(obj, 'text', {
  configurable: true.enumerable: true,
  get() {
    console.log('Got the data')
  },
  set(newVal) {
    console.log('Data updated')
    input.value = newVal
    span.innerHTML = newVal
  }
})
input.addEventListener('keyup'.function(e) {
  obj.text = e.target.value
})
Copy the code

This is a simple two-way data binding, but it’s obviously not enough, so let’s move on.

DefineProperty is used to implement the response system

The data response implemented by defineProperty before Vue3 came, based on published-subscribe model, consists of three main parts: Observer, Dep and Watcher.

1

// Data that needs to be hijacked
let data = {
  a: 1.b: {
    c: 3}}// Hijack data
observer(data)

// Listen on the properties of the subscription data data
new Watch('a', () => {
    alert(1)})new Watch('a', () => {
    alert(2)})new Watch('b.c', () => {
    alert(3)})Copy the code

The above is a simple hijacking and listening process, how to implement the corresponding Observer and Watch?

2. Observer

An observer is used to hijack data and convert data attributes to accessor attributes.

  • 1.ObserverTo convert data to reactive, it should be a function (class) that accepts arguments.
  • ② In order to convert the data into a responsive form, that needs to be usedObject.defineProperty.
  • ③ There is more than one type of data, which requires recursive traversal judgment.
// Define a class for passing in listener data
class Observer {
  constructor(data) {
    let keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}
/ / use Object. DefineProperty
function defineReactive (data, key, val) {
  // Verify that the value is an object each time you set the accessor, implementing recursion for each attribute
  observer(val)
  // Hijacking data attributes
  Object.defineProperty(data, key, {
    configurable: true.enumerable: true,
    get () {
      return val
    },
    set (newVal) {
      if (newVal === val) {
        return
      } else {
        data[key] = newVal
        // New values are also hijacked
        observer(newVal)
      }
    }
  })
}

// Recursive judgment
function observer (data) {
  if (Object.prototype.toString.call(data) === '[object, Object]') {
    new Observer(data)
  } else {
    return}}/ / to monitor obj
observer(data)

Copy the code

3. Watcher

New Watch(‘a’, () => {alert(1)})

class Watch {
  // The first argument is an expression and the second argument is a callback function
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
  }
}
Copy the code

How does the Watch relate to the observer? Think of any points that are related to each other? It seems like a good place to start is with e to the point that they all share:

class Watch {
  // The first argument is an expression and the second argument is a callback function
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
    data[exp]   // What is the effect of this sentence}}Copy the code

The statement data[exp] indicates that a value is being fetched. If exp is a, it indicates that data.a, the property under data has been hijacked as an accessor property, which means that we can trigger the corresponding property get function. Can we collect the trigger Watch when triggering the get function? That’s where a bridge Dep comes in.

4. Dep

Each attribute under data has a unique Dep object, collect dependencies only for that attribute in GET, and then trigger all collected dependencies in set, and you’re done.

class Dep {
  constructor () {
    // Define a container to collect the corresponding property dependencies
    this.subs = []
  }
  // Collect dependency methods
  addSub () {
    // dep. target is a global variable used to store the current watcher
    this.subs.push(Dep.target)
  }
  // The dependency is notified when the set method is triggered
  notify () {
    for (let i = 1; i < this.subs.length; i++) {
      this.subs[i].cb()
    }
  }
}

Dep.target = null

class Watch {
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
    // Assign the Watch instance to the global variable dep. target so that get can get it
    Dep.target = this
    data[exp]
  }
}

Copy the code

We also need to add some code to defineReactive:

function defineReactive (data, key, val) {
  observer()
  let dep = new Dep() // New: so that each attribute can correspond to a Dep instance
  Object.defineProperty(data, key, {
    configurable: true.enumerable: true,
    get () {
      dep.addSub() // New: addSub is triggered when get is triggered to collect the current dep. target, i.e. Watcher
      return val
    },
    set (newVal) {
      if (newVal === val) {
        return
      } else {
        data[key] = newVal
        observer(newVal)
        dep.notify() // Add a dependency for notification}}})}Copy the code

So far observer, Dep and Watch have formed a whole with a clear division of labor. However, there are some areas that need to be handled, such as adding new attributes to a hijacked object directly, as well as modifying the element values of an array. Here, by the way, is how the Vue source code solves this problem:

For objects: Vue provides two methods for adding new attributes, vue. set and vm.$set. The principle is to determine whether the attribute is reactive and, if not, to make it reactive via defineReactive.

For arrays: using subscripts to modify values is still ineffective. Vue hacks only seven methods in arrays: pop’,’push’,’shift’,’unshift’,’splice’,’sort’, and ‘reverse’, so that we can still use them in a responsive manner. The idea is that when we call the seven methods of the array, Vue will modify the methods. It will also execute the logic of the methods internally, but with some additional logic: take the increased value, make it reactive, and then manually start dep.notify()

Third, to implement the response system by Proxy

Proxy puts a layer of “blocking” in front of a target. Any access to the Object must pass through this layer, so it provides a mechanism to filter and rewrite access to the Object. Think of Proxy as a fully enhanced version of Object.defineProperty.

There are still three major components: Observer, Dep and Watch. We will improve these three components on the previous basis.

1. Dep

let uid = 0 // New: define an ID
class Dep {
  constructor () {
    this.id = uid++ // Add: add id to dep to avoid repeated subscription of Watch
    this.subs = []
  }
  depend() {  // Add: in the source code, the get method is triggered first and then depend collection, so that deP can be passed to Watch
    Dep.target.addDep(this);
  }
  addSub () {
    this.subs.push(Dep.target)
  }
  notify () {
    for (let i = 1; i < this.subs.length; i++) {
      this.subs[i].cb()
    }
  }
}
Copy the code

2. Watch

class Watch {
  constructor (exp, cb) {
    this.depIds = {} // New: Store subscriber id to avoid duplicate subscription
    this.exp = exp
    this.cb = cb
    Dep.target = this
    data[exp]
    // New: check whether the DEP is subscribed, if not, store the ID and call dep.addSub to collect the current watcher
    addDep (dep) {  
      if (!this.depIds.hasOwnProperty(dep.id)) {
        dep.addSub(this)
        this.depIds[dep.id] = dep
      }
    }
    // Add: put the subscriber into the queue for batch update
    update () {
      pushQueue(this)}// New: triggers the actual update operation
    run () {
      this.cb()
    }
  }
}
Copy the code

3. Observer

Unlike Object.defineProperty, Proxy can listen (actually Proxy) on the entire Object, so there is no need to listen through the attributes of the Object. However, if the attributes of the Object are still an Object, Proxy cannot listen, so it can still use recursion.

function Observer (data) {
  let dep = new Dep()
  return new Proxy(data, {
    get () {
      // If the subscriber exists, go to the Depend method
      if (Dep.target) {
        dep.depend()
      }
      // Reflect
      return Reflect.get(data, key)
    },
    set (data, key, newVal) {
      // If the value does not change, it is returned directly without triggering subsequent operations
      if (Reflect.get(data, key) === newVal) {
        return
      } else {
        // Determine whether to recursively listen for the new value while setting the new value
        Reflect.set(target, key, observer(newVal))
        // Trigger the notification method of the Dep when the value is triggered to change
        dep.notify(key)
      }
    }
  })
}

// recursive listening
function observer (data) {
  // If it is not an object, return it directly
  if (Object.prototype.toString.call(data) ! = ='[object, Object]') {
    return data
  }
  // Determine the attribute value recursively when it is an object
  Object.keys(data).forEach(key= > {
    data[key] = observer(data[key])
  })
  return Observer(data)
}

/ / to monitor obj
Observer(data)
Copy the code

At this point, the three big things are basically done, and it can listen on arrays without hacks.

Trigger dependency collection and batch asynchronous update

Complete the reactive system, and also mention how the Vue source code triggers dependency collection and batch asynchronous updates.

1. Trigger dependency collection

When the $mount method is called in the Vue source code, this code is triggered indirectly:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)
Copy the code

This causes new Watcher() to evaluate its passed parameters first, which indirectly triggers vm._render(), which actually triggers the data access, which in turn triggers the property get method to achieve dependency collection.

2. Batch asynchronous update

Vue is executed asynchronously when updating the DOM. As long as it listens for data changes, Vue opens a queue and buffers all data changes that occur in the same event loop. If the same watcher is triggered more than once, it will only be pushed into the queue once. This removal of duplicate data while buffering is important to avoid unnecessary computation and DOM manipulation. Then, in the next event loop, “TICK,” Vue refreshes the queue and performs the actual (de-duplicated) work. Vue internally attempts to use native Promise.then, MutationObserver, and setImmediate for asynchronous queues, and setTimeout(fn, 0) instead if the execution environment does not support it.

This queue is mainly asynchronous and de-duplicated, according to the official document above.

  1. You need a queue to store and deduplicate changes to the data in an event loop.
  2. Adds data changes from the current event loop to the queue.
  3. Asynchronously execute all data changes in the queue.
// Use the Set data structure to create a queue, which can be automatically de-duplicated
let queue = new Set(a)// When the property fires the set method, watcher.update is fired, and the following method is executed
function pushQueue (watcher) {
  // Add data changes to the queue
  queue.add(watcher)
  // the nextTick performs the data change, so nextTick should receive a function that executes the queue
  nextTick('A function that iterates through the queue')}// Simulate nextTick with Promise
function nextTick('A function that iterates through the queue') {
  Promise.resolve().then('A function that iterates through the queue')}Copy the code

Now that we have a general idea, let’s finish ‘a function that iterates through the queue’ :

// Queue is an array, so just iterate through it
function flushQueue () {
  queue.forEach(watcher= > {
    // Trigger the run method in watcher to do the actual update
    watcher.run()
  })
  // Clear the queue after execution
  queue = new Set()}Copy the code

Another problem is that nextTick should only fire once in the same event loop, not every time a queue is added:

// Sets a flag to indicate whether nextTick is triggered
let waiting = false
function pushQueue (watcher) {
  queue.add(watcher)
  // After the following code is executed once, it will not be executed next time, ensuring that nextTick fires only once
  if(! waiting) {// Ensure that nextTick fires only once
    waiting = true
    nextTick('A function that iterates through the queue')}}Copy the code

The complete code is as follows:

// Define the queue
let queue = new Set(a)// The function passed into the execution queue in nextTick
function flushQueue () {
  queue.forEach(watcher= > {
    watcher.run()
  })
  queue = new Set()}// nextTick
function nextTick(flushQueue) {
  Promise.resolve().then(flushQueue)
}

// Add to the queue and call nextTick
let waiting = false
function pushQueue (watcher) {
  queue.add(watcher)
  if(! waiting) { waiting =true
    nextTick(flushQueue)
  }
}
Copy the code

The last

The above is a general principle of response, of course, there are a lot of details did not say, interested in you can go to the source, if you feel helpful, welcome to focus on a praise!

Related references:

  • Vue source code learning
  • How better is implementing a two-way binding Proxy than defineProperty?
  • Vue. Js source code comprehensive in-depth analysis