JavaScript 异步编程指南 - 探索浏览器中的事件循环机制

开发 前端
当我了解事件循环时,尝试去找一些规范来学习,但是查遍 EcmaScript 或 V8 发现它们没有这个东西的定义,例如,在 v8 里有的是执行栈、堆这些信息。确实,事件循环不在这里。

[[429067]]

当我了解事件循环时,尝试去找一些规范来学习,但是查遍 EcmaScript 或 V8 发现它们没有这个东西的定义,例如,在 v8 里有的是执行栈、堆这些信息。确实,事件循环不在这里。

后来才逐渐的了解到,当在浏览器环境中,关于事件循环相关定义是在 HTML 标准中,之前 HTML 规范由 whatwg 和 w3c 制定,两个组织都有自己的不同,2019 年时两个组织签署了一项协议 就 HTML 和 DOM 的单一版本进行合作,最终,HTML、DOM 标准最终由 whatwg 维护。

本文的讲解主要也是以 whatwg 标准为主,在 HTML Living Standard Event loops 中,这个规范定义了浏览器内核该如何的去实现它。

浏览器规范中的事件循环

事件循环定义

为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节描述的事件循环。每个代理有一个关联的事件循环,它对每个代理是唯一的。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

从这个定义也可看出,事件循环主要是用来协调事件、网络、JavaScript 等之间的一个运行机制,我们以 JavaScript 为出发点来看下它们之间是如何交互的。

事件循环中有一个重要的概念任务队列,它决定了任务的执行顺序。

事件循环的处理模式

规范 8.1.6.3 处理模型 定义了事件循环的处理模式,当一个事件循环存在,它就会不断的执行以下步骤:

这些概念很晦涩难懂,简单总结下:

  • 执行 Task:任务队列有多个任务源(DOM、UI、网络等)队列,从中至少选出一个可运行的任务,放到 taskQueue 中。
    • 如果没有直接跳到微任务队列,就不会经过 2 ~ 5。
    • 否则从 taskQueue 中取出第一个可执行任务做为 oldestTask 执行,对应 2 ~ 5。
    • 注意,微任务不会在这里被选中,但是当一个任务队列里含有微任务,会将该微任务加入微任务队列。
    • 执行 Microtask:执行微任务队列,直到微任务队列为空,这里如果调度太多的微任务也会导致阻塞。
    • 更新渲染。

看到一个图,描述一次事件循环的过程,差不多就是这个意思,主要呢,还是这三个阶段:Task、Microtask、Render 下文会展开的讨论。

图片来源:https://pic2.zhimg.com/80/v2-38e53b9df2d13e9470c31101bb82dbb1_1440w.jpg

Task(Macrotask)

之前也看过很多文章关于事件循环的介绍,**大多会把 “Task” 当作 “Marcotask” 也就是宏任务来介绍,但是在规范中没有所谓的 “Marcotask”,**因为规范里没有这个名词,所以我在这个标题上特意加了个括号,有很多的叫法,也有称为外部队列的,这其实是一个意思,如果你是学习事件循环的新朋友可能就会有疑问,为什么我搜索不到关于这个的解释。

下文我会继续使用规范中的名词 “任务队列” 来表达。

任务队列是一个任务的集合。事件循环有一个或多个任务队列,事件循环做的第一步是从选择的队列中获取第一个可运行的任务,而不是出列第一个任务。

传统的队列(Queue)是一个先进先出的数据结构,总是排在第一个的先执行,而这里的队列里面会包含一些类似于 setTimeout 这样延迟执行的任务,所以,在规范中有这样一句话:“Task queues are sets, not queues(翻译为任务队列是一个集合,不是队列)”。

