vue中nextTick实现原理

# 简介

大家所熟悉的 Vue API Vue.nextTick 全局方法和 vm.$nextTick 实例方法的内部都是调用 nextTick 函数,该函数的作用可以理解为异步执行传入的函数。

nextTick中的回调是在下次 DOM 更新循环结束之后执行的延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。原理就是异步方法(promise,mutationObserver,setImmediate,setTimeout)经常与事件环一起来问(宏任务和微任务)

vue多次更新数据,最终会进行批处理更新。内部调用的就是nextTick实现了延迟更新,用户自定义的nextTick中的回调会被延迟到更新完成后调用,从而可以获取更新后的DOM。

# 原理

nextTick方法主要使用了宏任务和微任务,定义了一个异步方法。多次调用nextTick会将方法存入队列中,通过这个异步方法清空当前队列。所以这个nextTick方法就是异步方法;

image-20210205221822523

Vue.nextTick 内部逻辑; 源码位置:src/core/global-api/index.js:45

在执行 initGlobalAPI(Vue) 初始化 Vue 全局 API 中,这么定义 Vue.nextTick

function initGlobalAPI(Vue) {
    //...
    Vue.nextTick = nextTick;
}
1
2
3
4

可以看出是直接把 nextTick 函数赋值给 Vue.nextTick,就可以了,非常简单。

vm.$nextTick 内部逻辑; 源码位置:src/core/observer/watcher.js:179

Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
};
1
2
3

源码位置:src/core/util/env.js:78

export const nextTick = (function () {
  /*存放异步执行的回调*/
  const callbacks = [];
  /*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/
  let pending = false;
  /*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/
  let timerFunc;

  /*下一个tick时的回调*/
  function nextTickHandler() {
    /*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/
    pending = false;
    /*执行所有callback*/
    const copies = callbacks.slice(0);
    callbacks.length = 0;
    for (let i = 0; i < copies.length; i++) {
      copies[i]();
    }
  }

  // the nextTick behavior leverages the microtask queue, which can be accessed
  // via either native Promise.then or MutationObserver.
  // MutationObserver has wider support, however it is seriously bugged in
  // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
  // completely stops working after triggering a few times... so, if native
  // Promise is available, we will use it:
  /* istanbul ignore if */

  /*
    这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法。
    优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。
    如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
  */
  if (typeof Promise !== "undefined" && isNative(Promise)) {
    /*使用Promise*/
    var p = Promise.resolve();
    var logError = (err) => {
      console.error(err);
    };
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError);
      // 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 if (
    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 IE11, iOS7, Android 4.4
    /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会加入该回调*/
    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);
    };
  } else {
    // fallback to setTimeout
    /*使用setTimeout将回调推入任务队列尾部*/
    timerFunc = () => {
      setTimeout(nextTickHandler, 0);
    };
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

# 分析

# Promise 创建异步执行函数

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    timerFunc = function() {
        p.then(flushCallbacks);
        if (isIOS) {
            setTimeout(noop);
        }
    };
    isUsingMicroTask = true;
}
1
2
3
4
5
6
7
8
9
10

执行 if (typeof Promise !== 'undefined' && isNative(Promise)) 判断浏览器是否支持 Promise,

其中 typeof Promise 支持的话为 function ,不是 undefined,故该条件满足,这个条件好理解。

来看另一个条件,其中 isNative 方法是如何定义,代码如下。

function isNative(Ctor) {
    return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}
1
2
3

Ctor 是函数类型时,执行 /native code/.test(Ctor.toString()),检测函数 toString 之后的字符串中是否带有 native code 片段,那为什么要这么监测。这是因为这里的 toString 是 Function 的一个实例方法,如果是浏览器内置函数调用实例方法 toString 返回的结果是function Promise() { [native code] }

若浏览器支持,执行 var p = Promise.resolve()Promise.resolve() 方法允许调用时不带参数,直接返回一个resolved状态的 Promise 对象。

那么在 timerFunc 函数中执行 p.then(flushCallbacks) 会直接执行 flushCallbacks 函数,在其中会遍历去执行每个 nextTick 传入的函数,因 Promise 是个微任务 (micro task)类型,故这些函数就变成异步执行了。

执行 if (isIOS) { setTimeout(noop)} 来在 IOS 浏览器下添加空的计时器强制刷新微任务队列。

