javascript事件循环(浏览器/node)

为何要了解js中的事件循环

javascript是一种基于事件的单线程、异步、非阻塞编程语言,我常常在看书或者浏览别人博客的时候看到这种说法,但是以前一直没有深刻理解过,只知道javascript中常用各类回调函数,好比浏览器端的各类事件回调(点击事件、滚动事件等)、ajax请求回调、setTimeout回调以及react16的内核fiber中用到的requestAnimationFramepromise回调、nodefs模块异步读取文件内容回调、process模块的nextTick等等。最近有时间浏览了各类资料(后面有各类相关资料的连接),终于明白全部的这些内容其实都离不开javascript事件循环,而浏览器端的事件循环又与node端的事件循环有较大区别,下面分别介绍下。javascript

浏览器 vs node

自从有了nodejavascript既能够运行在浏览器端又能够运行在服务端,以chrome浏览器为例,相同点是都基于v8引擎,不一样的是浏览器端实现了页面渲染、而node端则提供了一些服务端会用到的特性,好比fsprocess等模块,同时node端为了实现跨平台,底层使用libuv来兼容linuxwindowsmacOS三大操做系统。所以虽然都实现了javascript的异步、非阻塞特性,可是却有有很多不一样之处。java

浏览器端

不管是在浏览器端仍是node端,线程入口都是一个javascript脚本文件,整个脚本文件从第一行开始到最后运行完成能够看做是一个entry task,即初始化任务,下图task中第一项即为该过程。初始化过程当中确定会注册很多异步事件,好比常见的setTimeoutonClickpromise等,这些异步事件执行中又有可能注册更多异步事件。全部的这些异步任务都是在事件循环一次次的循环中获得执行,而这些异步任务又能够分为两大类,即microtasktask(或macrotask)。那么一次事件循环中会执行多少个异步任务?microtasktask的执行前后顺序是什么呢?看下图。 node

浏览器端事件循环

先忽略图中的红色部分(渲染过程,后面再介绍),顺时针方向即为事件循环方向,能够看出每次循环会前后执行两类任务,taskmicrotask每一类任务都由一个队列组成,其中task主要包括以下几类任务:react

  1. index.js(entry)
  2. setTimeout
  3. setInterval
  4. 网络I/O

microtask主要包括:linux

  1. promise
  2. MutationObserver

所以microtask的执行事件结点是在两次task执行间隙。前面说了,每类任务都由一个队列组成,这实际上是一种生产者-消费者模型,事件的注册过程即为任务生产过程,任务的执行过程即为事件的消费过程。那么每次轮到一类任务执行各个队列会出队多少个任务来执行呢?图中我已经标明,task队列每次出队一项任务来执行,执行完成以后开始执行microtask;而microtask则每次都把全部(包括当前microtask执行过程当中新添加的任务)任务执行完成,而后才会继续执行task。也就是说,即使microtask是异步任务,也不能无节制的注册,不然会阻塞task页面渲染的执行。好比,下面的这段代码中的setTimeout回调任务将永远得不到执行(注意,谨慎运行这段代码,浏览器可能卡死):web

setTimeout(() => {
    console.log('run setTimout callback');
}, 0);

function promiseLoop() {
    console.log('run promise callback');
    return Promise.resolve().then(() => {
        return promiseLoop();
    });
}

promiseLoop();
复制代码

如今回过头来再看上图中的粉红色虚线部分,该过程表示的是浏览器渲染过程,好比dom元素的stylelayout以及position这些渲染,那为何用虚线表示呢?是由于该部分的调度是由浏览器控制,并且是以60HZ的频率调度,之因此是60HZ是为了能知足人眼视觉效果的同时尽可能低频的调度,若是浏览器一刻不停的频繁渲染,那么不只人眼观察不到界面的变化效果(就如同夏天电扇转太快人眼分辨不出来),并且耗费计算资源。所以上图中渲染过程用虚线表示不必定每次事件循环都会执行渲染过程。ajax

