Vue nextTick 实现原理

前言

由于 Vue 更新 DOM 是异步执行的,如果在数据修改后面直接做某些操作,会被立即执行,而此时DOM还未更新,Vue 提供了 nextTick,在数据修改后立即调用,可以确保传入的回调函数会在更新完成之后执行。

接下来跟着两个问题来探索 nextTick 的原理

  • 调用 nextTick 会发生什么呢?
  • 它是如何检测到DOM更新完成的呢?

nextTick

全局API Vue.nextTick 和实例方法 vm.$nextTick 内部实际上都是在调用 nextTick 函数。nextTick逻辑很简单,其作用就是将传进去的回调函数推入 callbacks 队列,(如果没有回调且支持Promise,则传入一个resolve)然后判断 pending 为 false 则调用 timerFunc,到此就结束了。

const callbacks = []
let pending = false

export function nextTick (cb, ctx) {
  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()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
 

可以看出,timerFunc 就是决定何时执行回调函数的关键。pending 表示当前是否有异步任务正在执行,如果没有则立即调用 timerFunc 并修改 pendingtrue,防止重复调用,导致进程阻塞。

调用 timerFunc 会创建一个异步任务,等到这个异步任务结束了,就会去执行callbacks 队列中的函数,并且 pending 置为 false 等待下一个 nextTick 被调用,当 timerFunc 还没结束时,重复调用nextTick只会触发一次执行。

Vue 如何实现异步任务

看到这里,已经迫不及待想要知道 timerFunc 如何实现的,还有何时去执行回调函数的了。别急,这里还要先了解一点前置知识,就是浏览器的 Event Loop

Event Loop 事件循环

我们知道 js 是单线程的,同步任务会被顺序执行,还知道有事件监听器的回调、setTimeout、Promise 等等的异步任务不会被立即执行。那么,js 引擎是如何决定这些异步任务在何时被调用的呢?

消息队列

Javascript 运行时包含一个消息队列,用于管理待处理的消息,当一个绑定了事件监听器的事件被触发、或者添加了一个 setTimeout 的回调函数,就会添加一个消息入列,等待被处理。

任务队列

每个消息都有一个与之关联的回调函数,放在任务队列中,每次都会从消息队列的队头开始处理消息,并执行与之关联的回调函数,直到回调函数被执行完成。

任务与微任务

上面说到,一个(宏)任务与消息相关联,消息被处理时,对应的任务会被执行,如事件触发的回调、使用 setTimeout 添加的任务。

微任务则是独立存放在微任务队列中,当一个(宏)任务开始执行,新添加的微任务会被添加到微任务队列,等到任务执行完成后要进行下一轮迭代之前,会依次执行微任务直至微任务队列为空。如果不断添加微任务,就会继续处理直到微任务队列为空,因此,要防止重复添加微任务导致阻塞进程。

(宏)任务:事件监听器触发的回调、setTimeout、setInterval
微任务:Promise、queueMicrotask、MutationObserver

Event Loop 小结

结合上面三个概念,可以总结出这几个步骤

  1. 当一个消息被添加到消息队列,其对应的任务也会被添加到任务队列,js 引擎空闲时取出一个消息开始处理,后面的消息需要等待前面消息执行至完成才会被处理
  2. 当消息被处理,其对应的任务也会从任务队列被取出,在此期间新添加的微任务,如创建一个 Promise,就会被添加到微任务队列
  3. 等到任务执行至完成,会依次执行微任务直到微任务队列为空
  4. 开始处理下一个消息,重复步骤123,这就是事件循环

timerFunc 原理

了解了事件循环是怎么回事之后,就来看看 timerFunc 的原理,以及执行nextTick回调函数的时机。

从源码中可以看到 Vue 在创建异步任务做了很多兼容处理,依次尝试使用Promise,MutationObserver,setImmediate,setTimeout 来创建异步任务

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // ...
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // ...
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // ...
} else {
  // ...
}
 

Promise.then

首先判断是否原生支持 Promise,原生 Promise.then 是一个微任务,优先级比(宏)任务高,调用 timerFunc 会添加一个微任务,等到DOM更新完成,下一次事件循环开始之前 flushCallbacks 会被执行

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // iOS 中奇怪的bug,微任务入列了却没有刷新,直到浏览器需要处理其他一些工作,如定时器,这里用来强制刷新队列
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
}
 

MutationObserver

MutationObserver 接收一个回调函数,使用 new 关键字创建并返回一个实例对象,会在指定的DOM发生变化时被调用。

MutationObserver 虽然是指定 DOM 发生修改时会被触发,但只是添加一个微任务,并不会立即执行,而是等到所有 DOM 更新完毕才会被执行,因此,Vue 在这个地方只需要创建一个结点去修改它的内容,就可以实现监听了。

这里很巧妙的利用 counter = (counter + 1) % 2 让 counter 在 0/1 之间变化,调用 timerFunc 就会修改 textNode 的内容,等待监听到 textNode 变化就会添加一个微任务,DOM 更新完成之后就调用 flushCallbacks

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
 

setImmediate

该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数。

只有 IE10+ 支持,是一个(宏)任务

timerFunc = () => {
  setImmediate(flushCallbacks)
}
 

setTimeout

备选选项,以上都不支持的情况下,使用 setTimeout 创建异步任务,是个(宏)任务

timerFunc = () => {
  setTimeout(flushCallbacks, 0)
}
 

flushCallbacks

如上面所见,Vue 依次尝试使用 Promise.then, MutationObserver, setImmediate, setTimeout 来创建一个异步任务,flushCallbacks 会在DOM更新完成后被执行。

flushCallbacks 首先将 pending 修改为 false,等待下一次调用 timerFunc,然后遍历回调函数队列,依次取出回调函数执行。

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

小结

由于Vue在更新DOM是异步执行的,因此nextTick需要维护一个回调函数队列,等待合适的时机才执行回调函数,这个时机则是利用了事件循环机制,Vue进行异步更新时,新添加一个异步任务,会等到Vue更新完成后被处理,此时回调函数会被依次执行。

原创文章,作者:我心飞翔,如若转载,请注明出处:https://www.pipipi.net/14683.html

发表评论

登录后才能评论