当前位置: 首页 > 科技观察

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

时间:2023-03-12 21:14:00 科技观察

学习事件循环的时候,想找一些规范来学习,但是查了EcmaScript或者V8,发现都没有这个东西的定义,例如,在v8中有一些是执行栈和堆信息。确实,事件循环不在这里。后来慢慢了解到,在浏览器环境下,事件循环的定义是HTML标准中的。以前的HTML规范是由whatwg和w3c制定的。这两个组织各有不同。2019年,两个组织签署了一项协议,合作开发单一版本的HTML和DOM,最终HTML、DOM标准最终由whatwg维护。本文的讲解主要基于whatwg标准。在HTMLLivingStandard事件循环中,该规范定义了浏览器内核如何实现它。浏览器规范中的事件循环事件循环定义为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节中描述的事件循环。每个代理都有一个关联的事件循环,每个代理都是唯一的。为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节中描述的事件循环。每个代理都有一个关联的事件循环,该循环对于该代理是唯一的。从这个定义中也可以看出,事件循环主要是用来协调事件、网络、JavaScript等之间的一种运行机制,下面我们就以JavaScript为起点,看看它们是如何交互的。事件循环中有一个重要的概念任务队列,它决定了任务的执行顺序。事件循环处理方式规范8.1.6.3处理模型定义了事件循环处理方式,当一个事件循环存在时,会继续执行以下步骤:这些概念很晦涩,简单总结一下:ExecuteTask:任务队列有多个任务源(DOM、UI、网络等)队列,从中至少选择一个可运行的任务并放入taskQueue。如果不直接跳转到微任务队列,2-5就不会通过。否则,从taskQueue中取出第一个可执行任务,作为oldestTask执行,对应2-5。注意这里不会选择microtasks,但是当一个taskqueue中包含microtasks时,microtasks会被添加到microtaskqueue中。执行微任务:执行微任务队列,直到微任务队列为空。如果这里调度了太多的微任务,也会造成阻塞。更新渲染。看到一张图,描述一个事件循环的过程,差不多就是这个意思。主要就是这三个阶段:Task、Microtask、Render,下面会讲到。图片来源:https://pic2.zhimg.com/80/v2-38e53b9df2d13e9470c31101bb82dbb1_1440w.jpgTask(Macrotask)之前看过很多关于事件循环的文章,大部分都会用“Task”作为“Marcotask”来介绍宏任务,但规范中没有所谓的“Marcotask”。**因为规范中没有这个名词,所以我特地给这个标题加了括号。名字有很多,有的叫外部队列。这其实是一个意思。如果你是学习事件循环的新朋友,你可能会有疑问,为什么我找不到关于这个的解释。下面我继续使用规范中的名词“任务队列”来表达。任务队列是任务的集合。事件循环有一个或多个任务队列,事件循环做的第一步是从选定的队列中获取第一个可运行的任务,而不是将第一个任务从队列中取出。传统的队列(Queue)是先进先出的数据结构,总是先执行,而这里的队列会包含一些像setTimeout这样的延迟执行的任务。因此,在规范中,有一句话:“Taskqueuesaresets,notqueues(翻译成任务队列是集合,不是队列)”。任务队列的任务来源主要有以下几种:DOM操作:DOM操作产生的任务,例如向文档document.body=aNewBodyElement;插入一个元素时的非阻塞方式。用户交互:用户交互产生的任务,如鼠标点击、移动产生的回调任务。Network:由网络请求产生的任务,比如f??etch()。历史遍历:此任务源用于对history.back()和类似API的调用进行排队。**setTimeout,setInterval:**定时器相关任务。例如,当用户代理有一个管理鼠标和键盘事件的任务队列和另一个与其他任务源相关的任务队列时,它会在事件循环中花费四分之三的时间来确定鼠标和键盘事件的优先级,而不是其他任务。键盘事件的任务队列,使得与用户交互相关的任务在其他任务源的任务队列可以处理的情况下,可以优先处理,也提升了用户体验。Microtask为每个事件循环都有一个microtask队列,它不是任务队列,两者是独立的队列。什么是微任务(microtask)微任务是一个简短的函数,当创建该函数的函数执行时触发,并且JavaScript执行上下文堆栈为空,但在控制返回到事件循环之前。当我们在一个microtask中通过queueMicrotask(callback)不断在microtask队列中创建更多的任务时,对于事件循环来说,它会不断调用microtask直到队列为空。constlog=console.log;leti=0;log('syncrunstart');runMicrotask();log('syncrunend');functionrunMicrotask(){queueMicrotask(()=>{log("microtaskrun,i=",i++);if(i>10)return;runMicrotask();});}上面的代码很简单。runMicrotask()函数在主线程中调用,它使用queueMicrotask()创建一个微任务并递归调用它。task的触发是在执行栈为空的时候执行的,因为里面的递归调用每次都会产生一个新的microtask,事件循环只有在microtask执行完成后才会在TaskQueue中执行setTimeout回调.syncrunstartsyncrunendmicrotaskrun,i=0microtaskrun,i=1microtaskrun,i=2microtaskrun,i=3microtaskrun,i=4microtaskrun,i=5microtaskrun,i=6microtaskrun,i=7microtaskrun,i=8microtaskrun,i=9microtaskrun,i=10可以看出调度大量微任务也会造成和同步任务一样的性能缺陷,后续任务不会执行,浏览器的渲染工作也会被阻塞。微任务中的队列是真正的队列。创建一个Microtask(PromiseVSqueueMicrotask)以前我们创建一个microtask非常简单。我们可以创建一个立即解析的Promise。每次我们需要创建Promise实例时,也会带来额外的内存开销。另外,Promise抛出的错误是一个非标准的Error,如果没有被正常捕获,通常会得到这样的错误UnhandledPromiseRejectionWarning:。使用Promise创建一个微任务。constp=newPromise((resolve,reject)=>{//reject('err')resolve(1);});p.then(()=>{log('Promisemicrotask.')});现在Window对象以标准的方式提供了queueMicrotask()方法来安全地引入微任务而不使用额外的技巧,它提供了一个标准的异常。使用queueMicrotask()创建一个微任务。queueMicrotask(()=>{log('queueMicrotask.');});我们在写业务函数的时候,一个函数或者方法涉及到多个异步调度任务也是很常见的。基于Promise,我们再熟悉不过了,也可以用Async/Await,用同步线性的思维写代码。而queueMicrotask需要传一个回调函数,层次多了容易嵌套。关键是在大多数情况下,我们不需要创建微任务。过度滥用也会导致性能问题。可能在做一些类似创建框架或库的事情时,我们可能需要使用微任务来实现某些功能。在这里我想到了一道常见的面试题“ImplementaPromise”。这可以使用queueMicrotask()来实现。在《JavaScript 异步编程》的源码系列中,又会看到这个问题。Microtask总结Microtask一句话概括就是:“它在当前执行栈的末尾的下一个事件循环之前执行”。需要注意的是,事件循环在处理微任务时,如果微任务队列不为空,它会继续执行微任务,例如使用递归不断添加新的微任务,这是不好的。microtasks中包含的任务源没有明确定义,通常包括这些:Promise.then()、Object.observe(已过时)、MutaionObserver、queueMicrotask。更新渲染渲染是事件循环中另一个非常重要的阶段。这里很好的解释了浏览器的工作原理。整个渲染过程主要通过以下几个步骤来理解,其中Layout、**Paint**这几个字我们在下面的例子中又会看到。将HTML文档解析为DOMTree,同时也将外部CSS文件和嵌入的CSS样式解析为CSSOMTree。DOMTree和CSSOMTree的结合创建了另一种树结构RenderTree。RenderTree完成后,进入布局(Layout)阶段,为每个节点分配一个屏幕上的坐标位置。接下来根据节点坐标位置绘制(Paint)整个页面。当我们修改DOM元素时,比如改变元素的颜色或者添加一个DOM节点,布局和重绘(Repaint)也会在这个时候被触发。图片来源:https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/webkitflow.png结合Task和Microtask看渲染过程做一个测试,使用queueMicrotask创建一个microtask,在自定义的runMicrotask()function内部递归调用了10次,每次都想改变容器div的背景色,还放了一个属于Task队列的setTimeout。这是为了让大家看到事件循环中Task队列的执行顺序。0

