Implementation principle of nextTick

When developing with vue.js, if we want to manipulate the correct DOM based on data state, we must have dealt with the nextTick() method, which is one of the core methods in vue.js. In this section, we introduce how nextTick is implemented in vue.js.

Asynchronous knowledge

Because nextTick involves a lot of asynchrony, we’ll introduce asynchrony to make it easier to learn.

Event Loop

We all know that JavaScript is single-threaded and is executed based on an Event Loop that follows certain rules: All synchronous tasks are executed in the main thread, forming an execution stack. All asynchronous tasks are temporarily put into a task queue. When all synchronous tasks are completed, the task queue is read and put into the execution stack to start execution. The above is a single execution mechanism. The main thread repeats this process over and over again to form an Event Loop.

The above is a general introduction to Event Loop, but there are still some details we need to master when executing Event Loop.

We mentioned tick in the update section, so what is tick? The tick is a single execution of the main thread. All asynchronous tasks are scheduled by task queue, which stores tasks. According to the specification, these tasks are divided into Macro task and micro task. There is a subtle relationship between macro tasks and Micro Tasks: After each Macro task is executed, all micro Tasks are cleared.

Macro Task and Micro Task correspond as follows in the browser environment:

  • macro taskMacro task:MessageChannel,postMessage,setImmediateandsetTimeout.
  • micro taskMicro tasks:Promise.thenandMutationObsever.

MutationObserver

It creates and returns a new instance of MutationObserver, which will be called whenever the specified DOM changes.

Let’s write an example according to the documentation:

const callback = () = > {
  console.log('text node data change')}const observer = new MutationObserver(callback)
let count = 1
const textNode = document.createTextNode(count)
observer.observe(textNode, {
  characterData: true
})

function func () {
  count++
  textNode.data = count
}
func() // text node data change
Copy the code

Code analysis:

  • First of all, we definecallbackThe callback function andMutationObserverObject, where the constructor passes arguments that are ourscallback.
  • It then creates a text node and passes in the initial text of the text node, followed by the callMutationObserverThe instanceobserveMethod, passing in the text node we created and aconfigObserve the configuration object, wherecharacterData:trueWe have to observetextNodeThe text of the node changes.configThere are other option properties that you can use in theMDNYou can view it in the document.
  • And then, let’s define onefuncFunction, the main thing that this function does is modifytextNodeThe text content in the text node, when the text content changes,callbackIs automatically called, so the outputtext node data change.

Now that we know how to use MutationObserver, let’s take a look at how the nextTick method uses MutationObserver:

import { isIE, isNative } from './env'

// omit the code
else if(! isIE &&typeofMutationObserver ! = ='undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () = > {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
}
Copy the code

As you can see, nextTick determines that a non-INTERNET Explorer browser is used only when MutationObserver is available and is a native MutationObserver. For judging the non-ie situation, you can see issue#6466 (Labour of new window) in vue.js to see why.

SetImmediate and setTimeout

SetTimeout is a very common timer method for most people, so we won’t cover it too much.

In the nextTick method implementation, it uses setImmediate, which Can be seen on the Can I Use (New Window) website. This API method is only available in advanced Internet Explorer and low Edge, but not in other browsers.

Then why is this method used? It is because of the issue we mentioned before: MutationObserver is not reliable in Internet Explorer, so in Internet Explorer you level down to using setImmediate, which we can think of as similar to setTimeout.

setImmediate(() = > {
  console.log('setImmediate')},0)
/ / is approximately equal to
setTimeout(() = > {
  console.log('setTimeout')},0)
Copy the code

NextTick implementation

After introducing the knowledge related to nextTick and asynchrony, let’s analyze the implementation of nextTick method. The first thing to say is: asynchronous degradation.