仔细看虚线框起来的渲染过程,能够看到在执行渲染以前能够执行一个回调函数requestAnimationFrame,执行渲染以后能够执行一个回调函数requestIdleCallback。使用这两个钩子函数注册的回调函数同task回调和microtask回调同样,会进入专属的事件队列,可是这两个钩子函数与setTimeout不同,不是为了在4ms,16ms或1s以后再执行,而是在下一次页面渲染阶段去执行,具体来讲是requestAnimationFramestylelayout计算以前执行,requestIdleCallback则是在变动真正渲染到页面后执行。算法

requestAnimationFramesetTimeout更适合作动画,这里有个例子能够参考:jsfiddle.net/H7EEE/245/。效果以下图所示,能够看出requestAnimationFramesetTimeout动画效果更加流畅。 chrome

requestIdleCallback则是在每一渲染贞后的空闲时间去完成回调任务,所以通常用于一些低优先级的 任务调度,好比 react16则使用了该钩子函数实现异步 reconcilation算法以保证页面性能,固然因为 requestIdleCallback是比较新的 APIreact团队实现了 pollyfill,注意是目前是使用 requestAnimationFrame实现的哦。
react16使用requestIdleCallback实现精细的调度算法

如今总结一下浏览器端的事件队列,共包括四个事件队列:task队列、requestAnimationFrame队列、requestIdleCallback队列以及microtask队列,javascript脚本加载完成后首先执行第一个task队列任务,即初始化任务,而后执行全部microtask队列任务,接着再次执行第二个task队列任务,以此类推,这其中穿插着60HZ渲染过程。先执行谁后执行谁如今了解清楚了,但是到每一个事件队列执行的轮次时,分别会有多少个事件出队执行呢?答案见下图(截图自Jake Archibald大神的JSConf演讲视频): shell

能够看出,在一次事件循环中: 普通task每次出队一项回调函数去执行,requestAnimationFrame每次出队全部当前队列的回调函数去执行(requestIdleCallback同样),microtask每次出队全部当前队列的回调函数以及本身轮次执行过程当中又新增到队尾的回调函数。这三种不一样的调度方式正好覆盖了全部场景。

实践一下

demo1: 对比index.jspromisesetTimeout的执行前后顺序
console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0);

new Promise(function (resolve) {
    console.log('promise1.1');
    resolve();
}).then(function () {
    console.log('promise1.2');
}).then(function () {
    console.log('promise1.3');
}).then(function () {
    console.log('promise1.4');
});

new Promise(function (resolve) {
    console.log('promise2.1');
    resolve();
}).then(function () {
    console.log('promise2.2');
}).then(function () {
    console.log('promise2.3');
}).then(function () {
    console.log('promise2.4');
});

console.log('script end');
复制代码

这段代码的输入以下:

script start
promise1.1
promise2.1
script end
promise1.2
promise2.2
promise1.3
promise2.3
promise1.4
promise2.4
setTimeout
复制代码

按照前面的事件循环示例图,按照以下顺序执行:

  1. 执行task(index.js); 这里包括四项输出:script startpromise1.1promise2.1script end。其中须要留意promise1.1promise2.1,由于new Promiseresolve()调用以前也是同步代码,所以也会同步执行。
  2. 执行microtask; 这里须要留意microtask会边执行边生成新的添加到事件队列队尾,所以执行完全部microtask才从新进入事件循环开始下一项。
  3. 执行task(setTimeout); 根据前面的示例图,这里又轮到了task的执行,只不过此次是setTimout

node端

前面介绍了下浏览器端的事件循环,涉及到taskmicrotask,其实node端的异步任务也包括这些,只不过node端的task划分的更细,以下图所示,node端的task能够分为4类任务队列:

  1. index.js(entry)、setTimeoutsetInterval
  2. 网络I/O、fs(disk)child_process
  3. setImmediate
  4. close事件

microtask包括:

  1. process.nextTick
  2. promise
    node端事件循环
    开始后会首先执行注册过的全部microtask,而后会依次执行该4类task队列。而每执行完一个task队列就会接着执行microtask队列,而后再接着执行下一个task队列。所以microtask队列的执行是穿插在各个类形的task之间的,固然也能够。 node端与浏览器端事件循环的一个很重要的不一样点是,浏览器task队列每轮事件循环仅出队一个回调函数去执行接着去执行microtask,而node端只要轮到执行task,则会跟执行完队列中的全部当前任务,可是当前轮次新添加到队尾的任务则会等到下一轮次才会执行,该机制与浏览器端的requestAnimationFrame的调度机制时同样的。 总结一下node端的事件循环,共包括4类task事件队列与2类microtask事件队列,microtask穿插在task之间执行。task每次轮到执行会将当前队列中的全部回调函数出队执行,而microtask的调度机制则与浏览器端同样,每次轮到执行都会出队全部当前队列的回调函数以及本身轮次执行过程当中又新增到队尾的回调函数去执行。与浏览器端不同的是node端的microtask包括process.nextTickpromise两类。

