揭开事件循环的神秘面纱
作者 | 小萱
导读
这篇文章会全方位讲解事件循环机制,从这篇文章你可以学到,「事件循环」和「浏览器渲染」的关系,浏览器 setTimeout、requestAnimationFrame(RAF)、requestIdleCallback(RIC)等 API 在事件循环的「执行时机」,导致浏览器卡顿的原因、交互指标是如何测量的以及如何提升网站的交互性能。
全文 10503 字,预计阅读时间 27 分钟。
01 前言
我们常常会提到页面性能,为什么要优化长任务,又为什么 React 要做时间切片呢。这篇文章把浏览器的渲染、事件循环与页面性能串联起来。
从这篇文章你可以学到,「事件循环」和「浏览器渲染」的关系,浏览器 setTimeout、
requestAnimationFrame(RAF)、requestIdleCallback(RIC)等 API 在事件循环的「执行时机」,导致浏览器卡顿的原因、交互指标是如何测量的以及如何提升网站的交互性能。
学完这些,你可以对为什么动画要用 RAF、又何时去用 RIC、该不该选择 setTimeout、如何规避长任务之类的问题应对自如。
02 事件循环概述
2.1 为什么要了解事件循环?
深入了解事件循环是性能优化的基础。在讨论事件循环之前,我们需要先了解浏览器的多进程和多线程架构。
2.2 浏览器的架构
回顾浏览器的架构,现代浏览器都是多进程和多线程的。
2.2.1 多进程
Chrome 浏览器使用多进程架构,意味着每个标签页(在某些浏览器中也包括每个扩展程序)通常在其自己的进程中运行。这样做的好处是,一个标签页崩溃不会影响到其他标签页。
站点隔离特性,浏览器每个 tab,都是独立的渲染进程,这点的好处是假设你打开三个标签页,一个标签卡死不影响其他两个。但如果三个标签共用一个进程,一个卡死会导致全部都卡,这样体验很差。
△浏览器的多进程示意图
2.2.2 多线程
每个浏览器进程都可以包含多个线程。例如,主线程用于执行 JavaScript 代码和处理页面布局,而其他线程可能用于网络请求、渲染等任务。
主线程
Web 应用程序需要在此单个主线程上执行某些关键操作。当您导航到 Web 应用程序时,浏览器将创建并向您的应用程序授予该线程,以便您的代码在其上执行。
主线程指的是渲染进程下的主线程,负责解析 HTML、计算 CSS 样式、执行 JavaScript、计算布局、绘制图层等任务。
△主进程即渲染进程包含的线程图
某些任务必须 在主线程上运行。例如,任何直接需要访问 DOM(即 DOM document)的操作都必须在主线程上运行(因为 DOM 不是线程安全的)。这将包括大多数 UI 相关代码。
主线程上一次只能运行 一个任务。
此外,一个任务必须在主线程上运行完成,然后才能运行另一个任务。浏览器没有“部分”执行任务的机制,每个任务都完整地运行直至完成。
在下面的示例中,在浏览器展示界面的时候,按顺序运行下面的任务,并且每个任务都在主线程上完成:
03 事件循环的具体流程
我们这里主要讨论的是 window event loop。也就是浏览器一个渲染进程内主线程所控制的 Event Loop。
△发生一次事件循环的具体流程
发生一次事件循环,也就是浏览器一帧中可以用于执行JS的流程如下:
从 task queue 取出一个 task(宏任务)执行并删除 -> 执行并清空队列中全部 job(微任务) -> requestAnimationFrame -- 浏览器更新渲染 -- requestIdleCallback
3.1 更新渲染的步骤
前两个步骤,耳熟能详,这里不再讨论,重点讨论「更新渲染」之后的步骤。
1. Rendering opportunities: 标志是否一次事件循环后会发生渲染。在每次事件循环的结束,不一定会发生渲染。导致不渲染的可能:无法维持当前刷新率、浏览器上下文不可见、浏览器判断更新不会造成视觉改变并且 raf 的回调为空。
如果这些条件都不满足,当前文档不为空,设置 hasARenderingOpportunity 为 true。
2.如果窗口变化,执行 resize。
3.如果滚动,执行 scroll。
4.媒体查询。
5.canvas 。
6.执行 RAF 回掉,传递回掉参数 DOMHighResTimeStamp,开始执行回调的时间。
7.重新执行 Layout 等计算,渲染绘制界面。
8.如果满足 任务队列和微任务队列都为空,并且渲染时机 hasARenderingOpportunity 为 false,执行算法是否执行 requestIdleCallback 的回调函数。
3.2 执行顺序与渲染
来一道简单的题目,将创建宏任务、微任务、RIC、RAF 的代码同时定义,输出执行顺序。
你可能会疑问为什么 RAF 会在 setTimeout(fn, 0)之前执行,setTimeout(fn, 0)的执行时机是延迟 0-4ms,RAF 可以粗暴理解为 settimeout(fn, Math.random() * 16.6),因此 setTimeout 会优先。但如果在 setTimeout 执行之前主线程被其他的任务跑满了,超过了一帧的耗时,setTimeout 会在 RAF 的回调之后执行(用例见下面的代码段),因此 setTimeout 的延迟时间并不稳定,RAF 的执行时机稳定,在一帧内注册的,都会在这一帧的结束,下一帧的开始之前执行。
注意,Promise.then 的回调可以保证第一轮的准确性,如果继续.then 发生的行为和浏览器版本有关,开发时不要过分依赖多.then 的回调顺序,这是不可靠的。
上面提到渲染是在一次事件循环的「最后」发生,那么对于多次「修改 dom」的操作,是会被合并取最后一次的结果作为布局渲染。
外层父容器 400px,这段代码,表现是盒子从 0 到 200px,盒子设置 400px 的动作,被合并掉了。那如何实现盒子从 400px 呢,可以采取延迟到下一帧渲染。
△演示效果
「嵌套的 RAF」可以保证回调在下一帧执行。当然,此处用 setTimeout 也可以达到同样的延迟效果。
△延迟后的演示效果
04 任务队列与执行时机
执行 JavaScript task 是在渲染之前,如果在一帧之内 JavaScript 执行时间过长就会阻塞渲染,同样会导致丢帧、卡顿,这里的 js 执行时间过长,就是长任务,下面会仔细介绍。
对长任务的定义:如果任务耗时超过 50ms,则认为该任务是长任务。
当我们谈到长任务造成页面卡顿时,通常指的是主线程(Main Thread)上的任务。主线程指的是渲染进程下的主线程,负责解析 HTML、计算 CSS 样式、执行 JavaScript、计算布局、绘制图层等任务。当主线程上的一个任务(例如一个 JavaScript 函数)运行时间过长时,它会阻塞主线程上的其他任务,包括但不限于 UI 更新和用户交互事件的处理,从而导致页面卡顿或不响应。
JS 的执行和渲染的关系:
JS 执行与 Paint 任务都发生在主线程,具体的绘制操作是交由合成线程完成,与主线程并不互斥,但是 JS 的执行时间过长,会导致 Paint 整理好的数据没有及时提交给合成线程,因此页面有帧没有执行绘制,也就是掉帧。
△JS 的执行和渲染的关系图
4.1 为什么不使用 setTimeout 做动画
raf 和 setTimeout 对比:
(https://jsfiddle.net/hixuanxuan/mrw6upgs/3/__)
1.不同步与显示刷新率:
浏览器通常以每秒 60 帧的速度刷新,大约每 16.67 毫秒刷新一次。如果你使用 setTimeout 来创建动画,并尝试每 16.67 毫秒运行一帧,你的代码不会完全与浏览器的刷新速率同步,导致丢帧
2.延迟执行:
setTimeout 的延迟时间参数只是一个最小延迟时间,而不是保证执行的精确时间。如果主线程忙于其他任务,setTimeout 的回调可能会被延迟,导致丢帧
3.计时器合并:
浏览器渲染有渲染时机(Rendering opportunity),也就是浏览器会根据当前的浏览上下文判断是否进行渲染,因为考虑到硬件的刷新频率限制、页面性能以及页面是否存在后台等等因素,宏任务之间不一定会伴随着浏览器绘制。如果两个 Task 距离的很近,他们可能会被合并在一次渲染任务,得到的结果是意料之外的,如果 Task 距离较大,那他跟不上浏览器的刷新频率,会导致丢帧。
RAF 的执行时机是在下一次渲染前调用,也就是说使用这个 API 允许你在下一次渲染开始之前更改 DOM,然后在本次渲染中立即体现,因此他是制作动画的绝佳选择。
4.2 requestIdleCallback 的执行时机
主要在浏览器的主线程空闲时执行,为了保证响应性,会计算一个截止时间,computeDeadline,它将决定何时执行 requestIdleCallback 中注册的回调。下面是计算截止时间算法的简要概述:
1.设置初始截止时间:
初始化时,将事件循环的最后闲置周期开始时间设置为当前时间。
设置一个基本的截止时间,该时间是事件循环的最后闲置周期开始时间加上 50 毫秒(为了保证对新用户输入的响应性)。为什么要加这个 50ms,是因为浏览器为了提前应对一些可能会突发的用户交互操作,比如用户输入文字。如果给的时间太长了,你的任务把主线程卡住了,那么用户的交互就得不到回应了。50ms 可以确保用户在无感知的延迟下得到回应。
2.检查是否有待处理的渲染:
初始化一个变量 hasPendingRenders 为 false。
遍历相同事件循环的所有窗口,检查每个窗口是否有未执行的 RAF 回调或可能的渲染更新。如果有,将 hasPendingRenders 设置为 true。
3.基于 timeout 调整截止时间:
如果 RIC 传入第二个参数 timeout,更新截止时间为 timeout。这会强制浏览器不管多忙,都在超过这个时间之后去执行 rIC 的回调函数。
4.考虑渲染的时间:
如果 hasPendingRenders 为 true,计算下一个渲染的截止时间,基于事件循环的最后渲染机会时间和当前的刷新率。
如果下一个渲染的截止时间早于当前设置的截止时间,那么更新截止时间为下一个渲染的截止时间。
5.返回最终的截止时间:
返回计算出的截止时间,这个时间将用于确定何时执行 requestIdleCallback 中注册的回调。
6.开始空闲期:
对于相同事件循环的每个窗口,执行“开始空闲期”算法,使用 computeDeadline 作为参数,确定何时执行 requestIdleCallback 中注册的回调。
也就是说,这个 timeRemaining() 的计算非常动态,会根据上面这些因素去决定。
4.3 React 如何实现 Time slice,没有使用 RIC、setTimeout 的原因是什么
没使用 RIC 的原因是他在部分浏览器表现不佳,比如 safari。
需要满足的条件:
1.暂停 JS 执行,将主线程去执行 style、layout、paint 等任务,让浏览器有机会更新页面。
2.在未来某个时刻可以继续调度任务,执行上次还没有完成的任务。
对于 react 的 Time Slice,他的目的是中断当前 js 的执行,让他去执行渲染相关任务,因此需要的 API 是在浏览器的 Paint 之后执行,浏览器并未提供除了 RIC 这样的 API。RAF 的执行时机是在一帧的结束,此时创建宏任务开启下一轮 Task,渲染的任务放在 RAF 里在这一帧执行。如果使用 setTimeout(fn, 0)创建宏任务,如果 timeout 嵌套的层级超过了 5 层,最低会有 4ms 的延迟,具体定义的代码可以参考 chrome 对计时器的定义(https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp),因此首选的是 message channel,优先级高于 setTimeout 可以在上一帧渲染结束后立即执行,这样就实现了可以中断的 JS 执行的效果。
4.4 模拟实现 requestIdecallback
要模拟实现 requestIdecallback 的效果,定义的任务队列在浏览器完成渲染任务之后执行,扩展来说也可以用来测量浏览器渲染任务的执行时间。
Background Tasks API - Web API 接口参考 | MDN(https://developer.mozilla.org/zh-CN/docs/Web/API/Background_Tasks_API)
05 交互性能指标与优化方法
长任务对页面的影响,带来「卡顿」、「掉帧」等不好的体验,常用衡量交互性能的指标有 TTI 和 FID,这些均可使用 web-vital 库进行测量。下面展开对指标的详细介绍。
5.1 交互性能的衡量指标
衡量交互性能的指标主要关注以下几个方面:
5.1.1 TTI (理想可交互时间)
1.定义可交互:
首先,需要明确什么是“可交互”。一个页面被认为是可交互的,意味着页面的主要内容已经加载完毕,用户可以进行点击、输入等交互操作,而且页面能够快速响应。
2.监测首次内容绘制 (FCP) 和 DOMContentLoaded:
测量 TTI 的过程通常开始于监测首次内容绘制 (FCP) 和 DOMContentLoaded 事件。这两个事件分别表示浏览器开始绘制页面内容和 DOM 结构加载完毕的时刻。
3.长任务监测:
长任务是指那些执行时间超过 50 毫秒的任务。长任务通常会阻塞主线程,延迟页面的交互可用性。通过监测长任务,可以了解主线程何时变得空闲。
4.寻找交互窗口:
为了确定 TTI,需要找到一个至少 5 秒钟主线程空闲的窗口,且该窗口应在首次内容绘制 (FCP) 之后。在这个 5 秒空闲窗口期间,没有长任务执行,意味着用户可以与页面交互。一旦找到这个空闲窗口,记录 TTI。如果未找到长任务,则 TTI 与 FCP 相同。
△TTI 测量示意图(源于 web.dev)
5.1.2 FID(首次输入延迟)
FID,即 First Input Delay,用于量化用户在页面加载时首次交互的响应延迟。一个低的 FID 表示页面是快速响应用户交互的,而一个高的 FID 表示页面在响应用户交互时有延迟。
1.事件监听:
为了计算 FID,浏览器需要监听用户的交互事件,如点击、键盘输入或者触摸事件。当用户与页面交互时,会触发这些事件。
2.事件处理时间:
当事件被触发时,浏览器会计算从事件触发到浏览器开始处理事件的时间。这个时间就是 FID。它包括了浏览器将事件放入事件队列、事件队列的等待时间、以及浏览器开始处理事件的时间。
3.事件处理:
一旦事件开始被处理,浏览器会记录下处理开始的时间。如果页面在处理事件时非常忙碌,或者有其他高优先级的任务,那么事件处理可能会被延迟,这会增加 FID。
5.1.3 INP(交互到下一次绘制)
INP,即 Interaction to Next Paint,主要关注的是用户交互(如点击、滚动或按键操作)到页面响应的时间长度,具体到页面上的某个元素的可视更新。
比起来 FID 关注的是页面加载完成后用户首次交互,INP 关注的是所有交互的最长渲染延迟,因此 INP 不仅仅代表第一印象,可以全面评估响应情况, 使 INP 比 FID 在衡量用户交互体验上更为可靠。
INP 将会在 2024 年 3 月取代 FID 成为标准性能指标。
△交互到绘制的时间
5.2 如何优化交互性能指标
1、拆分任务,这是避免长任务的有效手段。
利用 performance 进行分析,找出 long task
针对 long task,进行每个步骤的任务拆分,执行优先级高的,剩下的部分利用延迟代码执行的方法进行中断。
比如,有个 Input 框,当输入的内容发生变更,需要进行大量计算/创建 dom 等耗时操作,造成输入卡顿。因此我们需要在用户「尝试发生互动」的时候,「退让主线程」。
2、非关键模块 延迟执行。对于点击率不高、非核心模块等,采取 dynamic import 的方式,用到了再加载,或是延迟到一定时间后再加载,减少首次主线程所需要执行的任务。
3、对于视口内不可见的内容,延迟加载。
图片的延迟加载。
为 img 标签 loading 设为 lazy,延迟加载资源,直到资源达到与视口的计算距离,Chrome77+支持。
利用 IntersectionObserver 监测图片是否在可视区域,再进行渲染。推荐使用 lazy-load-image-component(https://www.npmjs.com/package/react-lazy-load-image-component) 等库。
减少大量 dom 的渲染。使用 content-visibility 延迟渲染屏幕外元素,Chrome85+支持。
4、灵活的缓存策略。
用 service-worker 跨站资源共享。
除了资源可以采取强缓存+协商缓存配合的方式,用 service-worker 实现更为灵活的缓存策略。比如站点 a 和站点 b 仅满足同源,技术栈渲染方式都完全不同,如何实现在访问 a 的时候可以预取 b 的资源。站点 a 空闲的时候注册 service-worker,访问站点 b 即可从 cache 里读取缓存,提升加载速度。sw 不仅在缓存方面表现优秀,也可以帮我们实现离线应用,以及无法被浏览器强缓存的文件手动添加缓存(不同浏览器对可以强缓存的文件的体积限制不同)。
△使用 sw 做跨站资源预取
06 总结
1.浏览器是多进程和多线程的,通常说主线程指的是渲染进程下的主线程。
2.主线程上一次只能运行一个任务,浏览器的绘制和主线程并不互斥,但长任务会导致延迟进入合成,甚至在这一帧不发生合成也就是掉帧。
3.在每次事件循环的结束,不一定会发生渲染。setTimeout 的执行时机并不稳定。
4.RAF 的执行时机稳定是在当前帧的最后,下一帧的开始之前,非常适合做动画。
5.RIC 的执行时机并不稳定,computeDeadline 由被多因素影响计算得出,但可以传递 timeout 控制执行的 deadline。
6.用 TTI 和 FID(INP)去衡量页面的交互性能。
7.用长任务拆分、延迟非关键模块执行、延迟非可视区域图片加载、减少页面渲染以及配置灵活的缓存策略等手段,提升网站的交互性能。
——END——
参考资料:
[1]HTML living standand - evnet loop processing model:
https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
推荐阅读:
版权声明: 本文为 InfoQ 作者【百度Geek说】的原创文章。
原文链接:【http://xie.infoq.cn/article/c9413ff7644b74f5410e06521】。文章转载请联系作者。
评论