# MutationObserver 创建异步执行函数

if (!isIE && typeof MutationObserver !== 'undefined' &&
    (isNative(MutationObserver) ||
    MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
    var counter = 1;
    var observer = new MutationObserver(flushCallbacks);
    var textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {
        characterData: true
    });
    timerFunc = function() {
        counter = (counter + 1) % 2;
        textNode.data = String(counter);
    };
    isUsingMicroTask = true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

MutationObserver() 创建并返回一个新的 MutationObserver 它会在指定的 DOM 发生变化时被调用,IE11浏览器才兼容,故干脆执行 !isIE 排除 IE浏览器。执行 typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) 判断,其原理在上面已介绍过了。执行 MutationObserver.toString() === '[object MutationObserverConstructor]') 这是对 PhantomJS 浏览器 和 iOS 7.x版本浏览器的支持情况进行判断。

执行 var observer = new MutationObserver(flushCallbacks),创建一个新的 MutationObserver 赋值给常量 observer, 并且把 flushCallbacks 作为回到函数传入,当 observer 指定的 DOM 要监听的属性发生变化时会调用 flushCallbacks 函数。

执行 var textNode = document.createTextNode(String(counter)) 创建一个文本节点。

执行 var counter = 1counter 做文本节点的内容。

执行 observer.observe(textNode, { characterData: true }),调用 MutationObserver 的实例方法 observe 去监听 textNode 文本节点的内容。

这里很巧妙利用 counter = (counter + 1) % 2 ,让 counter 在 1 和 0 之间变化。再执行 textNode.data = String(counter) 把变化的 counter 设置为文本节点的内容。这样 observer 会监测到它所观察的文本节点的内容发生变化,就会调用 flushCallbacks 函数,在其中会遍历去执行每个 nextTick 传入的函数,因 MutationObserver 是个微任务 (micro task)类型,故这些函数就变成异步执行了。

# setImmediate 创建异步执行函数

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = function() {
        setImmediate(flushCallbacks);
    };
} 
1
2
3
4
5

setImmediate 只兼容 IE10 以上浏览器,其他浏览器均不兼容。其是个宏任务 (macro task),消耗的资源比较小

# setTimeout 创建异步执行函数

timerFunc = function() {
    setTimeout(flushCallbacks, 0);
}
1
2
3

兼容 IE10 以下的浏览器,创建异步任务,其是个宏任务 (macro task),消耗资源较大

# 创建异步执行函数的顺序

Vue 历来版本中在 nextTick 函数中实现 timerFunc 的顺序时做了几次调整,直到 2.6+ 版本才稳定下来

第一版的 nextTick 函数中实现 timerFunc 的顺序为 PromiseMutationObserversetTimeout

在2.5.0版本中实现 timerFunc 的顺序改为 setImmediateMessageChannelsetTimeout。 在这个版本把创建微任务的方法都移除,原因是微任务优先级太高了,其中一个 issues 编号为 #6566, 情况如下:

<div class="header" v-if="expand"> // block 1
    <i @click="expand = false;">Expand is True</i> // element 1
</div>
<div class="expand" v-if="!expand" @click="expand = true;"> // block 2
    <i>Expand is False</i> // element 2
</div>
1
2
3
4
5
6

按正常逻辑 点击 element 1 时,会把 expand 置为 false,block 1 不会显示,而 block 2 会显示,在点击 block 2 ,会把 expand 置为 false,那么 block 1 会显示。

当时实际情况是 点击 element 1 ,只会显示 block 1。这是为什么,什么原因引起这个BUG。Vue 官方是这么解释的

点击事件是宏任务,<i>上的点击事件触发 nextTick(微任务)上的第一次更新。在事件冒泡到外部div之前处理微任务。在更新过程中,将向外部div添加一个click侦听器。因为DOM结构相同,所以外部div和内部元素都被重用。事件最终到达外部div,触发由第一次更新添加的侦听器,进而触发第二次更新。为了解决这个问题,您可以简单地给两个外部div不同的键,以强制在更新期间替换它们。这将阻止接收冒泡事件。

当然当时官方还是给出了解决方案,把 timerFunc 都改为用创建宏任务的方法实现,其顺序是 setImmediateMessageChannelsetTimeout,这样 nextTick 是个宏任务。

点击事件是个宏任务,当点击事件执行完后触发的 nextTick(宏任务)上的更新,只会在下一个事件循环中进行,这样其事件冒泡早已执行完毕。就不会出现 BUG 中的情况。

但是过不久,实现 timerFunc 的顺序又改为 PromiseMutationObserversetImmediatesetTimeout,在任何地方都使用宏任务会产生一些很奇妙的问题,其中代表 issue 编号为 #6813,代码就打出来,可以看这里 (opens new window)。 这里有两个关键的控制

  • 媒体查询,当页面宽度大于 1000px 时,li 显示类型为行内框,小于1000px时,显示类型为块级元素。
  • 监听页面缩放,当页面宽度小于 1000px 时,ul 用 v-show="showList" 控制隐藏。

初始状态:

img

当快速拖动网页边框缩小页面宽度时,会先显示下面第一张图,然后快速的隐藏,而不是直接隐藏。

img

img

那为出现这种BUG,首先要了解一个概念,UI Render (UI渲染)的执行时机,如下所示:

  1. macro 取一个宏任务。
  2. micro 清空微任务队列。
  3. 判断当前帧是否值得更新,否则重新进入1步骤
  4. 一帧欲绘制前,执行requestAnimationFrame队列任务。
  5. UI更新,执行 UI Render。
  6. 如果宏任务队列不为空,重新进入步骤

这个过程也比较好理解,之前执行监听窗口缩放是个宏任务,当窗口大小小于 1000px 时,showList 会变为 flase ,会触发一个 nextTick 执行,而其是个宏任务。在两个宏任务之间,会进行 UI Render ,这时,li 的行内框设置失效,展示为块级框,在之后的 nextTick 这个宏任务执行了,再一次 UI Render 时,ul 的 display 的值切换为 none,列表隐藏。

所以 Vue 觉得用微任务创建的 nextTick 可控性还可以,不像用宏任务创建的 nextTick 会出现不可控场景。

在 2.6 + 版本中采用一个时间戳来解决 #6566 这个BUG,设置一个变量 attachedTimestamp,在执行传入 nextTick 函数中的 flushSchedulerQueue 函数时,执行 currentFlushTimestamp = getNow() 获取一个时间戳赋值给变量 currentFlushTimestamp,然后再监听 DOM 上事件前做个劫持。其在 add 函数中实现。

function add(name, handler, capture, passive) {
    if (useMicrotaskFix) {
        var attachedTimestamp = currentFlushTimestamp;
        var original = handler;
        handler = original._wrapper = function(e) {
            if (
                e.target === e.currentTarget ||
                e.timeStamp >= attachedTimestamp ||
                e.timeStamp <= 0 ||
                e.target.ownerDocument !== document
            ) {
                return original.apply(this, arguments)
            }
        };
    }
    target.addEventListener(
        name,
        handler,
        supportsPassive ? {
            capture: capture,
            passive: passive
        } : capture
    );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

执行 if (useMicrotaskFix)useMicrotaskFix 在用微任务创建异步执行函数时置为 true

执行 var attachedTimestamp = currentFlushTimestamp 把 nextTick 回调函数执行时的时间戳赋值给变量 attachedTimestamp,然后执行 if(e.timeStamp >= attachedTimestamp),其中 e.timeStamp DOM 上的事件被触发时的时间戳大于 attachedTimestamp,这个事件才会被执行。

为什么呢,回到 #6566 BUG 中。由于micro task的执行优先级非常高,在 #6566 BUG 中比事件冒泡还要快,就会导致此 BUG 出现。当点击 i标签时触发冒泡事件比 nextTick 的执行还早,那么 e.timeStampattachedTimestamp 小,如果让冒泡事件执行,就会导致 #6566 BUG,所以只有冒泡事件的触发比 nextTick 的执行晚才会避免此 BUG,故 e.timeStampattachedTimestamp 大才能执行冒泡事件。

# 自定义

let cbs = [];
let pending = false;
function flushCallbacks() {
    cbs.forEach(fn=>fn());
}
function nextTick(fn) {
    cbs.push(fn);
    if (!pending) {
        pending = true;
        setTimeout(() => {
            flushCallbacks();
        }, 0);
    }
}
function render() {
    console.log('rerender');
};
nextTick(render)
nextTick(render)
nextTick(render);
console.log('sync...')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
上次更新: 2022/04/15, 05:41:28
×