Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!

原发于个人博客javascript

前一篇文章已经详细记述了Vue的核心执行过程。至关于已经搞定了主线剧情。后续的文章都会对其中没有介绍的细节进行展开。html

如今咱们就来说讲其余支线任务:nextTick和microtask。vue

Vue的nextTick api的实现部分是Vue里比较好理解的一部分,与其余部分的代码也很是的解耦,所以这一块的相关源码解析文章不少。我原本也不许备单独写博客细说这部分,可是最近偶然在别人的文章中了解到:
每轮次的event loop中,每次执行一个task,并执行完microtask队列中的全部microtask以后,就会进行UI的渲染。可是做者彷佛对于这个结论也不是很确定。而我第一反应就是Vue的$nextTick既然用到了MutationObserver(MO的回调放进的是microtask的任务队列中的),那么是否是也是出于这个考虑呢?因而我想研究了一遍Vue的$nextTick,就能够了解是否是出于这个目的,也同时看能不能佐证UI Render真的是在microtask队列清空后执行的。java

研究以后的结论:我以前对于$nextTick源码的理解彻底是错的,以及每轮事件循环执行完全部的microtask,是会执行UI Render的。react

task/macrotask和microtask的概念自从去年知乎上有人提出这个问题以后,task和microtask已经被不少同窗了解了,我也是当时看到了microtask的内容,如今已经有很是多的中文介绍博客在介绍这部分的知识,最近这篇火遍掘金、SF和知乎的文章,最后也是考了microtask的概念。若是你没有看过task/microtask的内容的话,我仍是推荐这篇英文博客,是绝大多数国内博客的内容来源。webpack

先说nextTick的具体实现

先用120秒介绍MutationObserver: MO是HTML5中的新API,是个用来监视DOM变更的接口。他能监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等等。
调用过程很简单,可是有点不太寻常:你须要先给他绑回调:
var mo = new MutationObserver(callback)
经过给MO的构造函数传入一个回调,能获得一个MO实例,这个回调就会在MO实例监听到变更时触发。ios

这个时候你只是给MO实例绑定好了回调,他具体监听哪一个DOM、监听节点删除仍是监听属性修改,你都尚未设置。而调用他的observer方法就能够完成这一步:git

var domTarget = 你想要监听的dom节点
mo.observe(domTarget, {
      characterData: true //说明监听文本内容的修改。
})

一个须要先说的细节是,MutationObserver的回调是放在microtask中执行的。github

ok了,如今这个domTarget上发生的文本内容修改就会被mo监听到,mo就会触发你在new MutationObserver(callback)中传入的callback。web

如今咱们来看Vue.nextTick的源码:

export const nextTick = (function () {
  var callbacks = []
  var pending = false
  var timerFunc
  function nextTickHandler () {
    pending = false
    // 之因此要slice复制一份出来是由于有的cb执行过程当中又会往callbacks中加入内容
    // 好比$nextTick的回调函数里又有$nextTick
    // 这些是应该放入到下一个轮次的nextTick去执行的,
    // 因此拷贝一份当前的,遍历执行完当前的便可,避免无休止的执行下去
    var copies = callbacks.slice(0)
    callbacks = []
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  /* istanbul ignore if */
  // ios9.3以上的WebView的MutationObserver有bug,
  //因此在hasMutationObserverBug中存放了是不是这种状况
  if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
    var counter = 1
    // 建立一个MutationObserver,observer监听到dom改动以后后执行回调nextTickHandler
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(counter)
    // 调用MutationObserver的接口,观测文本节点的字符内容
    observer.observe(textNode, {
      characterData: true
    })
    // 每次执行timerFunc都会让文本节点的内容在0/1之间切换,
    // 不用true/false多是有的浏览器对于文本节点设置内容为true/false有bug?
    // 切换以后将新值赋值到那个咱们MutationObserver观测的文本节点上去
    timerFunc = function () {
      counter = (counter + 1) % 2
      textNode.data = counter
    }
  } else {
    // webpack attempts to inject a shim for setImmediate
    // if it is used as a global, so we have to work around that to
    // avoid bundling unnecessary code.
    // webpack默认会在代码中插入setImmediate的垫片
    // 没有MutationObserver就优先用setImmediate,不行再用setTimeout
    const context = inBrowser
      ? window
      : typeof global !== 'undefined' ? global : {}
    timerFunc = context.setImmediate || setTimeout
  }
  return function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    // 若是pending为true, 就其实代表本轮事件循环中已经执行过timerFunc(nextTickHandler, 0)
    if (pending) return
    pending = true
    timerFunc(nextTickHandler, 0)
  }
})()

