你不知道的事件循环和渲染帧(下)

848 阅读14分钟

引言

浏览器事件循环和渲染帧相关概念对大多数前端开发人员来说是比较耳熟能详的,但本系列将通过chromium源码,html 规范,实际浏览器执行顺序测试等多个角度,展示它们不为人知的一面。由于本篇是下篇,难免会用到上篇中已经详细解释过的内容,为了更好的阅读体验,请观看本篇前确保已经看过你不知道的事件循环和渲染帧(上)相关内容

上篇涉及的问题

  • 任务队列到底是一个怎样的数据结构(并非 FIFO 的队列)?
  • 时间循环真的只是一个js相关的概念吗?
  • html规范中的事件循环具体是什么?

本篇涉及的问题

  • 微任务会堵塞页面渲染吗?
  • raf 注册的回调和宏任务谁先执行?
  • raf 内注册大量回调,会堵塞页面渲染吗?
  • 微任务是否只在宏任务之后执行?

在进入正文之前,先来看下这段代码吧:

<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      body {
        background-color: red;
      }
    </style>
  </head>
  <body>
    <script>
      const changBg = () => {
        document.body.style.background = "white";
      };
      // setTimeout(changBg, 0); // 场景 1
      // requestAnimationFrame(changBg) // 场景 2
      // Promise.resole().then(changBg); // 场景 3
    </script>
  </body>
</html>

请分析一下,只执行场景 1,页面是如何变化的?只执行场景 2,只执行场景 3 呢?

如果你多刷新几下页面会发现,场景 1 也就是定时器触发changBg的页面,偶尔会有红色闪过,剩余 promise,和 raf 触发changBg的页面不会有红色闪过。但是想要解释清楚这个问题,就需要研究一下本系列的主角,事件循环和渲染帧了。其中事件循环相关知识请参考你不知道的事件循环和渲染帧(上)

渲染帧

首先要澄清一点的是,目前好像是没有渲染帧render frame的概念的,能看到的概念是帧frame.一次渲染为一帧,一般一帧的时间长度为 16.6ms,因为时间过短,人眼感受不到就浪费性能,时间过长则表现为卡顿。接下来主要用大量(1000个)耗时中等(2ms左右)的函数来做页面堵塞渲染测试,并分析解答引言中的问题。

帧和事件循环的关系

应该说一帧里包含了多次事件循环,两次可以 render update 的 loop 之间为一次有内容变化的帧。

大量微任务会堵塞页面渲染吗?

Perform a microtask checkpoint规范如下,实际上只看其中几条就够了:

  1. If the event loop's performing a microtask checkpoint is true, then return.如果这个 checkPoint 值为 true,就 return
  2. Set the event loop's performing a microtask checkpoint to true. 如果不是 true,就设置成 true
  3. While the event loop's microtask queue is not empty: 当 event loop 的微任务队列不为空时,执行以下操作:
    1. Let oldestMicrotask be the result of dequeuing from the event loop's microtask queue. 从微任务队列出队第一个微任务并将变量oldestMicrotask的值设置为该任务
    2. Set the event loop's currently running task to oldestMicrotask. 设置currently running task变量值为oldestMicrotask
    3. Run oldestMicrotask. 执行oldestMicrotask
    4. Set the event loop's currently running task back to null.将currently running task的值变会 null
  4. Set the event loop's performing a microtask checkpoint to false. 这里其实是第 7 步,456 对理解逻辑没啥影响,将 checkpoint 值设置为 false 通过第三步可以知道,不把微任务队列清空,是不会结束这个Perform a microtask checkpoint流程的,所以也就不会进入后续有可能存在的渲染步骤。继而堵塞了页面的渲染。
// 伪代码实现
const checkpoint = false;
const microtaskQueue = [];
let oldestMicrotask = null;
function runCheckpoint() {
  if (checkpoint) return;
  while (microtaskQueue.length) {
    oldestMicrotask = microtaskQueue.pop();
    oldestMicrotask();
  }
}

// ---执行相关逻辑,可能会向microtaskQueue中push microtask.
runCheckpoint();

测试代码:

<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      function midTaskFn() {
        // midTaskFn是个耗时3ms左右的函数
        for (let i = 0; i < 10000000; i++) {}
      }
      for (let i = 0; i < 1000; i++) {
        Promise.resolve().then(midTaskFn);
      }
    </script>
  </body>
</html>

promiseBlock.jpg 从上图可以看出,Run Microtasks 耗时 3.27s,直到其执行结束以后,才开始执行 fp,进行页面渲染,所以大量微任务可能堵塞页面渲染

raf 内注册大量回调,会堵塞页面渲染吗?