实践一下

demo1: 对比promise与setTimeout的执行顺序
console.log('main');
setTimeout(function () {
    console.log('execute in first timeout');
    Promise.resolve(3).then(res => {
        console.log('execute in third promise');
    });
}, 0);
setTimeout(function () {
    console.log('execute in second timeout');
    Promise.resolve(4).then(res => {
        console.log('execute in fourth promise');
    });
}, 0);
Promise.resolve(1).then(res => {
    console.log('execute in first promise');
});
Promise.resolve(2).then(res => {
    console.log('execute in second promise');
});
复制代码

前面这段代码的输出结果以下:

main
execute in first promise
execute in second promise
execute in first timeout
execute in second timeout
execute in third promise
execute in fourth promise
复制代码

执行顺序以下:

  1. index.js(主程序代码main);
  2. microtask(promise1, promise2);
  3. task(setTimeout1, setTimeout2);
  4. microtask(promise3, promise4);

这个执行顺序与以前画的图彻底对应。

demo2: 对比index.jspromiseasync awaitsetTimeout的执行前后顺序
console.log('script start');

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}

async function async2() {
    console.log('entry async2');
    return Promise.resolve();
}

setTimeout(function () {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
    console.log('promise1');
    resolve();
}).then(function () {
    console.log('promise2');
}).then(function () {
    console.log('promise3');
}).then(function () {
    console.log('promise4');
}).then(function () {
    console.log('promise5');
}).then(function () {
    console.log('promise6');
});

console.log('script end');
复制代码

这段代码在node10环境的执行结果以下:

script start
async1 start
entry async2
promise1
script end
promise2
promise3
promise4
async1 end
promise5
promise6
setTimeout
复制代码

注意我这里强调了是node10环境,是由于node8node9下面async awaitbug,而node10中获得了修复,详情能够参考这篇文章:Faster async functions and promises。下面按照前面的事件循环示例图分析下前面这段代码的执行结果:

  1. 执行task(index.js); 这里包括5项输出:script startasync1 startentry async2promise1script end。这里要注意async函数中第一个await以前执行的代码也是同步代码,所以会打印出scync1 start以及entry async2
  2. 执行microtask; 这里打印了全部剩下的promise以及一个位于await后的语句async1 end打印这个集合确定是没问题的,可是问题是为何async1 end会比promise延迟3个呢? 这个问题是这段代码最难懂的地方,答案在刚刚提到的那篇文章中:每一个await须要至少3个microtask queue ticks,所以这里async1 end的打印相对于promise晚打印了3个tick。其实经过这里例子咱们也应该的出一个结论,就是最要不要把promiseasync await混用,不然容易时序混乱。
  3. 执行task(setTimeout)。 根据前面的示例图,这里又轮到了task的执行,只不过此次是setTimout。 从demo2能够看出,虽然async await本质上也是microtask,可是每一个await会耗费至少3个microtask queue ticks,这点须要注意。

引用

本篇总结主要参考了以下资源,强烈推荐浏览阅读:

  1. Jake Archibald: In The Loop - JSConf.Asia 2018
  2. Philip Roberts: What the heck is the event loop anyway? - JSConf.EU 2014
  3. Everything You Need to Know About Node.js Event Loop - Bert Belder, IBM
  4. Event Loop and the Big Picture — NodeJS Event Loop Part 1
  5. Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2
  6. Promises, Next-Ticks and Immediates— NodeJS Event Loop Part 3
  7. Handling IO — NodeJS Event Loop Part 4
  8. Event Loop Best Practices — NodeJS Event Loop Part 5
  9. Using requestIdleCallback
相关文章
相关标签/搜索