let timerFunc

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise! = ='undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () = > {
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if(! isIE &&typeofMutationObserver ! = ='undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () = > {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeofsetImmediate ! = ='undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () = > {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () = > {
    setTimeout(flushCallbacks, 0)}}Copy the code

We introduced the Event Loop in the previous section. Due to the special execution mechanism of Macro Task and Micro Task, we first determine whether the current browser supports promises. If not, we then demoted to determine whether MutationObserver is supported. It continues to demote to determining whether or not setImmediate is supported, and finally to using setTimeout.

After introducing asynchronous degradation, let’s look at the nextTick implementation code:

const callbacks = []
let pending = false
export function nextTick (cb? :Function, ctx? :Object) {
  let _resolve
  callbacks.push(() = > {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')}}else if (_resolve) {
      _resolve(ctx)
    }
  })
  if(! pending) { pending =true
    timerFunc()
  }
  // $flow-disable-line
  if(! cb &&typeof Promise! = ='undefined') {
    return new Promise(resolve= > {
      _resolve = resolve
    })
  }
}
Copy the code

The real code for nextTick is not complicated. It collects incoming CB’s and then executes the timerFunc method when pending is false, where timeFunc is defined during asynchronous demotion. The nextTick method also makes a final judgment that if no CB is passed in and a Promise is supported, it will return a Promise, so we can use nextTick in two ways:

const callback = () = > {
  console.log('nextTick callback')}/ / way
this.$nextTick(callback)

2 / / way
this.$nextTick().then(() = > {
  callback()
})
Copy the code

Finally, we’ll look at an implementation of the flushCallbacks method that wasn’t mentioned before:

const callbacks = []
let pending = false
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
Copy the code

The flushCallbacks method returns the pending state to false and executes the methods in the callbacks array.

Note for change detection

Although the Object.defineProperty() method works well, there are exceptions where changes to these exceptions do not trigger setters. In this case, we classify objects and arrays.

object

Suppose we have the following example:

export default {
  data () {
    return {
      obj: {
        a: 'a'
      }
    }
  },
  created () {
    // 1. Add attribute b, attribute B is not reactive, does not trigger setter for obj
    this.obj.b = 'b'
    // 2.delete delete existing property, cannot trigger setter for obj
    delete this.obj.a
  }
}
Copy the code

From the above examples, we can see:

  • When a new property is added to a responsive object, the new property is not reactive and cannot be triggered by any subsequent changes to the new propertysetter. To solve this problem,Vue.jsProvides a globalVue.set()Methods and Examplesvm.$set()Method, they’re all really the samesetMethod, which we will cover globally in relation to responsiveness in a later sectionAPIThe implementation of the.
  • This is not triggered when a reactive object deletes an existing propertysetter. To solve this problem,Vue.jsProvides a globalvue.delete()Methods and Examplesvm.$delete()Method, they’re all really the samedelMethod, which we will cover globally in relation to responsiveness in a later sectionAPIThe implementation of the.

An array of

Suppose we have the following example:

export default {
  data () {
    return {
      arr: [1.2.3]
    }
  },
  created () {
    // 1. Cannot capture array changes through index changes.
    this.arr[0] = 11
    // 2. Cannot capture array changes by changing the array length.
    this.arr.length = 0}}Copy the code

From the above examples, we can see:

  • Modifying an array directly through an index does not capture changes to the array.
  • Changes to the array cannot be caught by changing the array length.

For the first case, we can use the aforementioned vue.set or vm.$set, and for the second, we can use the array splice() method.

In the latest version of Vue3.0, Proxy is used to replace Object.defineProperty() to achieve responsiveness. All the above problems can be solved after Proxy is used. However, Proxy belongs to ES6, so it has certain requirements for browser compatibility.

Change detection API implementation

In the previous section, we looked at some of the problems with change detection. In this section, we’ll look at how vue.js implements the API to solve these problems.

Vue. Set to achieve

Vue. Set and vm.$set refer to a set method defined in observer/index.js:

