抽丝剥茧般的阅读源码,将$nextTick()拉下神坛!

2,371 阅读9分钟

    古人云:知其然知其所以然

前言

相信有很多开发第一次碰到vue中的$nextTick()时都会把它当作一次setTimeout()调用。这个理解是对的吗?

1. 前置知识

这个方法理解起来并不难,但需要知道下面的概念:

  • 调用栈
  • 任务队列
  • 事件循环

下面假设大家对这些概念已经非常清楚了。

$nextTick()

1. 概念

掌握一个知识点的背后原理,就必须对它的使用要非常熟悉。来看官方的介绍。

vm.$nextTick([callback])

  • 参数:{function} [callback]
  • 用法:将回调延迟到下次DOM更新循环之后执行。在修改数据之后立即使用它,然后等待DOM更新。回调的this自动绑定到调用它的实例上。
new Vue({
  // ...
  methods: {
    // ...
    example: function () {
      // 修改数据
      this.message = 'changed'
      // DOM 还没有更新
      this.$nextTick(function () {
        // DOM 现在更新了
        // `this` 绑定到当前实例
        this.doSomethingElse()
      })
    }
  }
})

上面有一句话说"将回调延迟到下次DOM更新循环之后在执行"。这句话的意思是:

Vue在更新DOM时是异步执行的。只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。然后,在下一个的事件循环中,Vue刷新队列并执行实际的工作。

例如,当设置vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环中更新。但是如果想基于更新后的DOM状态来做点什么,这就有点难办了。所以Vue就推出了$nextTick(),此方法接收的回调函数将在DOM更新完成后被调用。

Vue文档上也写了:

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

所以将$nextTick()当作一次setTimeout()调用,并不能说是错的。只是没有那么准确。

2. 源码解读

$nextTick()是在/src/core/instance/render.js中定义的:

export function renderMixin (Vue: Class<Component>) {
  ...
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
  ...
}

$nextTick()方法是在renderMixin函数中挂载到Vue原型上的。可以看出$nextTick()是对nextTick函数的简单封装。

nextTick函数是在/src/core/util/next-tick.js中定义的。next-tick.js文件中主体是一段4层if else语句。

if () {
    ...
} else if () {
    ...
} else if () {
    ...
} else {
    ...
}

第一层

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)
  }
}

首先判断当前环境支不支持Promise,如果支持则优先使用Promise

总所周知,任务队列并非只有一个队列,总的来说可以将其分为微任务--microtask和宏任务--macrotask。当调用栈空闲后,事件循环就会在宏任务消息队列中读取一个任务并执行。宏任务执行的过程中,有时候会产生多个微任务,将其保存在微任务队列中。也就是说每个宏任务都关联了一个微任务队列。当主函数执行结束之后、当前宏任务结束之前,事件循环就会将当前微任务队列执行并清空。

另外两个宏任务之间可能穿插着UI的重渲染,那么只需要在微任务中把所有UI重渲染之前把需要更新的数据全部更新,这样只需要一次重渲染就能得到最新的DOM了。

对于vue来说,vue是一个数据驱动的框架,要是能在UI重渲染之前更新所有数据状态,这对性能的提升是一个非常大的帮助,所以要优先使用微任务去更新数据状态而不是宏任务,这就是为什么优先使用promise,而不是setTimeout的原因。

接着解读:
首先定义常量 p 它的值是一个立即 resolvePromise 实例对象。

接着将变量 timerFunc 定义为一个函数,这个函数的执行将会把 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)

注释说,在一些UIWebViews中微任务没有被刷新,解决方案就是让浏览器做一些其他的事件,比如注册一个宏任务,即使这个宏任务什么都不做,这样就能间接触发微任务的刷新。

第二层

如果当前环境不支持Promise(IE:看我干嘛),走到第二层else if语句中。

else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. 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)
  }
}

判断当前环境是否支持MutationObserver

MutationObserver也是一个微任务,提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。