通过Chrome的Performance记录,运行过程,先看只有一个Frame,直接renderthefinal的结果,如果按照上面的例子,我们可能会认为应该有蓝色->红色->蓝色->...的渲染,然后再看每个microtask执行时更详细的执行过程.可以看到在执行完步骤后,首先运行的是microtask,对应我们代码中的runMicrotask()函数。下图中紫色的是Layout,Paint是渲染。你可以看到它是在运行完所有微任务之后执行的。之后是下一个事件循环最后一次执行OKTaskQueueTimer根据事件循环处理方式规范中的描述,渲染是在一个事件循环微任务结束后运行的。上面的例子几乎已经验证了这个结果。这时候就有一个疑问:“为什么不在每个microtask结束后执行,当你把queueMicrotask换成setTimeout的时候也是一样的,不会在每个事件中都执行。”Render什么时候在事件循环中执行?规范中也有这样的描述,信息是渲染不一定在每次事件循环结束后执行。如果每一轮事件循环都没有阻塞操作,这个时间是非常快的。考虑到硬件刷新频率限制和用户代理出于性能原因的节流,浏览器的更新渲染不会在每个事件循环中触发。如果浏览器试图达到每秒60Hz的刷新率,也称为60fps(每秒60帧),则绘制Frame的间隔为16.67ms(1000/60)。如果16ms内有多次DOM操作,则不会多次渲染。如果浏览器不能保持60fps,就会降到30fps、4fps甚至更低。如果你想在每个事件循环中或在一个微任务之后执行一次绘制,你可以通过requestAnimationFrame重新渲染。结合requestAnimationFrame来看渲染过程requestAnimationFrame是浏览器窗口对象下提供的一个API。它的应用场景是告诉浏览器我需要运行一个动画。该方法会要求浏览器在下次重绘前调用指定的回调函数来更新动画。修改上面的示例以添加requestAnimationFrame()方法。functionrunMicrotask(){queueMicrotask(()=>{requestAnimationFrame(()=>{if(i>10)return;container.innerText=i;container.style.backgroundColor=i%2===0?'blue':'red';i++;runMicrotask();});});}运行后,如下图,每个元素变化都会重新绘制。放大其中一个以查看任务的执行情况。requestAnimationFrame也可以看作是一个任务,可以看到它运行后执行的是微任务。RenderSummary事件循环中的Render阶段可能在一个事件循环中运行,也可能在多个事件循环之后运行。它会受到浏览器刷新率的影响。如果是60fps,则每16.67ms执行一次。另一方面,当浏览器认为更新的渲染对用户没有影响时,它也会认为它不是必要的渲染。一般来说,它的机制和浏览器有关,了解就可以了,不需要特别纠结。总结一下,浏览器中的事件循环主要由三个阶段组成:Task、Microtask、Render。Task和Microtask是我们用的比较多的。不管是网络请求,DOM操作,还是Promise,这些都大致分为这两类任务,每一轮事件循环都会检查两个任务队列中是否有要执行的任务。JavaScript上下文栈为空后,会先处理微任务队列中的所有任务,然后再执行宏任务,不需要Render。是的,它受浏览器的一些因素影响,不一定在每个事件循环中都执行。参考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-loopshttps://zhuanlan.zhihu.com/p/34229323