上面这个函数执行过程后生成的那个函数才是nextTick。而这个函数的执行过程就是先初始化pending变量和cb变量,cb用来存放须要执行的回调,pending表示是否把清空回调的nextTickHandler函数加入到异步队列中。

而后就是建立了一个MO,这个MO监听了一个新建立的文本节点的文本内容变化,同时监听到变化时的回调就是nextTickHandler。nextTickHandler遍历cb数组,把须要执行的cb给拿出来一个个执行了。

而最后返回出去做为nextTick的那个函数就比较简单了:

function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    // 若是pending为true, 就其实代表本轮事件循环中已经执行过timerFunc(nextTickHandler, 0)
    if (pending) return
    pending = true
    timerFunc(nextTickHandler, 0)
  }
}

也就是把传入的回调放入cb数组当中,而后执行timerFunc(nextTickHandler, 0),实际上是执行timerFunc(),后面传入的两参数没用,在浏览器不支持MO的状况timerFunc才回退到setTimeout,那俩参数才有效果。timerFunc就是把那个被MO监听的文本节点改一下它的内容,这样我改了文本内容,MO就会在当前的全部同步代码完成以后执行回调,从而执行数据更新到DOM上以后的任务。

我一开始在看这一段代码时忘记了MutationObserver的回调是在microtask里执行的。并且当时也尚未看过Vue的其余源码,当时的我大致看懂nextTick代码流程以后,造成了以下的理解,并且以为彷佛完美的解释了代码逻辑:
watcher监听到数据变化以后,会立马去修改dom,接着用户书写的代码里的nextTick被执行,而nextTick内部也是去修改DOM(textNode),当这个最后修改的textNode修改完成了,触发了MutationObserver的回调,那就意味着,前面的DOM修改也已经完成了,因此nextTick向用户保证的DOM更新以后再执行用户的回调就得以实现了。

Damn,如今看了Batcher的代码和认真反思了之后,立马醒悟,上面的想法完彻底全就是一坨狗屎,totally shit!

首先,一个广泛的常识是DOM Tree的修改是实时的,而修改的Render到DOM上才是异步的。根本不存在什么所谓的等待DOM修改完成,任什么时候候我在上一行代码里往DOM中添加了一个元素、修改了一个DOM的textContent,你在下一行代码里必定能立马就读取到新的DOM,我知道这个理。可是我仍是搞不懂我怎么会产生用nextTick来保证DOM修改的完成这样的怪念头。可能那天屎吃得有点多了。

其次,咱们来看看使用nextTick的真正缘由:

Vue在两个地方用到了上述nextTick:

  • Vue.nextTick和Vue.prototype.$nextTick都是直接使用了这个nextTick

  • 在batcher中,也就是watcher观测到数据变化后执行的是nextTick(flushBatcherQueue)flushBatcherQueue则负责执行完成全部的dom更新操做。

Batcher的源码,我在上一篇文章当中已经详细的分析了,在这里我用一张图来讲明它和nextTick的详细处理过程吧。
假设此时Vue实例的模板为:<div id="a">{{a}}</div>

仔细跟踪了代码执行过程咱们会发现,真正的去遍历watcher,批处理更新是在microtask中执行的,并且用户在修改数据后本身执行的nextTick(cb)也会在此时执行cb,他们都是在同一个microtask中执行。根本就不是我最开始想的那样,把回调放在之后的事件循环中去执行。

同时,上面这个过程也深切的揭露出Vue nextTick的本质,我不是想要MO来帮我真正监听DOM更改,我只是想要一个异步API,用来在当前的同步代码执行完毕后,执行我想执行的异步回调。

之因此要这样,是由于用户的代码当中是可能屡次修改数据的,而每次修改都会同步通知到全部订阅该数据的watcher,而立马执行将数据写到DOM上是确定不行的,那就只是把watcher加入数组。等到当前task执行完毕,全部的同步代码已经完成,那么这一轮次的数据修改就已经结束了,这个时候我能够安安心心的去将对监听到依赖变更的watcher完成数据真正写入到DOM上的操做,这样即便你在以前的task里改了一个watcher的依赖100次,我最终只会计算一次value、改DOM一次。一方面省去了没必要要的DOM修改,另外一方面将DOM操做汇集,能够提高DOM Render效率。