首先将flushCallbacks传入MutationObserver构造函数中,创建并返回一个新的MutationObserver它会在指定的DOM发生变化时被调用。

const observer = new MutationObserver(flushCallbacks)

然后创建一个根据counter变量的文本DOM节点,并配置MutationObserver订阅此DOM节点,所以当这个DOM节点变化时,flushCallbacks就会注册在微任务队列。

let counter = 1
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
    characterData: true
})

最后将timeFun注册为一个函数,当timeFun执行时,立即更改counter的值,从而引起MutationObserver的更改,将flushCallbacks注册在微任务队列中。

timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}

第三层

接着解读第三层else if语句

else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
}

可以看出setImmediate函数优先setTimeout函数。这是因为setImmediatesetTimeout有更好的性能。

setTimeout将回调函数注册在宏任务队列中之前要不断的做超时检测,而setImmediate不需要。但是setImmediate有明显的缺陷,只有IE实现了它。

第四层

最后第四层else语句,就轮到setTimeout出场了。

else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

3. 非浏览器环境

翻查源码的过程中,发现nextTick函数的定义不只有一处地方。在packages/weex-vue-framework/factory.js中也定义了。

也不难理解,因为上面介绍的都是基于是浏览器环境的,weex是运行在node环境下的。

也来看看这个nextTick定义与上面的有什么不同。

主体是围绕着mircotaskmarcotask进行,也就是分别定义宏任务与微任务。

var macroTimerFunc;
if () {
    macroTimerFunc = function () {...}
} else if () {
    macroTimerFunc = function () {...}
} else {...}

var microTimerFunc;
if () {
    microTimerFunc = function () {...}
} else {
    microTimerFunc = macroTimerFunc
}

可以看到macroTimerFunc有三层if判断,microTimerFunc有两层。

macroTimerFunc

先看macroTimerFunc

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = function () {
    setImmediate(flushCallbacks);
  };
}

优先判断当前环境是否支持setImmediate,最后的else才是使用setTimeout

else {
  /* istanbul ignore next */
  macroTimerFunc = function () {
    setTimeout(flushCallbacks, 0);
  };
}

MessageChannel

而中间的else if是判断是否支持MessageChannel

else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  var channel = new MessageChannel();
  var port = channel.port2;
  channel.port1.onmessage = flushCallbacks;
  macroTimerFunc = function () {
    port.postMessage(1);
  };
}

了解过Web Workers都知道,Web Workers的内部实现就是用到MessageChannel。一个MessageChannel实例对象拥有两个属性port1port2,只要让port1监听onmessage事件,然后使用port2postMessageport1发送消息即可,这样port1onmessage回调就会被注册为宏任务,由于它也不需要任何检测工作,所以性能也比setTimeout要好。

总之macroTimerFunc函数的作用就是将flushCallbacks注册为宏任务。

microTimerFunc

举一反三,microTimerFunc函数的作用就是将flushCallbacks注册为微任务。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  microTimerFunc = function () {
    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); }
  };
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc;
}

如果不支持Promise,那么microTimerFunc = macroTimerFunc;

4. 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)
    }
  })
  // 忽略
}

nextTick函数会在callbacks数组中添加一个新的函数,callbacks数组定义在文件头部:const callback = []。注意并不是将cb回调函数直接添加到callbacks数组中,会使用一个新的函数包裹回调函数并将新函数添加到callbacks数组中。但这个被添加到callbacks数组中的函数执行会间接调用cd回调函数,并且可以看到cb函数时使用.call方法将函数cb的作用域设置为ctx,也就是nextTick函数的第二个参数。所以对于$nextTick方法来讲,传递给$nextTick方法的回调函数的作用域是当前组件实例对象,前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也没关系,只要达到目的就行。

继续看源码

export function nextTick (cb?: Function, ctx?: Object) {
  ...
  if (!pending) {
    pending = true
    timerFunc()
  }
  ...
}

