前言

nextTick 是 Vue 的一个核心功能,在 Vue 内部实现中也经常用到 nextTick。但是,很多新手不理解 nextTick 的原理,甚至不清楚 nextTick 的作用。

那么,我们就先来看看 nextTick 是什么。

nextTick 功能

看看官方文档的描述:

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

再看看官方示例:

  1. // 修改数据
  2. vm.msg = 'Hello'
  3. // DOM 还没有更新
  4. Vue.nextTick(function () {
  5. // DOM 更新了
  6. })
  7. // 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
  8. Vue.nextTick()
  9. .then(function () {
  10. // DOM 更新了
  11. })

2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。

可以看到,nextTick 主要功能就是改变数据后让回调函数作用于 dom 更新后。很多人一看到这里就懵逼了,为什么需要在 dom 更新后再执行回调函数,我修改了数据后,不是 dom 自动就更新了吗?

这个和 JS 中的 Event Loop 有关,网上教程不计其数,在此就不再赘述了。建议明白 Event Loop 后再继续向下阅读本文。

举个实际的例子:

我们有个带有分页器的表格,每次翻页需要选中第一项。正常情况下,我们想的是点击翻页器,向后台获取数据,更新表格数据,操纵表格 API 选中第一项。

但是,你会发现,表格数据是更新了,但是并没有选中第一项。因为,你选中第一项时,虽然数据更新了,但是 DOM 并没有更新。此时,你可以使用 nextTick ,在DOM更新后再操纵表格第一项的选中。

那么,nextTick 到底做了什么了才能实现在 DOM 更新后执行回调函数?

源码分析

nextTick 的源码位于src/core/util/next-tick.js,总计118行,十分的短小精悍,十分适合初次阅读源码的同学。

nextTick源码主要分为两块:

  1. 能力检测

  2. 根据能力检测以不同方式执行回调队列

能力检测

这一块其实很简单,众所周知,Event Loop 分为宏任务(macro task)以及微任务( micro task),不管执行宏任务还是微任务,完成后都会进入下一个 tick,并在两个 tick 之间执行UI渲染。

但是,宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,使用宏任务;但是,各种宏任务之间也有效率的不同,需要根据浏览器的支持情况,使用不同的宏任务。

nextTick在能力检测这一块,就是遵循的这种思想。

  1. // Determine (macro) task defer implementation.
  2. // Technically setImmediate should be the ideal choice, but it's only available
  3. // in IE. The only polyfill that consistently queues the callback after all DOM
  4. // events triggered in the same loop is by using MessageChannel.
  5. /* istanbul ignore if */
  6. // 如果浏览器不支持Promise,使用宏任务来执行nextTick回调函数队列
  7. // 能力检测,测试浏览器是否支持原生的setImmediate(setImmediate只在IE中有效)
  8. if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  9. // 如果支持,宏任务( macro task)使用setImmediate
  10. macroTimerFunc = () => {
  11. setImmediate(flushCallbacks)
  12. }
  13. // 同上
  14. } else if (typeof MessageChannel !== 'undefined' && (
  15. isNative(MessageChannel) ||
  16. // PhantomJS
  17. MessageChannel.toString() === '[object MessageChannelConstructor]'
  18. )) {
  19. const channel = new MessageChannel()
  20. const port = channel.port2
  21. channel.port1.onmessage = flushCallbacks
  22. macroTimerFunc = () => {
  23. port.postMessage(1)
  24. }
  25. } else {
  26. /* istanbul ignore next */
  27. // 都不支持的情况下,使用setTimeout
  28. macroTimerFunc = () => {
  29. setTimeout(flushCallbacks, 0)
  30. }
  31. }

首先,检测浏览器是否支持 setImmediate,不支持就使用 MessageChannel,再不支持只能使用效率最差但是兼容性最好的 setTimeout了。

之后,检测浏览器是否支持 Promise,如果支持,则使用 Promise 来执行回调函数队列,毕竟微任务速度大于宏任务。如果不支持的话,就只能使用宏任务来执行回调函数队列。

执行回调函数队列

执行回调函数队列的代码刚好在一头一尾

  1. // 回调函数队列
  2. const callbacks = []
  3. // 异步锁
  4. let pending = false
  5. // 执行回调函数
  6. function flushCallbacks () {
  7. // 重置异步锁
  8. pending = false
  9. // 防止出现nextTick中包含nextTick时出现问题,在执行回调函数队列前,提前复制备份,清空回调函数队列
  10. const copies = callbacks.slice(0)
  11. callbacks.length = 0
  12. // 执行回调函数队列
  13. for (let i = 0; i < copies.length; i++) {
  14. copies[i]()
  15. }
  16. }
  17. ...
  18. // 我们调用的nextTick函数
  19. export function nextTick (cb?: Function, ctx?: Object) {
  20. let _resolve
  21. // 将回调函数推入回调队列
  22. callbacks.push(() => {
  23. if (cb) {
  24. try {
  25. cb.call(ctx)
  26. } catch (e) {
  27. handleError(e, ctx, 'nextTick')
  28. }
  29. } else if (_resolve) {
  30. _resolve(ctx)
  31. }
  32. })
  33. // 如果异步锁未锁上,锁上异步锁,调用异步函数,准备等同步函数执行完后,就开始执行回调函数队列
  34. if (!pending) {
  35. pending = true
  36. if (useMacroTask) {
  37. macroTimerFunc()
  38. } else {
  39. microTimerFunc()
  40. }
  41. }
  42. // $flow-disable-line
  43. // 2.1.0新增,如果没有提供回调,并且支持Promise,返回一个Promise
  44. if (!cb && typeof Promise !== 'undefined') {
  45. return new Promise(resolve => {
  46. _resolve = resolve
  47. })
  48. }
  49. }

总体流程就是:接收回调函数,将回调函数推入回调函数队列中。

同时,在接收第一个回调函数时,执行能力检测中对应的异步方法(异步方法中调用了回调函数队列)。

如何保证只在接收第一个回调函数时执行异步方法?

nextTick 源码中使用了一个异步锁的概念,即接收第一个回调函数时,先关上锁,执行异步方法。此时,浏览器处于等待执行完同步代码就执行异步代码的情况。

打个比喻:相当于一群旅客准备上车,当第一个旅客上车的时候,车开始发动,准备出发,等到所有旅客都上车后,就可以正式开车了。

当然执行 flushCallbacks 函数时有个难以理解的点,即:为什么需要备份回调函数队列?执行的也是备份的回调函数队列?

因为,会出现这么一种情况:nextTick 的回调函数中还使用 nextTick。如果 flushCallbacks 不做特殊处理,直接循环执行回调函数,会导致里面 nextTick 中的回调函数会进入回调队列。这就相当于,下一个班车的旅客上了上一个班车。

实现一个简易的nextTick

说了这么多,我们来实现一个简单的 nextTick:

  1. let callbacks = []
  2. let pending = false
  3. function nextTick (cb) {
  4. callbacks.push(cb)
  5. if (!pending) {
  6. pending = true
  7. setTimeout(flushCallback, 0)
  8. }
  9. }
  10. function flushCallback () {
  11. pending = false
  12. let copies = callbacks.slice()
  13. callbacks.length = 0
  14. copies.forEach(copy => {
  15. copy()
  16. })
  17. }

可以看到,在简易版的 nextTick 中,通过 nextTick 接收回调函数,通过 setTimeout 来异步执行回调函数。通过这种方式,可以实现在下一个 tick 中执行回调函数,即在UI重新渲染后执行回调函数。