事件循环与-Vue-的-nextTick

60次阅读

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

事件循环

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);

正文完
 0