Event Loop事件循环机制

Event Loop事件循环机制

一、JS单线程执行却不堵塞的秘密

在开始之前,我们先看一张图:

EventLoop01

此图为事件在浏览器中的角色,不用看太久,有个概念就好,等你读完这篇文章,就能看懂这张图了。

首先要声明,事件循环是与 js 的运行环境相关的机制,它与 js (引擎) 本身无关。常见的运行环境有浏览器、Node.js,每种运行环境可能都有自己实现事件循环的方式,而本文只探讨浏览器的时间循环。

为什么要进行事件循环?因为 js 是单线程执行的,一个函数运行过久就会卡住后面的函数执行,如果这些函数恰好是做与 UI rendering 相关的事,那么画面便会延迟更新,这对使用者来说是非常不友好的。

深入点说,js 内部有个堆栈是用来执行任务的,这个堆栈又称执行上下文,是用来追踪程序所执行的函数。每当程序调用一个函数,这个函数所产生的执行上下文便会被压入(push)栈中;当这个函数被执行完,便会被弹出(pop)。函数的执行顺序是“后进先出”的模式。

我们常用的AJAX,计时器 setTimeout,与动画密切相关的 requestAnimationFrame,还有提升网页性能的requestIdleCallback……他们统称为 Web APIs。

接下来我们就来研究是谁跳出回调并将回调塞进执行栈的?

事件循环的基本概念

同样,在进到事件循环的流程前,先来看看一张图:

EventLoop2

事件循环的运作流程。

看得懂在干嘛吗?看不懂?没关系,继续看下去,一切都会豁然开朗。

为什么要进行事件循环?

为了协调事件,使用者互动,脚本,渲染和网络活动等,使用者代理必须使用本节所描述的事件循环,每个代理都有一个相关且唯一的事件循环。

接下来,根据规范不同代理对事件循环做了三种分类:window event loop、worker event loop、worklet event loop。第一个代理是我们最经常碰到的 Window 物件;第二个代理告诉我们 service worker 会有自己的事件循环;第三个代理我不知道是什么,worklet 似乎是一个实验性的 API。

一个事件循环会有一到多个任务队列(task queues)

每个任务(task)都来自特定的任务源(task source)。每个任务源都必须对应一个特定的任务队列。

这两段话出现了三个专有名词:任务、任务源、任务队列。

首先任务就是宏任务,他可以干很多事情,包括发布 Event物件、解析HTML、调用回调、获取资源、响应dom操作。

任务源也有很多,但大都被归类为以下四种:

  • DOM操作
  • 使用者互动:比如键盘输入或鼠标点击
  • 网络活动
  • 历史记录访问:比如 history.back() API

那任务队列又是干什么的呢?它是用来分类任务源的;换而言之,不同任务源可能会被塞进统一任务队列。但为什么要对任务源进一步分类?这个例子说明的很清楚:

例如使用者代理可以将任务队列分为鼠标和键盘事件(即使用者互动任务源)用的,和其他任务源用的。为此,使用者代理可以在事件循环的处理模型的初始步骤中,给使用者互动任务队列关联的任务队列四分之三的优先处理权,已让界面保持响应,但又不会卡死其他任务队列。

换句话说,说到任务的顺序时,并没有谁先触发就谁先执行的道理,这一切都要看你的浏览器如何操作。

最后再将一件事:

微任务队列(microtask queque)不是任务队列。

微任务是一种通俗的说法,指的是通过 queque a microtask 算法创建的任务。

微任务虽然不是一种任务,但它所形成的队列与任务队列不同,而这是因为它们在成为队列时所使用的的算法不同。

事件循环是怎么运作的?

只要事件循环存在,便必须一直运行以下步骤:

  1. 挑出一个任务队列,要怎么挑由使用者代理决定。如果没有任务队列,那就直接跳到微任务步骤。

  2. 将 oldestask 设为该任务队列中第一个任务,并移除。

  3. 将当前正在运行的任务设为 oldstTask。

  4. 运行 oldstTask

  5. 执行完后,将当前正在运行的任务设为 null。

  6. 开始处理微任务。

    1. 首先检查 performing a microtask checkpoint 这个 flag。(为什么要这么做?因为微任务检查并不只有在事件循环的这个环节才触发,其他触发时机比如回调;为了避免在微任务队列时重复施行微任务检查,因此需要一个 flag 来控制)
    2. 将 oldestMicrotask 设为对微任务队列出列的结果
    3. 将当前正在运行的任务设为 oldestMicrotask
    4. 运行 oldestMicrotask
    5. 将当前正在运行的任务设回 null
    6. 检查微任务队列是否为空,若不为空,便执行第一个微任务。重复这个过程,直到所有微任务都执行完毕。
  7. 将 hasARenderingOpportunity 设为 false

    从字面上理解,hasARenderingOpportunity 便是“是否有渲染机会”,一开始是 false,它将跟更新画面(

    Update the rendering)有关。

  8. 将 now 设为 current high resolution time。

    这里的 now 将成为 requestAnimationFrame 回调的第一个参数。

  9. 更新画面

    要怎么决定有无渲染机会?如果使用者代理当前能够将浏览器上下文内容呈现给使用者,那该浏览上下文就有渲染机会,这需要考虑硬件刷新率的限制和使用者代理出于性能考量的节流,也需要考虑内容的可呈现性,即使它在视窗之外。

    简单点来说,不是每轮事件循环都会更新画面,其频率高低,全看浏览器怎么操作。

  10. 事件循环的更新画面结束后,会根据以下几个条件:

    1. 事件循环是 window event loop
    2. 在任务队列中没有任何任务
    3. 微任务队列为空
    4. hasARenderingOpportunity 为 false

    若条件都满足,requestIdleCallback 的回调将在这一步执行。

    这样一轮事件循环就跑完了。

