事件循环与 Vue 的 nextTick

事件循环

js 语言是单线程的,为了协调事件、脚本、用户交互、UI 渲染和网络请求(events, scripts, user interaction, rendering, networking)等行为,防止主线程阻塞,js 引入了事件循环这个概念。

Event Loop 有多种类型,按线程来分的话,可以分为 window event loop 和 worker event loop。每个 JavaScript 线程都有一个独立的 Event Loop,所以不同的 Worker 有不同的 Event Loop,它们都是独立运行的。

按照运行环境来分的话,Event Loop 大致可以分为浏览器环境中的event loop和 node.js 环境中event loop。它们对event loop的实现方式不同,并不一定遵循WHATWG标准,相对来说,浏览器环境中的event loop更加符合标准。

一些基本概念:

  1. 执行上下文:JS 引擎在执行全局 JS 代码,函数或者 eval 语句时,会生成一个执行上下文对象,它里面有变量对象(VO)、作用域链(scope)、this 等属性;
  2. 函数调用栈:调用函数时,JS 引擎会将函数的执行上下文push到函数调用栈中,等它执行完毕就会pop出栈;

浏览器环境

首先看标准——WHATWG HTML标准

An event loop has one or more task queues. A task queue is a set of tasks.

一个event loop中有一个或多个任务队列。task queuetask的集合。

A source: One of the task sources, used to group and serialize related tasks

task source任务源对任务进行分组和序列化。

Per its source field, each task is defined as coming from a specific task source. For each event loop, every task source must be associated with a specific task queue.

每个task都来自于一个特殊的task source。对于每个事件循环,每个任务源都与一个特殊的任务队列相关联。
主要的task source有:

  1. tasks: script(整体代码), setTimeout, setInterval, setImmediate(ie, node.js), I/O, MessageChannel;
  2. microtask: Promise, MutationObserve, process.nextTick(node.js)

这些task source可以分发task 或者 microtask,比如Promise的三个原型方法thencatchfinally的回调函数就是真正的microtask

继续看标准:

Each event loop has a currently running task, which is either a task or null. Initially, this is null. It is used to handle reentrancy.

每个事件循环都有一个正在执行的 task,不过也可以为 null

Each event loop has a microtask queue, which is a queue of microtasks, initially empty. A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.

每个事件循环都有一个微任务队列,注意是一个,不是多个。当然这只是标准,不是实现node.js 环境中就不止一个microtask queue
更多内容请直接阅读标准 —— WHATWG HTML标准

事件循环的流程:

在浏览器环境中:

  1. 首次事件循环,执行脚本整体代码,将全局上下文压入 call stack 中执行。执行过程中遇到的同步任务直接压入 call stack 执行,遇到的异步任务则由浏览器后台其他线程处理,待异步任务满足条件,就放入microtask queue中。
  2. 等此次事件循环的task执行完毕(即 call stack 只剩全局上下文),则开始执行microtask queue中的所有微任务。一般来说,这些microtask按照microtask queue中的顺序执行。一旦轮到某个microtask,就将其执行上下文压入call stack中执行,执行过程中遇到同步和异步任务时,处理方式同第一步。待call stack中的栈帧清空,就表示这个microtask执行完毕,开始执行microtask queue中的下一个microtask
  3. 执行完所有的微任务后,浏览器会判断是否需要进行 UI 渲染。如需要,则渲染;不需要,则进入下一次事件循环。
  4. 第二次事件循环,取出某个task queue中的队首task,压入call stack 中执行,……
Promise.resolve(2).then(v => {
    console.log('Promise1')
    Promise.resolve(2).then(v => console.log('Promise1 触发的 Promise2'))
})
setTimeout(_ => console.log('setTimeout'))

输出为:Promise1 => Promise1 触发的 Promise2 => setTimeout
这是因为在第一次事件循环中,执行Promise1时触发了Promise2Promise2被加入了microtask queue中,其后会在此次事件循环就执行掉。

node.js 环境

参考链接:

  1. 剖析nodejs的事件循环
  2. Node.js 事件循环,定时器和 process.nextTick

在 node.js 中每次事件循环都包含 6 个阶段:

   ┌───────────────────────────┐
┌─>│          `timers`         │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │    `pending callbacks`    │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     `idle`, `prepare`     │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │          `poll`           │<─────┤ `connections`,│
│  └─────────────┬─────────────┘      │  `data`, etc. │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │          `check`          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤     `close callbacks`     │
   └───────────────────────────┘