那为何必定要用MutationObserver呢?不,并无必定要用MO,只要是microtask均可以。在最新版的Vue源码里,优先使用的就是Promise.resolve().then(nextTickHandler)来将异步回调放入到microtask中(MO在IOS9.3以上的WebView中有bug),没有原生Promise才用MO。

这充分说明了microtask才是nextTick的本质,MO什么的只是个备胎,要是有比MO优先级更高、浏览器兼容性更好的microtask,那可能就分分钟把MO拿下了。

那问题又来了,为何必定要microtask?task能够吗?(macrotask和task是一回事哈,HTML5标准里甚至都没有macrotask这个词)。

哈,如今恰好有个例子,Vue一开始曾经改过nextTick的实现。咱们来看看这两个jsFiddle:jsfiddle1jsfiddle2

两个fiddle的实现如出一辙,就是让那个绝对定位的黄色元素起到一个fixed定位的效果:绑定scroll事件,每次滚动的时候,计算当前滚动的位置并更改到那个绝对定位元素的top属性上去。你们本身试试滚动几下,对比下效果,你就会发现第一个fiddle中的黄元素是稳定不动的,fixed很好。然后一个fiddle中就有问题了,黄色元素上下晃动,彷佛跟不上咱们scroll的节奏,总要慢一点,虽然最后停下滚动时位置是对的。

上述两个例子实际上是在这个issue中找到的,第一个jsfiddle使用的版本是Vue 2.0.0-rc.6,这个版本的nextTick实现是采用了MO,然后由于IOS9.3的WebView里的MO有bug,因而尤雨溪更改了实现,换成了window.postMessage,也就是后一个fiddle所使用的Vue 2.0.0-rc.7。后来尤雨溪了解到window.postMessage是将回调放入的macrotask 队列。这就是问题的根源了。

HTML中的UI事件、网络事件、HTML Parsing等都是使用的task来完成,所以每次scroll事件触发后,在当前的task里只是完成了把watcher加入队列和把清空watcher的flushBatcherQueue做为异步回调传入nextTick。

若是nextTick使用的是microtask,那么在task执行完毕以后就会当即执行全部microtask,那么flushBatcherQueue(真正修改DOM)便得以在此时当即完成,然后,当前轮次的microtask所有清理完成时,执行UI rendering,把重排重绘等操做真正更新到DOM上(后文会细说)。(注意,页面的滚动效果并不须要重绘哈。重绘是当你修改了UI样式、DOM结构等等,页面将样式呈现出来,别晕了。)
若是nextTick使用的是task,那么会在当前的task和全部microtask执行完毕以后才在之后的某一次task执行过程当中处理flushBatcherQueue,那个时候才真正执行各个指令的修改DOM操做,但那时为时已晚,错过了屡次触发重绘、渲染UI的时机。并且浏览器内部为了更快的响应用户UI,内部多是有多个task queue的:

For example, a user agent could have one task queue for mouse and key events (the user interaction task source), and another for everything else. The user agent could then give keyboard and mouse events preference over other tasks three quarters of the time, keeping the interface responsive but not starving other task queues, and never processing events from any one task source out of order.

而UI的task queue的优先级可能更高,所以对于尤雨溪采用的window.postMessage,甚至可能已经屡次执行了UI的task,都没有执行window.postMessage的task,也就致使了咱们更新DOM操做的延迟。在重CPU计算、UI渲染任务状况下,这一延迟达到issue观测到的100毫秒到1秒的级别是彻底课可能的。所以,使用task来实现nextTick是不可行的,而尤雨溪也撤回了这一次的修改,后续的nextTick实现中,依然是使用的Promise.then和MO。

task microtask和每轮event loop以后的UI Render

我最近认真阅读了一下HTML5规范,仍是来讲一说task和microtask处理完成以后的UI渲染过程,讲一下每次task执行和全部microtask执行完毕后使如何完成UI Render的。

先上HTML标准原文
比较典型的task有以下这些

  • Events
    Dispatching an Event object at a particular EventTarget object is often done by a dedicated task. Not all events are dispatched using the task queue, many are dispatched during other tasks.

  • Parsing
    The HTML parser tokenizing one or more bytes, and then processing any resulting tokens, is typically a task.

  • Callbacks
    Calling a callback is often done by a dedicated task.

  • Using a resource
    When an algorithm fetches a resource, if the fetching occurs in a non-blocking fashion then the processing of the resource once some or all of the resource is available is performed by a task.

  • Reacting to DOM manipulation
    Some elements have tasks that trigger in response to DOM manipulation, e.g. when that element is inserted into the document.