具体规范在run the animation frame callbacks,其中第 3 点描述了对callbackHandles中存在的每个回调,依次执行。所以raf 内注册大量回调可能堵塞页面渲染。下面是测试代码和实验截图:

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="content">init content</div>
    <script>
      function midTaskFn() {
        for (let i = 0; i < 10000000; i++) {}
      }
      requestAnimationFrame(() => {
        content.textContent = "raf";
      });
      for (let i = 0; i < 1000; i++) {
        requestAnimationFrame(midTaskFn);
      }
    </script>
  </body>
</html>

rafBlock.jpg 同样的有个 task 耗时 3.26s,其结束以后才是 fp,不过这里有个需要注意的现象,第一个 raf 回调把 content 元素的 text 值改成了“raf”,在页面首次绘制的是时候,其实是不会出现原始的“init content”这个内容的

定时器内注册大量回调,会堵塞页面渲染吗?

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="content">content</div>
    <script>
      function midTaskFn() {
        for (let i = 0; i < 10000000; i++) {}
      }
      for (let i = 0; i < 1000; i++) {
        setTimeout(midTaskFn);
      }
    </script>
  </body>
</html>

timeOut.jpg

empty.jpg 本次测试加了一组没有这行循环逻辑的代码来做对照,实际时间对比如下,有大量定时器情况下,fp出现在49ms,对照组则是29ms。由此可以得到一个推论,定时器回调注册的宏任务并不会堵塞页面渲染,但是可能会推迟页面渲染时间。

什么样的宏任务会堵塞页面渲染呢?

按照之前的分析,用户交互类任务应该是优先级比较高的,这类型的任务应该会堵塞渲染,为了眼见为实,还是要具体测试下

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="content">content</div>
    <button id="btn">click me</button>

    <script>
      function longTaskFn() { // 500ms左右
        for (let i = 0; i < 1000000000; i++) {}
        console.log("long task", count);
      }
      let count = 0;
      function rafCb() {
        requestAnimationFrame(() => {
          content.textContent = ++count;
          console.log("raf", count);
          rafCb();
        });
      }
      rafCb();
      btn.addEventListener("click", longTaskFn);
    </script>
  </body>
</html>

本次测试代码做了一下修改,具体逻辑是在每帧的raf中注册回调修改页面渲染,然后通过点击事件执行一个500ms左右的函数(500ms主要是因为时间再短,两次敲击间任务就执行完毕了),来模拟有多个较高优先级任务在队列中排队待执行的情况。

eventBlock.jpg 这个测试的效果,用log顺序更直观一些,可以看到在任务队列中有多个clcik对应优先级任务要执行的情况下,页面要等所有click回调注册的宏任务执行完毕才会进行渲染。

文章首段代码执行逻辑分析

这里要讲一下浏览器的是如何绘制页面的,它首先是发送请求,拉取到 html 资源,然后解析 html 资源,碰到脚本会停下来执行脚本,碰到其他资源如 css,js,图片等,会去拉取资源,初始化各种环境,然后生成对应 dom,cssom,进行布局,绘制,合成,光栅化,渲染等,前面描述的这些任务会占据一定的时间,而且这个时间可能每次刷新还不太一样。
而页面有红色闪动,则是因fp的时候,对应修改颜色函数还未执行,此时页面是红色,待下一帧该函数执行以后,页面变为白色,一帧时间很短所以会有红色闪动的效果。基于此我们对三个场景分别做下分析:

  1. 场景1,定时器回调修改页面颜色。由于定时器任务优先级不高,所以它能否在fp之前执行,完全取决于上面任务占用的时间。而这个时间也是不确定的,所以就出现了多次刷新下偶尔页面有红色闪动的情况。
  2. 场景2,raf回调修改页面颜色。raf注册的回调会在本帧渲染前执行,由于这是第一个宏任务中注册的,所以fp前它就执行了,于是页面不会有红色闪动
  3. 场景3,promise.then内注册的回调修改页面颜色。then内注册的函数,会在本次宏任务结束,js call stack清空时执行,执行时机比场景2还要早,于是页面也不会有红色闪动

timeOutFp.jpg 关于渲染要多说一句的是,虽然从performace图上看,定时器激活(定时器内注册函数执行)貌似比fp的时间线要早,但是仔细观察,会看到其前面有个绿色块,该绿色块对应的是一帧的commit,因为本次渲染定时器在这个commit之后,所以fp时就不会渲染出白色页面了。

不同任务执行逻辑的分析

本节将详细分析微任务执行时机,raf执行和宏任务执行关系以及idle执行时机。

raf 注册的回调(rafCbs)和宏任务谁先执行?

