作者 | 小萱
导读
这篇文章会全方位讲解事件循环机制,从这篇文章你可以学到,「事件循环」和「浏览器渲染」的关系,浏览器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 接口参考 | MDN(https://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
推荐阅读: