🚩Vue源码——nextTick实现原理

16,814 阅读7分钟

前言

在上一篇专栏讲到订阅者的响应是先把订阅者添加到一个队列,然后再 nextTick 函数中去遍历这个队列,对每个订阅者进行响应处理。大家所熟悉的 Vue API Vue.nextTick 全局方法和 vm.$nextTick 实例方法的内部都是调用 nextTick 函数,该函数的作用可以理解为异步执行传入的函数。

一、Vue.nextTick 内部逻辑

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

function initGlobalAPI(Vue) {
    //...
    Vue.nextTick = nextTick;
}

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

二、vm.$nextTick 内部逻辑

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

可以看出是 vm.$nextTick 内部也是调用 nextTick 函数。

三、前置知识

nextTick 函数的作用可以理解为异步执行传入的函数,这里先介绍一下什么是异步执行,从 JS 运行机制说起。

1、JS 运行机制

JS 的执行是单线程的,所谓的单线程就是事件任务要排队执行,前一个任务结束,才会执行后一个任务,这就是同步任务,为了避免前一个任务执行了很长时间还没结束,那下一个任务就不能执行的情况,引入了异步任务的概念。JS 运行机制简单来说可以按以下几个步骤。

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  • 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,会把其回调函数作为一个任务添加到任务队列中。
  • 一旦执行栈中的所有同步任务执行完毕,就会读取任务队列,看看里面有那些任务,将其添加到执行栈,开始执行。
  • 主线程不断重复上面的第三步。也就是常说的事件循环(Event Loop)。

2、异步任务的类型

nextTick 函数异步执行传入的函数,是一个异步任务。异步任务分为两种类型。

主线程的执行过程就是一个 tick,而所有的异步任务都是通过任务队列来一一执行。任务队列中存放的是一个个的任务(task)。规范中规定 task 分为两大类,分别是宏任务(macro task)和微任务 (micro task),并且每个 macro task 结束后,都要清空所有的 micro task。

用一段代码形象介绍 task的执行顺序。

for (macroTask of macroTaskQueue) {
    handleMacroTask();
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在浏览器环境中, 常见的创建 macro task 的方法有

  • setTimeout、setInterval、postMessage、MessageChannel(队列优先于setTimeiout执行)
  • 网络请求IO
  • 页面交互:DOM、鼠标、键盘、滚动事件
  • 页面渲染 常见的创建 micro task 的方法
  • Promise.then
  • MutationObserve
  • process.nexttick

nextTick 函数要利用这些方法把通过参数 cb 传入的函数处理成异步任务。

三、 nextTick 函数

var callbacks = [];
var pending = false;
function nextTick(cb, ctx) {
    var _resolve;
    callbacks.push(function() {
        if (cb) {
            try {
                cb.call(ctx);
            } catch (e) {
                handleError(e, ctx, 'nextTick');
            }
        } else if (_resolve) {
            _resolve(ctx);
        }
    });
    if (!pending) {
        pending = true;
        timerFunc();
    }
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(function(resolve) {
            _resolve = resolve;
        })
    }
}

可以看到在 nextTick 函数中把通过参数 cb 传入的函数,做一下包装然后 push 到 callbacks 数组中。

然后用变量 pending 来保证执行一个事件循环中只执行一次 timerFunc()

最后执行 if (!cb && typeof Promise !== 'undefined'),判断参数 cb 不存在且浏览器支持 Promise,则返回一个 Promise 类实例化对象。例如 nextTick().then(() => {}),当 _resolve 函数执行,就会执行 then 的逻辑中。

来看一下 timerFunc 函数的定义,先只看用 Promise 创建一个异步执行的 ztimerFunc 函数 。

var timerFunc;
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    timerFunc = function() {
        p.then(flushCallbacks);
        if (isIOS) {
            setTimeout(noop);
        }
    };
}

在其中发现 timerFunc 函数就是用各种异步执行的方法调用 flushCallbacks 函数。

来看一下flushCallbacks 函数

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

执行 pending = false 使下个事件循环中能nextTick 函数中调用 timerFunc 函数。

执行 var copies = callbacks.slice(0);callbacks.length = 0; 把要异步执行的函数集合 callbacks 克隆到常量 copies,然后把 callbacks 清空。

然后遍历 copies 执行每一项函数。回到 nextTick 中是把通过参数 cb 传入的函数包装后 push 到 callbacks 集合中。来看一下怎么包装的。

function() {
    if (cb) {
        try {
            cb.call(ctx);
        } catch (e) {
            handleError(e, ctx, 'nextTick');
        }
    } else if (_resolve) {
        _resolve(ctx);
    }
}

逻辑很简单。若参数 cb 有值。在 try 语句中执行 cb.call(ctx) ,参数 ctx 是传入函数的参数。 如果执行失败执行 handleError(e, ctx, 'nextTick')

若参数 cb 没有值。执行 _resolve(ctx),因为在nextTick 函数中如何参数 cb 没有值,会返回一个 Promise 类实例化对象,那么执行 _resolve(ctx),就会执行 then 的逻辑中。

到这里 nextTice 函数的主线逻辑就很清楚了。定义一个变量 callbacks,把通过参数 cb 传入的函数用一个函数包装一下,在这个中会执行传入的函数,及处理执行失败和参数 cb 不存在的场景,然后 添加到 callbacks。调用 timerFunc 函数,在其中遍历 callbacks 执行每个函数,因为 timerFunc 是一个异步执行的函数,且定义一个变量 pending来保证一个事件循环中只调用一次 timerFunc 函数。这样就实现了 nextTice 函数异步执行传入的函数的作用了。

那么其中的关键还是怎么定义 timerFunc 函数。因为在各浏览器下对创建异步执行函数的方法各不相同,要做兼容处理,下面来介绍一下各种方法。

1、Promise 创建异步执行函数

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    timerFunc = function() {
        p.then(flushCallbacks);
        if (isIOS) {
            setTimeout(noop);
        }
    };
    isUsingMicroTask = true;
}

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

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

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

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

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 浏览器下添加空的计时器强制刷新微任务队列。

2、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;
}

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)类型,故这些函数就变成异步执行了。

3、setImmediate 创建异步执行函数

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

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

4、setTimeout 创建异步执行函数

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

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

5、创建异步执行函数的顺序

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>

按正常逻辑 点击 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,代码就打出来,可以看这里。 这里有两个关键的控制

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

初始状态:

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

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

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

这个过程也比较好理解,之前执行监听窗口缩放是个宏任务,当窗口大小小于 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
    );
}

执行 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 大才能执行冒泡事件。