关于vue.js:vue源码中的nextTick是怎样实现的

32次阅读

共计 8292 个字符,预计需要花费 21 分钟才能阅读完成。

一、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 函数。参考 Vue3 源码视频解说:进入学习

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

正文完
 0