《Vue 源码系列》之 nextTick 源码

《Vue 源码系列》之 nextTick 源码

根据官网解释,nextTick 是下次 Dom 更新循环结束之后的回调,我们可以通过它拿到更新后的 Dom ,并做一些操作。

 次点击
23 分钟阅读

每天进步一点点,今天我们一起来看看 nextTick 的源码。

概念

根据官网解释,nextTick 是下次 DOM 更新循环结束之后的回调,我们可以通过它拿到更新后的 DOM ,并做一些操作。

nextTick 有2个,一个是全局的 Vue.nextTick(callback),一个是实例上的 this.$nextTick(callback),它们的作用都是一样的,只是作用的范围不一样而已。

参数

  • 对于全局的 nextTick ,有2个参数,回调函数上下文对象Vue.nextTick(callback, context)

  • 对于实例方法 $nextTick,只有1个回调函数this.$nextTick(callback)

两者的参数都是非必填的,在没有提供回调且在支持 Promise 的环境中,会返回一个 Promise

用法

使用方法很简单,无非如下 3 种:

1、一般用法

Vue.nextTick(()=>{
  // DOM 更新后的逻辑
})
this.$nextTick(()=>{
  // DOM 更新后的逻辑
})

2、结合 async/await ,更加简洁

async getData(){
  await this.$nextTick();
  // DOM 更新后的逻辑
}

3、作为 Promise 使用

this.$nextTick().then(()=>{
  // DOM 更新后的逻辑,不传回调函数,默认会返回一个 Promise
})

异步更新队列

在看源码之前,我们先来了解一下什么是 Vue 的异步更新队列

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中1次,最终只做1次Dom 更新,这极大的减少了性能开销。

在下一个的事件循环 tick 中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。

事件循环

另一个与 nextTick 关系密切的就是 JS 的 事件循环机制

我们都知道 JS 是单线程语言,一次只能做一件事。为了提升事件处理效率,加入了事件循环机制。

一个简单的事件循环流程图如下:

eventLoop

事件分类

  • 同步任务(一切同步的操作)

  • 异步任务

    • 宏任务(如 setTimeout、setInterval、setImmediate、I/O等)
    • 微任务(如 Promise.then、MutationObserver、process.nextTick等)

主线程会优先执行同步任务,当所有同步任务执行完毕后,才会从任务队列里取出异步任务执行。

这里只需要知道以下几点即可:

  • 每个宏任务都对应一个微任务队列,宏任务执行完后会立刻执行对应的微任务(当前循环);
  • 微任务执行完后,会做更新一次UI,然后继续下一轮循环;
  • 微任务的优先级很高,很适合做异步流程控制。

源码

首先,nextTick 源码位于 /src/core/util/next-tick.js ,整体代码很少,我们一起看一下。

初始变量

一开始定义了 如下 3 个变量:

  1. isUsingMicroTask :是否使用微任务
  2. callbacks :回调函数
  3. pending :是否正在处理
export let isUsingMicroTask = false
const callbacks = []
let pending = false

nextTick

接下来,我们先看 nextTick 方法的代码部分:

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

看起来很简单是不是?

  1. 首先定义一个 _resolve 的标识用来判断回调是否结束;
  2. 然后就是使用 callbacks 收集传入的回调函数,并对其做了异常处理;
  3. 然后会进入 timerFunc 这个方法,我们先不看;
  4. 最后,如果没有传回调函数并且当前环境支持 Promise,它会返回一个 Promise 对象,对应用法里的 Promise 方式使用。

timerFunc

在上面,我们看到 nextTick 中间的一个环节调用了 timerFunc 这个方法,他究竟做了什么呢?

我们可以搜索该方法,发现这个方法有多处赋值,整体代码如下(官方注释已翻译):

let timerFunc
// 虽然 MutationObserver 支持度比 Promise 更高,但它在iOS系统( >= 9.3.3)中,触发 touch 事件时会有严重 bug,
// 多次触发甚至会导致无响应,所以优先使用 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // <微任务> 使用原生 Promise
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 但在部分IOS系统里,微任务队列不会及时清空,所以手动加个计时器清一下
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // <微任务> 不支持原生 Promise 则用 MutationObserver 代替,但是它在 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 (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // <宏任务> 不支持 MutationObserver 则用 setImmediate 代替,虽然是宏任务,但效率优于 setTimeout
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // <宏任务>都不支持就只能用 setTimeout 代替
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

这个一个明显的分支判断,我们分析一下流程

  • 最开始会判断当前环境是否原生支持 Promise ,支持的话优先用 Promise
  • 不支持 Promise 那就看是否支持 MutationObserver
  • 不支持 MutationObserver 那就看是否支持 setImmediate
  • 以上都不支持,就只能用 setTimeout

在使用 PromiseMutationObserverisUsingMicroTask 会标记为 true,代表使用微任务来做异步流程控制。

timerFunc 的作用,只是为了执行回调函数而已,只是在使用不同方法时,需要加入一些特定的处理。

比如使用 MutationObserver 时,会通过监听一个文本节点的内容变化来触发清空操作,充分利用了微任务的高优先级特性。

总之,nextTick 会优先使用微任务(Promise、MutationObserver),不支持相应方法采用宏任务(setImmediate、setTimeout)代替。

flushCallbacks

这个就是清空回调函数队列的方法,它会执行 callbacks 数组里的所有回调并清空它。

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

通过源码里的大片注释,我们发现 nextTick 是个让作者很头痛的东西,在 2.5 版本的 vue 里,为了兼容性考虑,使用 宏任务 + 微任务 的方案来处理,相关的 issue 很多,2.6 版本才统一优先使用 微任务

// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).

总结

  • Vue 中的Dom更新是异步的(基于事件循环),会等待所有数据都操作完毕后统一更新 UI;

  • 在 Vue 2.6+ 中,优先使用 微任务 Promise 来做异步流程控制,实在不行才使用 宏任务 setTimeout 来控制

    具体降级流程:
    Promise > MutationObserver > SetImmediate > SetTimeout

    Vue 2.5 版本中则是:
    SetImmediate > MessageChannel > SetTimeout

  • 微任务有很高的优先级,处于宏任务结束后,UI渲染之前,是做异步流程控制的最佳选择,特别是 Promise

好啦👌,相信到这里,你们已经对 nextTick 的原理有了一个更深入的认识,如果有对事件循环 EventLoop 不了解的,可以参考文末的相关文章。

参考

一次弄懂EventLoop

你真的理解nextTick吗?

© 本文著作权归作者所有,未经许可不得转载使用。