此外,还包括setTimeout, setInterval, setImmediate, window.postMessage等等。
上述Reacting to DOM manipulation并非说你执行DOM操做时就会把这个DOM操做的执行当成一个task。是那些异步的reacting会被当作task。

HTML5标准:task、microtask和UI render的具体执行过程以下

An event loop must continually run through the following steps for as long as it exists:

1.Select the oldest task on one of the event loop's task queues, if any, ignoring, in the case of a browsing context event loop, tasks whose associated Documents are not fully active. The user agent may pick any task queue. If there is no task to select, then jump to the microtasks step below.

2.Set the event loop's currently running task to the task selected in the previous step.

3.Run: Run the selected task.

4.Set the event loop's currently running task back to null.

5.Remove the task that was run in the run step above from its task queue.

6.Microtasks: Perform a microtask checkpoint. //这里会执行全部的microtask

7.Update the rendering: If this event loop is a browsing context event loop (as opposed to a worker event loop), then run the following substeps.

7.1 Let now be the value that would be returned by the Performance object's now() method.
7.2 Let docs be the list of Document objects associated with the event loop in question, sorted arbitrarily except that the following conditions must be met:
7.3 If there are top-level browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context's top-level browsing context is in B.
7.4 If there are a nested browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context is in B.
7.5 For each fully active Document in docs, run the resize steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.6 For each fully active Document in docs, run the scroll steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.7 For each fully active Document in docs, evaluate media queries and report changes for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.8 For each fully active Document in docs, run CSS animations and send events for that Document, passing in now as the timestamp. [CSSANIMATIONS]
7.9 For each fully active Document in docs, run the fullscreen rendering steps for that Document, passing in now as the timestamp. [FULLSCREEN]
7.10 For each fully active Document in docs, run the animation frame callbacks for that Document, passing in now as the timestamp.
7.11 For each fully active Document in docs, run the update intersection observations steps for that Document, passing in now as the timestamp. [INTERSECTIONOBSERVER]
7.12 For each fully active Document in docs, update the rendering or user interface of that Document and its browsing context to reflect the current state.

8.If this is a worker event loop (i.e. one running for a WorkerGlobalScope), but there are no tasks in the event loop's task queues and the WorkerGlobalScope object's closing flag is true, then destroy the event loop, aborting these steps, resuming the run a worker steps described in the Web workers section below.
9.Return to the first step of the event loop.

解释一下:第一步,从多个task queue中的一个queue里,挑出一个最老的task。(由于有多个task queue的存在,使得浏览器能够完成咱们前面说的,优先、高频率的执行某些task queue中的任务,好比UI的task queue)。
而后2到5步,执行这个task。
第六步, Perform a microtask checkpoint. ,这里会执行完microtask queue中的全部的microtask,若是microtask执行过程当中又添加了microtask,那么仍然会执行新添加的microtask,固然,这个机制好像有限制,一轮microtask的执行总量彷佛有限制(1000?),数量太多就执行一部分留下的之后再执行?这里我不太肯定。

第七步,Update the rendering:
7.2到7.4,当前轮次的event loop中关联到的document对象会保持某些特定顺序,这些document对象都会执行须要执行UI render的,可是并非全部关联到的document都须要更新UI,浏览器会判断这个document是否会从UI Render中获益,由于浏览器只须要保持60Hz的刷新率便可,而每轮event loop都是很是快的,因此不必每一个document都Render UI。
7.5和7.6 run the resize steps/run the scroll steps不是说去执行resize和scroll。每次咱们scoll的时候视口或者dom就已经当即scroll了,并把document或者dom加入到 pending scroll event targets中,而run the scroll steps具体作的则是遍历这些target,在target上触发scroll事件。run the resize steps也是类似的,这个步骤是触发resize事件。
7.8和7.9 后续的media query, run CSS animations and send events等等也是类似的,都是触发事件,第10步和第11步则是执行咱们熟悉的requestAnimationFrame回调和IntersectionObserver回调(第十步仍是挺关键的,raf就是在这执行的!)。
7.12 渲染UI,关键就在这了。

第九步 继续执行event loop,又去执行task,microtasks和UI render。

更新:找到一张图,不过着重说明的是整个event loop,没有细说UI render。

相关文章
相关标签/搜索