从案例了解事件循环

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

Promise.resolve()
  .then(function onFulfill1() {
    console.log('promise1');
  })
  .then(function onFulfill2() {
    console.log('promise2');
  });

console.log('main');

logSomething();

function logSomething() {
  console.log('something');
}

当程序执行,会发生什么事?

  1. 建立一个全局执行上下文(global execution context),压入执行栈。
  2. 遇到setTimeout ,有 Web API 接管计时器的处理,事件一到便把 onTimeout 放入(宏)任务队列
  3. 遇到 Promise.then() ,将 onFulfill1 放入微任务队列
  4. 在控制台(console)输出 main
  5. 调用 logSomething,将其函数执行上下文压入执行栈
  6. 打印 something
  7. logSomething 执行完毕,出栈
  8. 程序执行完毕,全局上下文弹出。
  9. 施行微任务检查:微任务不为空,出列。
  10. 调用 onFulfill1,将其函数的执行上下文 放进微任务队列。
  11. 微任务不为空,出列
  12. 调用 onFulfill2,将其函数的执行上下文压入执行栈
  13. 打印 promise2
  14. onFulfill2 执行完毕,弹出。
  15. 微任务队列为空,微任务检查完毕
  16. 执行栈为空,事件循环从任务队列取出最旧的任务
  17. 调用 onTimeout,将其函数执行上下文压入执行栈,
  18. 打印 timeout
  19. onTimeout执行完毕,弹出。

再来看一个 例子

var btn = document.getElementById('btn');

btn.addEventListener('click', function onClick() {
  setTimeout(function onTimeout() {
    console.log('timeout');

    requestIdleCallback(function onIdle2() {
      console.log('idle2');
    });
  }, 0);

  Promise.resolve().then(function onFulfill1() {
    console.log('promise1');
  });

  requestAnimationFrame(function onAf() {
    console.log('raf');

    Promise.resolve().then(function onFulfill2() {
      console.log('promise2');
    });
  });

  requestIdleCallback(function onIdle1() {
    console.log('idle1');
  });
});

当你点击按钮,控制台会打印什么?

答案不只一种,在不同浏览器或同一浏览器的不同时间,可能出现不同结果。若只看最常出现的结果,Chrome 89.0.4389.90 是:

promise
raf
promise2
timeout
idle1
idle2

Firefox 87.0 是:

promise1
timeout
raf
promise2
idle1
idle2

或是:

promise1
timeout
raf
promise2
idle1
idle2

但无论是哪种结果,都能被事件循环的处理模型解释;换而言之,差异并不来自规范失败,而是来自规范赋予浏览器的弹性。

  1. click 事件已由 Web API 接手处理,将其回调放入任务队列
  2. 调用 onClick,将其函数执行上下文压入执行栈
  3. 遇到 setTimeout,由 Web API 接管计时器的处理,时间一到便把 onTimeout 放入任务队列
  4. 遇到 requestAnimationFrame,将其回调 onAf 放入 map of animation frame callbacks
  5. 遇到 requestIdleCallback,将其回调 onIdle1 放入 list of idle request callbacks
  6. onClick 执行完毕,弹出
  7. 施行微任务检查,微任务队列不为空,出列
  8. 调用 onFulfill,将其函数式执行上下文压入执行栈
  9. 印出 promise1
  10. onFulfill 执行完毕,出栈

接下来,就要发生分歧了:究竟是先执行计时器的回调 onTimeout,还是 requestAnimationFrame 的回调 onAf?要看此时 onTimeout 有没有被放入任务队列。

Chrome 的情况是:

  1. 执行栈为空,由于没有合格的任务队列,且微任务队列为空,事件循环进到更新页面步骤。
  2. 运行 animation frame callbacks
  3. 调用 onAf,将其函数的执行上下文压入执行栈
  4. 打印 raf
  5. 遇到 Promise.then(),将 onFulfill2 放入为任务队列
  6. onAf 执行完毕,出栈
  7. 开始微任务检查,微任务队列不为空,出列
  8. 调用 onFulfill2,将其函数的执行上下文压入执行栈
  9. 打印 promise2
  10. onFulfill2 执行完毕,出栈
  11. 由于 hasARenderingOpportunity 为 true,或任务队列中有任务,跳过 start an idle period algorithm
  12. 执行栈为空,事件循环从任务队列取出最旧的任务
  13. 打印 timeout
  14. 遇到 requestIDCallback,将其回调 onIdle2 放入 list of idle request callbacks
  15. onTimeout 执行完毕,出栈
  16. start an idle period algorithm,调用 onIdle1,将其函数的执行上下文压入执行栈
  17. 打印 idle1
  18. onIdle1 执行完毕,出栈
  19. 调用 onIdle2,将其函数执行上下文压入执行栈
  20. 打印 idle2
  21. onIdle2 执行完毕,出栈

Firefox 的情况呢,我就不讲了,可自行推导。

我只讲 Firefox 的第二种结果——onIdle1 怎么会在 onTimeout 之后执行?一个合理的猜测是:在这轮事件循环,用户代理跳过了更新画面,而且在 Rendering opportunities 这步就把本来要被更新画面的 Document 移除了,这样 hasARenderingOpportunity 才不会被设为 true,requestIdleCallback 的回调也才有办法被执行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值