全面解析Vue.nextTick实现原理

原文地址: https://mp.weixin.qq.com/s/mCcW4OYj3p3471ghMBylBw

编者按:本文由大豹杂说公众号吕大豹授权奇舞周刊转载。来一起学习吧!

vue 中有一个较为特殊的 API,nextTick。根据官方文档的解释,它可以在 DOM 更新完毕之后执行一个回调,用法如下:

// 修改数据
vm.msg = "Hello";
// DOM 还没有更新
Vue.nextTick(function() {
  // DOM 更新了
});

尽管 MVVM 框架并不推荐访问 DOM,但有时候确实会有这样的需求,尤其是和第三方插件进行配合的时候,免不了要进行 DOM 操作。而nextTick就提供了一个桥梁,确保我们操作的是更新后的 DOM。

本文从这样一个问题开始探索:vue 如何检测到 DOM 更新完毕呢?

检索一下自己的前端知识库,能监听到 DOM 改动的 API 好像只有MutationObserver了,后面简称 MO.

理解 MutationObserver

MutationObserver是 HTML5 新增的属性,用于监听 DOM 修改事件,能够监听到节点的属性、文本内容、子节点等的改动,是一个功能强大的利器,基本用法如下:

//MO基本用法
var observer = new MutationObserver(function() {
  //这里是回调函数
  console.log("DOM被修改了!");
});

var article = document.querySelector("article");

observer.observer(article);

MO 的使用不是本篇重点。这里我们要思考的是:vue 是不是用 MO 来监听 DOM 更新完毕的呢?

那就打开 vue 的源码看看吧,在实现 nextTick 的地方,确实能看到这样的代码:

//vue@2.2.5 /src/core/util/env.js
if (
  typeof MutationObserver !== "undefined" &&
  (isNative(MutationObserver) ||
    MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
  var counter = 1;
  var observer = new MutationObserver(nextTickHandler);
  var textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true
  });

  timerFunc = () => {
    counter = (counter + 1) % 2;

    textNode.data = String(counter);
  };
}

简单解释一下,如果检测到浏览器支持 MO,则创建一个文本节点,监听这个文本节点的改动事件,以此来触发nextTickHandler(也就是 DOM 更新完毕回调)的执行。后面的代码中,会执行手工修改文本节点属性,这样就能进入到回调函数了。

大体扫了一眼,似乎可以得到实锤了:哦!vue 是用 MutationObserver 监听 DOM 更新完毕的!

难道不感觉哪里不对劲吗?让我们细细想一下:

  1. 我们要监听的是模板中的 DOM 更新完毕,vue 为什么自己创建了一个文本节点来监听,这有点说不通啊!

  2. 难道自己创建的文本节点更新完毕,就能代表其他 DOM 节点更新完毕吗?这又是什么道理!

看来我们上面得出的结论并不对,这时候就需要讲讲 js 的事件循环机制了。

事件循环(Event Loop)

在 js 的运行环境中,我们这里光说浏览器吧,通常伴随着很多事件的发生,比如用户点击、页面渲染、脚本执行、网络请求,等等。为了协调这些事件的处理,浏览器使用事件循环机制。

简要来说,事件循环会维护一个或多个任务队列(task queues),以上提到的事件作为任务源往队列中加入任务。有一个持续执行的线程来处理这些任务,每执行完一个就从队列中移除它,这就是一次事件循环了,如下图所示:

事件循环

我们平时用 setTimeout 来执行异步代码,其实就是在任务队列的末尾加入了一个 task,待前面的任务都执行完后再执行它。

关键的地方来了,每次 event loop 的最后,会有一个 UI render 步骤,也就是更新 DOM。标准为什么这样设计呢?考虑下面的代码:

for (let i = 0; i < 100; i++) {
  dom.style.left = i + "px";
}

浏览器会进行 100 次 DOM 更新吗?显然不是的,这样太耗性能了。事实上,这 100 次 for 循环同属一个 task,浏览器只在该 task 执行完后进行一次 DOM 更新。

那我们的思路就来了:只要让 nextTick 里的代码放在 UI render 步骤后面执行,岂不就能访问到更新后的 DOM 了?