任务队列的 任务源 主要包括以下这些:

  • DOM 操作:对 DOM 操作产生的任务,例如,将元素插入文档时以非阻塞方式发生的事情 document.body = aNewBodyElement;。
  • 用户交互:用户交互产生的任务,例如鼠标点击、移动产生的 Callback 任务。
  • 网络:网络请求产生的任务,例如 fetch()。
  • 历史遍历:此任务源用于对 history.back() 和类似 API 的调用进行排队。
  • **setTimeout、setInterval:**定时器相关任务。

例如,当 User agent 有一个管理鼠标和键盘事件的任务队列和另一个其它任务源相关的任务队列,在事件循环中相比其它任务,它会多出四分之三的时间来优先执行鼠标和键盘事件的任务队列,这样使得其它任务源的任务队列在能够得到处理的情况下用户交互相关的任务可以得到更高优先级的处理,这也是提高了用户的体验。

Microtask

每个事件循环有一个微任务队列,它不是一个 task queue,两者是独立的队列。

什么是 Microtask(微任务)

微任务是一个简短的函数,当创建该函数的函数执行后,并且 JavaScript 执行上下文栈为空,而控制权尚未交还给事件循环之前触发。

当我们在一个微任务里通过 queueMicrotask(callback) 继续向微任务队列中创建更多的任务,对于事件循环来说,它仍会持续调用微任务直至队列为空。

  1. const log = console.log; 
  2. let i = 0; 
  3. log('sync run start'); 
  4. runMicrotask(); 
  5. log('sync run end'); 
  6.  
  7. function runMicrotask() { 
  8.   queueMicrotask(() => { 
  9.     log("microtask run, i = ", i++); 
  10.     if (i > 10) return;  
  11.     runMicrotask(); 
  12.   }); 

上面这段代码很简单,在主线程调用了 runMicrotask() 函数,该函数内部使用 queueMicrotask() 创建了微任务并且递归调用,微任务的触发是在执行栈为空时才执行,因为里面递归调用每次都会生成新的微任务,事件循环也是在微任务执行完毕才执行 Task Queue 里面的 setTimeout 回调。

  1. sync run start 
  2. sync run end 
  3. microtask run, i = 0 
  4. microtask run, i = 1 
  5. microtask run, i = 2 
  6. microtask run, i = 3 
  7. microtask run, i = 4 
  8. microtask run, i = 5 
  9. microtask run, i = 6 
  10. microtask run, i = 7 
  11. microtask run, i = 8 
  12. microtask run, i = 9 
  13. microtask run, i = 10 

通过这个示例,也可看到当调度大量的微任务也会导致和同步任务相同的性能缺陷,后面的任务得不到执行,浏览器的渲染工作也会被阻止。微任务这里的队列才是真正的队列。

创建一个 Microtask(Promise VS queueMicrotask)

在以往我们创建一个微任务很简单,可以创建一个立即 resolve 的 Promise,每次都需要创建一个 Promise 实例,同时也带来了额外的内存开销,另外 Promise 中抛出的错误是一个非标准的 Error,如果未正常捕获通常会得到这样一个错误 UnhandledPromiseRejectionWarning:。

使用 Promise 创建一个微任务。

  1. const p = new Promise((resolve, reject) => { 
  2.   // reject('err'
  3.   resolve(1); 
  4. }); 
  5. p.then(() => { 
  6.   log('Promise microtask.'
  7. }); 

现在 Window 对象上提供了 queueMicrotask() 方法以一种标准的方式,可以安全的引入微任务,而无需使用额外的技巧,它提供了一种标准的异常。

使用 queueMicrotask() 创建一个微任务。

  1. queueMicrotask(() => { 
  2.   log('queueMicrotask.'); 
  3. }); 

在我们写业务功能时,一个功能或方法内涉及多个异步调度的任务也是很常见的,基于 Promise 我们很熟悉,还可以使用 Async/Await 以一种同步线性的思维来书写代码。而 queueMicrotask 需要传递一个回调函数,当层级多了很容易出现嵌套。

重点是大多数情况下我们也不需要去创建微任务,过多的滥用也会造成性能问题,也许在做一些类似创建框架或库时可能需要借助微任务来达到某些功能。这里我想到了一个经常问的面试题 “实现一个 Promise” 这个在实现时也许可以采用 queueMicrotask(),在《JavaScript 异步编程》的源码系列,会再看到这个问题。

Microtask 总结

Microtask 总结一句话来讲就是:“它是在当前执行栈尾部下一次事件循环前执行”,需要注意的是,事件循环在处理微任务时,如果微任务队列不为空,就会继续执行微任务,例如,使用递归不停的增加新的微任务,这就很糟糕了。

微任务所包含的任务源没有明确的定义,通常包括这几个:Promise.then()、Object.observe(已废弃)、MutaionObserver、queueMicrotask。

更新渲染

渲染是事件循环中另一个很重要的阶段,这里有一个关于 浏览器工作原理 的讲解很好,整个渲染过程,理解下来主要是下面几个步骤,其中 Layout、 **Paint **这些词在下面的示例还会再次看到。

  • 解析 HTML 文档转化为 DOM Tree,同时也会解析外部 CSS 文件及内嵌的 CSS 样式为 CSSOM Tree。
  • DOM Tree、CSSOM Tree 两者的结合创建出另外一个树结构 Render Tree。
  • Render Tree 完毕之后进入布局(Layout)阶段,为每个节点分配一个在屏幕上的坐标位置。
  • 接下来根据节点坐标位置对整个页面绘制(Paint)。
  • 当我们对 DOM 元素修改之后,例如元素颜色改变、添加 DOM 节点,这时也还会触发布局和重绘(Repaint)。

图片来源:https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/webkitflow.png

结合 Task 与 Microtask 看渲染过程

做一个测试,使用 queueMicrotask 创建一个微任务,在自定义的 runMicrotask() 函数内部递归调用了 10 次,每一次里我都希望来回变换 container 这个 div 的背景色,另外还放置了一个 setTimeout 属于 Task queue 这个是让大家顺便看下 Task queue 在事件循环中的执行顺序

  1. <div id="container" style="width: 200px; height: 200px; background-color: red; font-size: 100px; color: #fff;"
  2.   0 
  3. </div> 
  4. <script> 
  5.   let i = 0; 
  6.   const container = document.getElementById('container'); 
  7.   setTimeout(() => {}); 
  8.   runMicrotask(); 
  9.   function runMicrotask() { 
  10.     queueMicrotask(() => { 
  11.       if (i > 10) return;  
  12.       container.innerText = i; 
  13.       container.style.backgroundColor = i % 2 === 0 ? 'blue' : 'red'
  14.       runMicrotask(); 
  15.     }); 
  16.   } 
  17. </script> 

通过 Chrome 的 Performance 记录,运行过程,首先看下 Frame 只有一个,直接渲染出了最后的结果,如果按照上例,我们可能会觉得应该是在每个微任务执行时都会有一次渲染 blue -> red -> blue -> ...

再看一个更详细的执行过程,可以看到在执行脚步执行后,首先运行的是微任务,对应的是我们代码 runMicrotask() 函数,下图紫色的是 Layout,Paint 是渲染绘制能够看到就是在运行完所有的微任务之后执行的,在之后是下一次事件循环最后执行了 Task Queue Timer。

根据事件循环处理模式规范中的描述,渲染是在一次事件循环的微任务结束之后运行,上例差不多验证了这个结果,这个时候有个疑问:“为什么不是在每一次微任务结束之后执行,当你把 queueMicrotask 替换成 setTimeout 也是一样的,不会在每次事件中都去执行”。

Render 在事件循环中什么时候执行?

规范中还有这样一段描述,得到一个信息是:在每一次的事件循环结束后不一定会执行渲染。

每一轮的事件循环如果没有阻塞操作,这个时间是很快的,考虑到硬件刷新频率限制和性能原因的 user agent 节流,浏览器的更新渲染不会在每次事件循环中被触发。如果浏览器试图达到每秒 60Hz 的刷新率,也简称 60fps(60 frame per second),这时绘制一个 Frame 的间隔为 16.67ms(1000/60)。如果在 16ms 内有多次 DOM 操作,也是不会渲染多次的。

如果浏览器无法维持 60fps 就会降低到 30fps、4fps 甚至更低。

如果想在每次事件循环中或微任务之后执行一次绘制,可以通过 requestAnimationFrame 重新渲染。

结合 requestAnimationFrame 再看渲染过程

requestAnimationFrame 是浏览器 window 对象下提供的一个 API,它的应用场景是告诉浏览器,我需要运行一个动画。该方法会要求浏览器在下次重绘之前调用指定的回调函数更新动画。

修改上述示例,加上 requestAnimationFrame() 方法。

  1. function runMicrotask() { 
  2.     queueMicrotask(() => { 
  3.       requestAnimationFrame(() => { 
  4.         if (i > 10) return;  
  5.         container.innerText = i; 
  6.         container.style.backgroundColor = i % 2 === 0 ? 'blue' : 'red'
  7.         i++; 
  8.         runMicrotask(); 
  9.       }); 
  10.     }); 
  11.   } 

运行之后如下所示,每一次的元素改变都得到了重新绘制。

放大其中一个看看任务的执行情况,requestAnimationFrame 也可以看作一个任务,可以看到它在运行之后执行微任务。

Render 总结

事件循环中 Render 阶段可能在一次事件循环中运行,也可能在多次事件循环后运行。它会受到浏览器的刷新频率影响,如果是 60fps 那就是每间隔 16.67ms 执行一次,另一方面当浏览器认为更新渲染对用户没有影响的情况下,也会认为这不是一次必要的渲染。

总的来说它的机制和浏览器是相关的,了解即可,不用特别的纠结。

总结

浏览器中事件循环主要由 Task、Microtask、Render 三个阶段组成,Task、Microtask 是我们会用到的比较多的,无论是网络请求、还是 DOM 操作、Promise 这些大致都划分为这两类任务,每一轮的事件循环都会检查这两个任务队列里是否有要执行的任务,等 JavaScript 上下文栈空后,先情况微任务队列里的所有任务,之后在执行宏任务,而 Render 则不是必须的,它受浏览器的一些因素影响,并不一定在每次事件循环中执行。

Reference

https://yu-jack.github.io/2020/02/03/javascript-runtime-event-loop-browser/

https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/

https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

 

https://zhuanlan.zhihu.com/p/34229323

 

责任编辑:武晓燕 来源: 编程界
相关推荐

2021-10-22 08:29:14

JavaScript事件循环

2017-01-05 09:07:25

JavaScript浏览器驱动

2016-10-09 08:38:01

JavaScript浏览器事件

2015-04-22 10:50:18

JavascriptJavascript异

2014-05-23 10:12:20

Javascript异步编程

2021-12-08 07:55:41

EventLoop浏览器事件

2017-02-09 15:15:54

Chrome浏览器

2019-12-17 14:45:17

浏览器事件循环前端

2020-12-23 07:37:17

浏览器HTML DOM0

2023-04-28 15:20:37

JavaScript事件循环

2024-06-04 15:56:48

Task​.NET异步编程

2013-03-08 09:33:25

JavaScript同步异步

2021-06-10 07:51:07

Node.js循环机制

2017-04-26 14:15:35

浏览器缓存机制

2021-06-06 19:51:07

JavaScript异步编程

2017-05-15 13:40:20

浏览器http缓存机制

2022-07-07 07:22:01

浏览器JavaScript工具

2022-07-07 09:12:17

JavaScript线程循环机制

2013-04-01 15:25:41

异步编程异步EMP

2020-09-28 14:41:24

Event Loop
点赞
收藏

51CTO技术栈公众号