每个阶段都有一个 FIFO 队列来存储待执行的回调。
通常来说,当事件循环进入某一阶段时,它将执行该阶段的一些特定操作,然后执行该阶段队列中的回调,直到队列用尽,或者达到当前阶段回调可调用的最大次数。然后会执行所有的process.nextTick回调,再然后执行所有的microtask,最终事件循环进入下一阶段。
node.jstaskmicrotask的任务源:

  1. task:timer(setTimeoutsetInterval), setImmediate, I/O
  2. microtask:process.nextTick, promise

node.js 实现了 libuv(用来实现 Node.js 事件循环和平台所有异步行为的 C 函数库),而 node.js 事件循环机制就是在它里面实现的,事件循环核心代码(C语言):

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
    int timeout;
    int r;
    int ran_pending;
    r = uv__loop_alive(loop);    //事件循环是否存活
    if (!r)
        uv__update_time(loop);    //更新 loop->time 为当前时间
    //如果事件循环存活,并且事件循环没有停止
    while (r != 0 && loop->stop_flag == 0) {
        uv__update_time(loop);
        // timers 阶段
        uv__run_timers(loop);
        // pending callbacks 阶段
        ran_pending = uv__run_pending(loop);
        // idle 阶段
        uv__run_idle(loop);
        // prepare 阶段
        uv__run_prepare(loop);

        timeout = 0;
        if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
            //计算当前时间和最近的 timer 的超时时间之间的时间差 timeout
            timeout = uv_backend_timeout(loop);
        // poll 阶段,该阶段轮询 I/O 事件,有则执行,无则阻塞,直到时间超过 timeout
        uv__io_poll(loop, timeout);
        // check 阶段
        uv__run_check(loop);
        // close callbacks 阶段
        uv__run_closing_handles(loop);

        if (mode == UV_RUN_ONCE) {
            uv__update_time(loop);
            uv__run_timers(loop);
        }
        
        r = uv__loop_alive(loop);
        //如果事件循环目前没有等待任何异步 I/O 或计时器,退出循环
        if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
            break;
    }

    if (loop->stop_flag != 0)
        loop->stop_flag = 0;

    return r;
}

timers 阶段

此阶段执行已达到等待时间的 timersetTimeoutsetInterval)的回调函数。uv__run_timers的执行时间由poll阶段控制。

void uv__run_timers(uv_loop_t* loop) {
    struct heap_node* heap_node;
    uv_timer_t* handle;

    for (;;) {
        //取出 timer_heap 中 `超时时间最近` 的定时器 heap_node
        heap_node = heap_min((struct heap*) &loop->timer_heap);
        if (heap_node == NULL)
            break;
        //获取 heap_node 的句柄
        handle = container_of(heap_node, uv_timer_t, heap_node);
        //判断最近的定时器的句柄 handle 的超时时间是否大于当前时间,如果大于当前时间,说明还未超时,跳出循环
        if (handle->timeout > loop->time)
            break;
        //停止定时器 
        uv_timer_stop(handle);
        //如果 handle->repeat 为 true,重启定时器
        uv_timer_again(handle);
        //执行定时器的回调函数
        handle->timer_cb(handle);
    }
}

循环取出 &loop->timer_heap 中的定时器,执行它的回调函数,直到当前定时器为NULL或者没有超时为止。从这可以初步看出,node.js环境在事件循环某一阶段,会一次性执行完对应的任务队列中所有满足条件的task

至于process.nextTick为何没有出现在流程图中,node.js 文档给出的解释是:

You may have noticed that process.nextTick() was not displayed in the diagram, even though it’s a part of the asynchronous API. This is because process.nextTick()

is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of

the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

大意是:process.nextTick在技术上不是事件循环的一部分,但是在事件循环的每个阶段中,等当前操作完成后就会执行nextTickQueue中的任务。具体原因得看 node.js 是怎么实现process.nextTick的。

至于更后面的microtask(一般是promise回调),它们会在每个阶段的最后执行。
promise回调的具体执行时间得看 import 的 Promise 是怎么实现的,在node\deps\npm\node_modules\es6-promiseasap.js中,有多种调用回调的方式:

