揭开事件循环的神秘面纱

揭开事件循环的神秘面纱_第1张图片

作者 | 小萱

导读

这篇文章会全方位讲解事件循环机制,从这篇文章你可以学到,「事件循环」和「浏览器渲染」的关系,浏览器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.2 多线程

每个浏览器进程都可以包含多个线程。例如,主线程用于执行 JavaScript 代码和处理页面布局,而其他线程可能用于网络请求、渲染等任务。

主线程

Web 应用程序需要在此单个主线程上执行某些关键操作。当您导航到 Web 应用程序时,浏览器将创建并向您的应用程序授予该线程,以便您的代码在其上执行。

主线程指的是渲染进程下的主线程,负责解析HTML、计算CSS样式、执行JavaScript、计算布局、绘制图层等任务。

揭开事件循环的神秘面纱_第3张图片

△主进程即渲染进程包含的线程图

某些任务必须 在主线程上运行。例如,任何直接需要访问 DOM(即 DOM document)的操作都必须在主线程上运行(因为 DOM 不是线程安全的)。这将包括大多数 UI 相关代码。

主线程上一次只能运行 一个任务

此外,一个任务必须在主线程上运行完成,然后才能运行另一个任务。浏览器没有“部分”执行任务的机制,每个任务都完整地运行直至完成。

在下面的示例中,在浏览器展示界面的时候,按顺序运行下面的任务,并且每个任务都在主线程上完成:

揭开事件循环的神秘面纱_第4张图片

03 事件循环的具体流程

我们这里主要讨论的是 window event loop。也就是浏览器一个渲染进程内主线程所控制的 Event Loop。

揭开事件循环的神秘面纱_第5张图片

△发生一次事件循环的具体流程

发生一次事件循环,也就是浏览器一帧中可以用于执行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呢,可以采取延迟到下一帧渲染。

揭开事件循环的神秘面纱_第6张图片

△演示效果

    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也可以达到同样的延迟效果。

揭开事件循环的神秘面纱_第7张图片

△延迟后的演示效果

04 任务队列与执行时机

执行 JavaScript task 是在渲染之前,如果在一帧之内 JavaScript 执行时间过长就会阻塞渲染,同样会导致丢帧、卡顿,这里的js执行时间过长,就是长任务,下面会仔细介绍。

对长任务的定义:如果任务耗时超过50ms,则认为该任务是长任务。

当我们谈到长任务造成页面卡顿时,通常指的是主线程(Main Thread)上的任务。主线程指的是渲染进程下的主线程,负责解析HTML、计算CSS样式、执行JavaScript、计算布局、绘制图层等任务。当主线程上的一个任务(例如一个JavaScript函数)运行时间过长时,它会阻塞主线程上的其他任务,包括但不限于UI更新和用户交互事件的处理,从而导致页面卡顿或不响应。

JS的执行和渲染的关系:

JS执行与Paint任务都发生在主线程,具体的绘制操作是交由合成线程完成,与主线程并不互斥,但是JS的执行时间过长,会导致Paint整理好的数据没有及时提交给合成线程,因此页面有帧没有执行绘制,也就是掉帧。

揭开事件循环的神秘面纱_第8张图片

揭开事件循环的神秘面纱_第9张图片

△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 相同。

揭开事件循环的神秘面纱_第10张图片

△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成为标准性能指标。

揭开事件循环的神秘面纱_第11张图片

△交互到绘制的时间

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不仅在缓存方面表现优秀,也可以帮我们实现离线应用,以及无法被浏览器强缓存的文件手动添加缓存(不同浏览器对可以强缓存的文件的体积限制不同)。

揭开事件循环的神秘面纱_第12张图片

△使用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,百度热点大事件搜索的稳定性保障实践

你可能感兴趣的:(前端,web,事件循环)