export function set (target: Array<any> | Object, key: any, val: any) :any {
  if(process.env.NODE_ENV ! = ='production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)}if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key intarget && ! (keyin Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if(target._isVue || (ob && ob.vmCount)) { process.env.NODE_ENV ! = ='production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if(! ob) { target[key] = valreturn val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}
Copy the code

Before analyzing the code, let’s review the use of vue. set or vm.$set:

export default {
  data () {
    return {
      obj: {
        a: 'a'
      },
      arr: []
    }
  },
  created () {
    // Add a new attribute to the object
    this.$set(this.obj, 'b'.'b')
    console.log(this.obj.b) // b

    // Add a new element to the array
    this.$set(this.arr, 0.'AAA')
    console.log(this.arr[0]) // AAA

    // Modify array elements by index
    this.$set(this.arr, 0.'BBB')
    console.log(this.arr[0]) // BBB}}Copy the code

Code analysis:

  • setMethod first on the incomingtargetParameters are verified, whereisUndefDetermine whetherundefined.isPrimitiveDetermine whetherJavaScriptRaw value, an error message is displayed in the development environment if one of the conditions is met.
export default {
  created () {
    // Error message
    this.$set(undefined.'a'.'a')
    this.$set(1.'a'.'a')
    this.$set('1'.'a'.'a')
    this.$set(true.'a'.'a')}}Copy the code
  • Then through theArray.isArray()Method to determinetargetWhether it is an array, and if so, pass againisValidArrayIndexCheck if it is a valid array index. If it is, variation is usedspliceThe setValue () method sets a value at the specified location in the array. It also resets the array’slengthProperty because the index we pass in May be better than the existing array’slengthEven larger.
  • It then determines whether it is an object and is currentlykeyIs it already on this object? If it already exists, then we just need to copy it again.
  • Finally, throughdefineReactiveMethod adds a property to the reactive object,defineReactiveMethods have been introduced before and will not be covered here. indefineReactiveAfter the execution is completed, an update is immediately distributed to inform the dependency of responsive data to be updated immediately. The following two pieces of code aresetCore of method core:
defineReactive(ob.value, key, val)
ob.dep.notify()
Copy the code

Vue. Delete

Delete and vm.$delete use the same delete method as defined in the observer/index.js file: Vue.

export function del (target: Array<any> | Object, key: any) {
  if(process.env.NODE_ENV ! = ='production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)}if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if(target._isVue || (ob && ob.vmCount)) { process.env.NODE_ENV ! = ='production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  if(! hasOwn(target, key)) {return
  }
  delete target[key]
  if(! ob) {return
  }
  ob.dep.notify()
}
Copy the code

Before analyzing the code, let’s review the following use of vue. delete or vm.$delete:

export default {
  data () {
    return {
      obj: {
        a: 'a'
      },
      arr: [1.2.3]
    }
  },
  created () {
    // Delete object properties
    this.$delete(this.obj, 'a')
    console.log(this.obj.a) // undefined
    // Delete an array element
    this.$delete(this.arr, 1)
    console.log(this.arr)   / / [1, 3]}}Copy the code

Code analysis:

  • The object to be deleted is determined firsttargetCan’t forundefinedOr a raw value, if so, an error is displayed in the development environment.
export default {
  created () {
    // Error message
    this.$delete(undefined.'a')
    this.$delete(1.'a')
    this.$delete('1'.'a')
    this.$delete(true.'a')}}Copy the code
  • Then through theArray.isArray()Method to determinetargetWhether it is an array, and if so, pass againisValidArrayIndexCheck if it is a valid array index. If it is, variation is usedspliceMethod to remove the element at the specified location.
  • Then determine whether the current attribute to be deleted is intargetObject, if it’s not there, it just returns, doing nothing.
  • Finally, throughdeleteThe operator deletes an attribute on the object, and thenob.dep.notify()Notifies dependencies on responsive objects to be updated.

Vue. Observables

Vue.observable is a global method available in Vue2.6+ that makes an object responsive:

const obj = {
  a: 1.b: 2
}
const observeObj = Vue.observable(obj)
console.log(observeObj.a) / / triggers the getter

observeObj.b = 22 / / triggers the setter
Copy the code

This global method is defined in the initGlobalAPI, which we’ve already covered, not covered here:

export default function initGlobalAPI (Vue) {
  // ...
  // 2.6 explicit observable API
  Vue.observable = <T>(obj: T): T= > {
    observe(obj)
    return obj
  }
  // ...
}
Copy the code

Observable implementation is very simple, just calling the observe method inside the method and returning the obj. The code implementation of Observe has been covered in the previous section, so there is no further explanation here:

export function observe (value: any, asRootData: ? boolean) :Observer | void {
  if(! isObject(value) || valueinstanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if( shouldObserve && ! isServerRendering() && (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) && ! value._isVue ) { ob =new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
Copy the code