进行一个if条件判断,判断pending的真假,pending变量定义在文件头部:let pending = false,它是一个标识,它的真假代表回调队列是否处于等待刷新的状态,初始值为false代表回调队列为空不需要等待刷新。假如此时在某个地方调用了$nextTick方法,那么if语句块内的代码将会被执行,在if语句块内优先将变量pending的值设置为true,代表着此时回调队列不为空,正在等待刷新。既然等待刷新,那么当然要刷新回调队列。这时就用到前面的timerFunc函数。在week中则是micTimeFunc或者marcoTaskFunc。无论是哪种任务类型,它们都将会等待调用栈清空之后才执行。

举例:

created () {
    this.$nextTick( () => {console.log(1)})
    this.$nextTick( () => {console.log(2)})
    this.$nextTick( () => {console.log(3)})
}

created钩子函数中连续调用三次$nextTick方法,但只有第一次调用$nextTick方法时才会执行timerFunc函数将flushCallbacks注册为微任务,但此时flushCallbacks函数并不会执行,因为它要等待接下来的两次$nextTick方法的调用语句执行完后才会执行,准确的说等待调用栈被清空之后才会进行。也就是说flushCallbacks函数执行的时候,callbacks回调队列中将包含本次事件循环所收集的所有通过$nextTick方法注册的回调,而接下来的任务就是在flushCallbacks函数内将这些回调全部执行并清空。

下面是flushCallbacks的源码

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

首先将变量pending设置为false,接着开始执行回调,但需要注意的是在执行callbacks队列中的回调函数时并没有直接遍历callbacks数组,而是使用copies常量保存一份复制品。然后遍历copies数组,并且在遍历copies数值之前将callbacks数组清空:callbacks.length = 0。

为什么要这样做呢?举个例子

created () {
    this.age = 20
    this.$nextTick(() => {
        this.age = 21
        this.$nextTick(() => { console.log('第二个nextTick')})
    })
}

$nextTick()的回调函数中再次调用了$nextTick方法,理论上外层$nextTick方法的回调函数不应该与内层$nextTick的回调函数在同一个微任务中被执行,而是两个不同的微任务,虽然在结果上看或许没什么差别,但从设计角度就应该这样做。

上面代码修改了两次age属性的值,首先将age的值修改为20,上面说到Vue在更新DOM时也是异步执行的,这个过程中就是将flushSchedulerQueue添加到callbacks数组中

callbacks = [
    flushSchedulerQueue
]

同时将flushCallbacks函数注册为微任务,所以微任务队列为

// 微任务队列
[
    flushCallbacks
]

接着调用第一个$nextTick方法,$nextTick会将回调函数添加到callbacks数组中,那么此时的callbacks数组如下:

callbacks = [
    flushSchedulerQueue,
    () => {
        this.age = 21
        this.$nextTick(() => {console.log('第二个$nextTick')})
    }
]

接下来主线程出于空闲状态,开始执行微任务队列,即执行flushCallbacks函数,flushCallbacks函数会按照顺序执行callbacks数组中的函数,首先会执行flushSchedulerQueue函数,这个函数会遍历queue中所有观察者并重新求值。接着执行如下函数:

() => {
    this.age = 21
    this.$nextTick(() => {console.log('第二个$nextTick')})
}

这个函数是第一个$nextTick的回调函数,由于在执行该回调函数之前已经完成了重新求值,所以该回调函数内的代码是能够访问更新后的值。在该回调函数内再次修改age属性的值后,同样会调用nextTick函数将flushSchedulerQueue添加到callbacks数组中,但是由于在执行flushCallbacks函数时优先将pending的设置为false,所以nextTick函数会将flushCallbacks函数注册为一个新的微任务。此时目的就达成了,队列包含两个微任务。

// 此时微任务队列
[
    flushCallbacks,
    flushCallbacks
]

第二个flushCallbacks函数的一切流程与第一个flushCallbacks是完全相同的。

以上,$nextTick()就介绍完毕。

结尾

更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。