根据相关规范:

  • 宏任务则在本轮事件循环的第 2 步执行。
  • raf 注册的回调在本轮事件循环可以渲染的情况下,在第 7 步批量执行。

只看规范,貌似宏任务在 rafCbs 之前执行,但是这里还是要具体分析,参考上面定时器堵塞渲染和点击事件堵塞渲染两个测验,如果这个任务优先级低于渲染任务,那这个任务其实是在 rafCbs 之后执行的。无论其他宏任务优先级怎么样,只要渲染触发,rafCbs就会执行,所以可以理解成,如果这个宏任务在渲染任务执行前执行,那它会在 rafCbs 之前执行,反之,则在 rafCbs 之后。

关于微任务执行时机的测验和分析

这里只是对微任务执行时机做一些分析,具体像promise单链,promise多链,以及promise和async函数之间执行顺序和具体逻辑的分析,会在下篇单独讲解。
前置知识1: 用户点击a标签时,如果有绑定click事件,先触发事件回调,等回调结束(可以理解成js部分执行完毕),再执行默认跳转跳转行为。
前置知识2: promise的then方法内注册的回调函数会让存储到当前promise对象回调数组上,当resolve调用时,才会让回调数组内函数去微任务中排队

<!DOCTYPE html>
<html lang="en">
  <body>
    <a id="link" href="https://sf.163.com/">link</a>
    <script>
      new Promise((res) => {
        link.addEventListener("click", res, { once: true }); // 1
      }).then((e) => {
        e.preventDefault(); // 2 阻止默认跳转行为
        console.log("click");
      });
      link.click(); // 3 
      // 用户点击 // 4
    </script>
  </body>
</html>

本例中,如果手动点击 a 标签,页面不会跳转。但是如果使用link.click()触发,则会发生页面跳转。
link.click()触发点击事件分析:

  • html parser执行,anonymous函数入栈 栈深 1
  • Promise入栈 栈深 2
  • addEventListener入栈 栈深 3
  • addEventListener出栈 栈深 2
  • promise出栈 栈深 1
  • then方法将其内注册的回调绑定到promise上
  • link.click触发
  • 触发事件相关回调执行,res入栈 栈深 2
  • res让then内注册回调进入微任务队列排队
  • res出栈 栈深 1
  • 执行默认跳转逻辑,此时由于call stack还不为空,所以then内阻止跳转的回调不能执行,于是页面进行跳转
  • html parser执行完毕,出栈 栈深 0
  • 栈空以后,checkPoint算法开始执行,执行此时正在排队的then内回调,但阻止默认跳转的时机已经过去了。

用户操作触发点击事件分析:

  • html parser执行,anonymous函数入栈 栈深 1
  • Promise入栈 栈深 2
  • addEventListener入栈 栈深 3
  • addEventListener出栈 栈深 2
  • promise出栈 栈深 1
  • then方法将其内注册的回调绑定到promise上
  • html parser执行完毕,出栈 栈深 0
  • n轮事件循环以后,用户点击按钮触发点击事件
  • 点击事件回调执行,res入栈 栈深 1
  • res让then内注册回调进入微任务队列排队
  • res出栈 栈深 0
  • 此时由于call stack为空,checkPoint算法开始执行,执行此时正在排队的then内回调,阻止默认跳转行为
  • 回调结束,执行默认跳转行为,由于已经被阻止了,所以不再跳转。

这里如果把link.click()换成setTimeout(() => link.click(), 1000);,效果是一样的,也会跳转,原理也是相同的,可以自己尝试分析下。

<!DOCTYPE html>
<html lang="en">
  <body>
    <button id="btn">click me</button>
    <script>
      btn.addEventListener("click", () => {
        console.log("task1");
        Promise.resolve().then(() => {
          console.log("microtask1");
        });
      });
      btn.addEventListener("click", () => {
        console.log("task2");
        Promise.resolve().then(() => {
          console.log("microtask2");
        });
      });
      btn.click();
    </script>
  </body>
</html>

第二个案例用户点击log顺序(task1,microtask1,task2,microtask2)和btn.click()触发log顺序(task1,task2,microtask1,microtask2)也不一样,具体原理和案例1相同,要考虑当前js call stack是否为空。可以尝试自己分析一下。

微任务是否只在宏任务之后执行?

首先可以明确的一点是js call stack清空以后,一定会尝试执行微任务。但是宏任务之后和js call stack清空并不是相同的概念。因为raf回调并不是宏任务,所以能用raf回调内注册的微任务是否可以执行来验证这个说法。

rafPro.jpg 字符串“test”可以被log出来,所以微任务不只在宏任务之后执行

详解requestidlecallback并测验执行时机

