写点什么

揭开事件循环的神秘面纱

作者:百度Geek说
  • 2023-12-19
    上海
  • 本文字数:8418 字

    阅读完需:约 28 分钟

揭开事件循环的神秘面纱

作者 | 小萱


导读

这篇文章会全方位讲解事件循环机制,从这篇文章你可以学到,「事件循环」和「浏览器渲染」的关系,浏览器 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 的代码同时定义,输出执行顺序。


console.log('开始执行');console.log('start');setTimeout(() => {  console.log('setTimeout');}, 0);
requestAnimationFrame(() => { console.log('requestAnimationFrame');});new Promise((resolve, reject) => { console.log('Promise'); resolve('promise resolved');})
requestIdleCallback(() => { console.log('requestIdleCallback');});
(async function asyncFunction() { console.log(await 'asyncFunction');})();
console.log('执行结束');// 开始执行// Promise// 执行结束// promise resolved// asyncFunction// setTimeout// requestAnimationFrame// requestIdleCallback
复制代码


你可能会疑问为什么 RAF 会在 setTimeout(fn, 0)之前执行,setTimeout(fn, 0)的执行时机是延迟 0-4ms,RAF 可以粗暴理解为 settimeout(fn, Math.random() * 16.6),因此 setTimeout 会优先。但如果在 setTimeout 执行之前主线程被其他的任务跑满了,超过了一帧的耗时,setTimeout 会在 RAF 的回调之后执行(用例见下面的代码段),因此 setTimeout 的延迟时间并不稳定,RAF 的执行时机稳定,在一帧内注册的,都会在这一帧的结束,下一帧的开始之前执行。


  let task = new Array(10000).fill(null).map((_, i) => () => {    const span = document.createElement("span");    span.innerText = i;    console.log("==>task", i);  });  task.forEach((i) => i());  requestAnimationFrame(() => {    console.log("===>requestAnimationFrame");  });  setTimeout(() => {    console.log("===>setTimeout");  }, 0);  //输出:  // ===>requestAnimationFrame  // ===>setTimeout
复制代码


注意,Promise.then 的回调可以保证第一轮的准确性,如果继续.then 发生的行为和浏览器版本有关,开发时不要过分依赖多.then 的回调顺序,这是不可靠的。


上面提到渲染是在一次事件循环的「最后」发生,那么对于多次「修改 dom」的操作,是会被合并取最后一次的结果作为布局渲染。


    const btn = document.querySelector(".btn");    btn.addEventListener("click", () => {      box.style.transform = "translateX(400px)";      box.style.transition = "transform 1s ease-in-out";      box.style.transform = "translateX(200px)";    });
复制代码


外层父容器 400px,这段代码,表现是盒子从 0 到 200px,盒子设置 400px 的动作,被合并掉了。那如何实现盒子从 400px 呢,可以采取延迟到下一帧渲染。



△演示效果


    btn.addEventListener("click", () => {      box.style.transform = "translateX(400px)";      requestAnimationFrame(() => {        requestAnimationFrame(() => {          box.style.transition = "transform 1s ease-in-out";          box.style.transform = "translateX(200px)";        });      });    });
复制代码


「嵌套的 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 接口参考 | MDNhttps://developer.mozilla.org/zh-CN/docs/Web/API/Background_Tasks_API


  // 当到时间了,立即执行的函数  const performWorkUntilDeadline = () => {    if (scheduledHostCallback !== null) {      const currentTime = getCurrentTime();      // 分配任务的剩余时间,这个可执行时间是根据fps动态算的      deadline = currentTime + yieldInterval;      const hasTimeRemaining = true;      // 调用已计划的回调,并传递剩余时间和当前时间。      const hasMoreWork = scheduledHostCallback(          hasTimeRemaining,          currentTime,        );        if (!hasMoreWork) {          isMessageLoopRunning = false;          scheduledHostCallback = null;        } else {          // If there's more work, schedule the next message event at the end          // of the preceding one.          port.postMessage(null);        }    } else {      isMessageLoopRunning = false;    }    // 给浏览器一个绘制的机会,并重置需要绘制的标志。    needsPaint = false;  };     const channel = new MessageChannel();  const port = channel.port2;  channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) { scheduledHostCallback = callback; if (!isMessageLoopRunning) { isMessageLoopRunning = true; port.postMessage(null); } };
复制代码

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 等耗时操作,造成输入卡顿。因此我们需要在用户「尝试发生互动」的时候,「退让主线程」。


// 通过Promise实现中断后继续执行,setTimeout调用来延迟任务function yieldToMain () {  return new Promise(resolve => {    setTimeout(resolve, 0);  });}    async function saveSettings(tasks) {      let deadline = performance.now() + 50;
while (tasks.length > 0) { // 判断当前是否有用户交互,isInputPending Chrome87+支持。 // 可以采用判断Expire Time达到类似效果 if ( navigator.scheduling?.isInputPending() || performance.now() >= deadline ) { // 如果有,退让主线程,等主线程任务完成再回来继续执行。 await yieldToMain(); deadline = performance.now() + 50; continue; } const task = tasks.shift(); task(); } }
const performLongTask = () => { // 创建耗时的任务 let task = new Array(10000).fill(null).map((_, i) => () => { const span = document.createElement("span"); span.innerText = i; }); saveSettings(task); // 任务切片 }; input.addEventListener("input", (e) => { input.value = e.target.value; performLongTask(); });
复制代码


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


推荐阅读:


百度搜索展现服务重构:进步与优化


百度APP iOS端包体积50M优化实践(七)编译器优化


百度搜索内容HTAP表格存储系统


大模型时代,“人人可AI”的百度开发者平台长什么样?


数十万QPS,百度热点大事件搜索的稳定性保障实践

发布于: 刚刚阅读数: 4
用户头像

百度Geek说

关注

百度官方技术账号 2021-01-22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
揭开事件循环的神秘面纱_web前端_百度Geek说_InfoQ写作社区