vue 就是这样的思路,并不是用 MO 进行 DOM 变动监听,而是用队列控制的方式达到目的。那么 vue 又是如何做到队列控制的呢?我们可以很自然的想到 setTimeout,把 nextTick 要执行的代码当作下一个 task 放入队列末尾。

然而事情却没这么简单,vue 的数据响应过程包含:数据更改->通知 Watcher->更新 DOM。而数据的更改不由我们控制,可能在任何时候发生。如果恰巧发生在 repaint 之前,就会发生多次渲染。这意味着性能浪费,是 vue 不愿意看到的。

所以,vue 的队列控制是经过了深思熟虑的(也经过了多次改动)。在这之前,我们还需了解 event loop 的另一个重要概念,microtask.

microtask

从名字看,我们可以把它称为微任务。对应的,task 队列中的任务也被叫做 macrotask。名字相似,性质可不一样了。

每一次事件循环都包含一个 microtask 队列,在循环结束后会依次执行队列中的 microtask 并移除,然后再开始下一次事件循环。

在执行 microtask 的过程中后加入 microtask 队列的微任务,也会在下一次事件循环之前被执行。也就是说,macrotask 总要等到 microtask 都执行完后才能执行,microtask 有着更高的优先级。

microtask 的这一特性,简直是做队列控制的最佳选择啊!vue 进行 DOM 更新内部也是调用 nextTick 来做异步队列控制。而当我们自己调用 nextTick 的时候,它就在更新 DOM 的那个 microtask 后追加了我们自己的回调函数,从而确保我们的代码在 DOM 更新后执行,同时也避免了 setTimeout 可能存在的多次执行问题。

常见的 microtask 有:PromiseMutationObserverObject.observe(废弃),以及 nodejs 中的 process.nextTick.

咦?好像看到了 MutationObserver,难道说 vue 用 MO 是想利用它的 microtask 特性,而不是想做 DOM 监听?对喽,就是这样的。核心是 microtask,用不用 MO 都行的。事实上,vue 在 2.5 版本中已经删去了 MO 相关的代码,因为它是 HTML5 新增的特性,在 iOS 上尚有 bug。

那么最优的 microtask 策略就是 Promise 了,而令人尴尬的是,Promise 是 ES6 新增的东西,也存在兼容问题呀~ 所以 vue 就面临一个降级策略。

vue 的降级策略

上面我们讲到了,队列控制的最佳选择是 microtask,而 microtask 的最佳选择是 Promise.但如果当前环境不支持 Promise,vue 就不得不降级为 macrotask 来做队列控制了。

macrotask 有哪些可选的方案呢?前面提到了 setTimeout 是一种,但它不是理想的方案。因为 setTimeout 执行的最小时间间隔是约 4ms 的样子,略微有点延迟。还有其他的方案吗?

不卖关子了,在 vue2.5 的源码中,macrotask 降级的方案依次是:setImmediateMessageChannelsetTimeout.

setImmediate 是最理想的方案了,可惜的是只有 IE 和 nodejs 支持。

MessageChannelonmessage 回调也是 microtask,但也是个新 API,面临兼容性的尴尬…

所以最后的兜底方案就是 setTimeout 了,尽管它有执行延迟,可能造成多次渲染,算是没有办法的办法了。

总结

以上就是 vue 的 nextTick 方法的实现原理了,总结一下就是:

  1. vue 用异步队列的方式来控制 DOM 更新和 nextTick 回调先后执行
  2. microtask 因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕

  3. 因为兼容性问题,vue 不得不做了 microtask 向 macrotask 的降级方案

相关资料:

event loop 标准

https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

vue2.5 的 nextTick 更改记录

https://github.com/vuejs/vue/commit/6e41679a96582da3e0a60bdbf123c33ba0e86b31

源码解析文章

https://github.com/answershuto/learnVue/blob/master/docs/Vue.js%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0DOM%E7%AD%96%E7%95%A5%E5%8F%8AnextTick.MarkDown

如果您觉得本文对您有用,欢迎捐赠或留言~
微信支付
支付宝

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注