//1、**node**
function useNextTick() {
    // node version 0.10.x displays a deprecation warning when nextTick is used recursively
    // see https://github.com/cujojs/when/issues/410 for details
    return () => process.nextTick(flush);
}
//2、vertx
function useVertxTimer() {
    if (typeof vertxNext !== 'undefined') {
        return function() {
            vertxNext(flush);
        };
    }

    return useSetTimeout();
}
//3、浏览器
function useMutationObserver() {
    let iterations = 0;
    const observer = new BrowserMutationObserver(flush);
    const node = document.createTextNode('');
    observer.observe(node, { characterData: true });

    return () => {
        node.data = (iterations = ++iterations % 2);
    };
}

大部分是用microtask来实现Promise的,后面还有一些用setTimeouttask来实现的。
我们主要关注 node.js 环境,如果用户是用es6-promise提供的Promise对象的话,该对象绑定的回调函数最终会在process.nextTick的回调中被调用,所以,promise回调也是在事件循环每个阶段的末尾执行的。

pending callbacks 阶段

此阶段执行pending_queue中的I/O回调函数(上个循环未执行完,并被延迟到这个循环的I/O回调)。

  1. 非I/O:定时器 (setTimeoutsetInterval), microtask, process.nextTick, setImmediate
  2. I/O:网络I/O,文件I/O,一些DNS操作…

之后的 idle, prepare 阶段仅在系统内部使用,不用了解。

poll 阶段

node.js 会将非阻塞 I/O 操作转移到系统内核中去,一旦操作完成,内核通过事件通知 Node.js 将适合的回调函数添加到poll queue中等待时机执行。
uv_run函数中,调用uv__io_poll(loop, timeout)进入poll阶段时,传入了timeout参数,它是当前时间距离最近定时器阈值的时间,也是poll阶段的阻塞时间。
在内核监听到事件通知node时,如果时间达到timeout,则直接退出poll阶段。
poll阶段执行的操作:

  1. 如果poll queue不为空,循环执行回调队列中的回调函数,直到队列用尽,或者达到了最大调用数。
  2. 如果poll queue是空的:

    1. 如果setImmediate task已经加入队列,则事件循环将结束poll阶段,进入check阶段。
    2. 如果setImmediate task尚未加入队列,则事件循环将等待I/O回调被添加到poll queue中,然后立即执行。

check 阶段

此阶段执行setImmediate的回调函数。setImmediate实际上是在事件循环的特定阶段运行的特殊计时器,它的回调在poll阶段完成后执行。

close callbacks 阶段

此阶段执行socket close事件的句柄函数。如果sockethandle突然关闭(例如 socket.destroy()),则close事件将在这个阶段发出,否则它将通过process.nextTick()发出。

Vue.js 中的 nextTick

不同版本的 Vue.js 中的 nextTick的实现方式不尽相同,在 Vue 2.5+ 后,单独有一个JS文件来实现nextTick,我们直接看目前的最新稳定版本:v2.6.10

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

export let isUsingMicroTask = false

const callbacks = []
let pending = false

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

// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc

// 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 next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
        // 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)
    }
    isUsingMicroTask = true
} else if (!isIE && 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, iOS7, Android 4.4
    // (#6466 MutationObserver is unreliable in IE11)
    let counter = 1
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true
    })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
    }
    isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    // Fallback to setImmediate.
    // Techinically it leverages the (macro) task queue,
    // but it is still a better choice than setTimeout.
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    // Fallback to setTimeout.
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

export function nextTick (cb?: Function, ctx?: Object) {
    let _resolve
    callbacks.push(() => {
        if (cb) {
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    if (!pending) {
        pending = true
        timerFunc()
    }
    // $flow-disable-line
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}
  1. 简单来说,首先,Vue 用一个 callbacks 数组存放待执行的 callback 函数,每当使用 Vue.nextTick 或者 vm.$nextTick 时,就会将callback pushcallbacks数组中。
  2. 接下来, Vue 声明了一个 flushCallbacks 函数,这个函数会取出(清空) callbacks 数组中所有的 callback 函数并执行。
  3. 然后 Vue 会尝试把 flushCallbacks 变成一个 microtask 或者 task 来执行。具体是 microtask 还是 task 得看 Vue 当前运行在什么环境:

大致判断流程如下:

  1. 当前环境有提供原生的 Promise ? Promise.resolve().then(flushCallbacks) :
  2. 是 ie 环境 ? setImmediate(flushCallbacks) :
  3. 有提供原生的 MutationObserver ? new MutationObserver(flushCallbacks) :
  4. setTimeout(flushCallbacks, 0);
    原文作者:宗介
    原文地址: https://segmentfault.com/a/1190000020329323
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