上篇中有对requestidlecallback一些简略的讲解,这里将参照requestidlecallback 草案进行解读。

通常一帧会有16.6ms,而执行完这一帧内的任务以后,距离帧渲染可能还有一段时间,可以称这一段时间为空闲期,为了利用起来这段空闲期,浏览器定义了requestidlecallbackapi,来注册需要在空闲期内运行的函数。
相关类型定义如下

function requestIdleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): number;

// options类型如下
interface IdleRequestOptions {
    timeout?: number;
}

// 回调函数类型如下
interface IdleRequestCallback {
    (deadline: IdleDeadline): void;
}

// 回调函数参数类型如下
interface IdleDeadline {
    readonly didTimeout: boolean;
    timeRemaining(): DOMHighResTimeStamp; // 一个高精度时间,返回本轮空闲期还有多久结束
}

空闲期 Idle Periods

其实事件循环规范的8.2: 计算deadline,就是在计算本轮空闲期的时长,它会比对50ms,下次渲染时间,下次定时任务激活时间,在三者中选择最小值。而这和w3c关于空闲期的说法是一致的the user agent's main thread often becomes idle until either: the next frame begins; another pending task becomes eligible to run; or user input is received.关于突然的用户输入,由于无法计算,8.2中也没有涉及。 image01.png 上图在很多地方被诠释为经典帧图,其实草案中明确说了这只是空闲期的一种示意,并不是说空闲期一定出现在渲染更新以后,而从api的角度考虑的话,requestidlecallback也可能出现在requestAnimationFrame之前。
空闲期一次多50ms,如果50ms过去以后,没有新的高优先级任务,会再安排下一段空闲期。
只有在当前空闲期开始之前使用requestidlecallback注册的回调函数才可以在本次空闲期运行。如果在本次空闲期执行的回调函数中再次调用requestidlecallback注册空闲期任务,那其将在下个空闲期运行。

关于空闲期出现时机的证明

requestidlecallback可能出现在raf之前(各浏览器实现可能不一致,目前看来FireFox的实现和html的规范表现是一致的,chrome则不太一样):

<!DOCTYPE html>
<html lang="en">
  <body>
    <script>
      const now = performance.now();

      requestAnimationFrame(function rafFn() {
        for (let i = 0; i < 10000000; i++) {}
        console.log("rafFn", performance.now() - now);
      });

      requestIdleCallback(function requestIdleCallbackFn() {
        for (let i = 0; i < 10000000; i++) {}
        console.log("requestIdleCallback", performance.now() - now);
      });
    </script>
  </body>
</html>

ridle3.jpg 一帧中出现多个空闲期的证明(FireFox可以实现一帧多个空闲期,chrome则一帧只出现一次空闲期,相同的是空闲期都可以出现在挂起任务(timeOut)未激活前):

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="content">0</div>
    <script>
      let count = 0;

      function idleFn(deadline) {
        if (deadline) console.log("deadLine::", deadline.timeRemaining());
        requestIdleCallback(idleFn);
      }

      const rafFn = () => {
        console.warn("raf");
        requestAnimationFrame(() => {
          content.innerText = ++count;
          rafFn();
        });
      };

      const timeOutFn = () => {
        console.log("timeOut");
        setTimeout(timeOutFn, 0);
      };

      idleFn();
      rafFn();
      timeOutFn();
    </script>
  </body>
</html>

chrome log截图: ridle5.jpg FireFox log截图:

redle4.jpg

一个空闲期内会尽量多的执行idle任务

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="content">0</div>
    <script>
      let count = 0;

      function idleFn(deadline) {
        for (let i = 0; i < 1000000; i++) {}
        if (deadline) console.log("idleFn::", count, deadline.timeRemaining());
        requestIdleCallback(idleFn);
      }

      const rafFn = () => {
        console.warn("raf");
        requestAnimationFrame(() => {
          content.innerText = ++count;
          rafFn();
        });
      };
      for (let i = 0; i < 10; i++) {
        idleFn();
      }
      rafFn();
    </script>
  </body>
</html>

每个空闲期注册了10个idle任务(即调用了requestIdleCallback 10次):

ridle6.jpg

结语

本系列较为详细的介绍了事件循环和渲染帧相关知识,前者主要从源码层面解释了task是如何生成和存储的,后者则主要采用测验的形式阐述了各种任务和渲染之间的关系。
不过关于浏览器渲染,仍然还有很多值得讲的地方,后续应该会从这个角度再跟进一篇浏览器渲染相关文章,会着重于渲染的各个方面和浏览器各自线程和进程的解读。
再往后应该会补一篇promsie源码,async函数实现相关,从源码角度分析微任务执行的具体顺序和逻辑。

参考