Event Loop与浏览器渲染

427 阅读6分钟

前言

在写这篇文章之前,首先提出几个问题:

  1. 浏览器事件循环的流程是怎么样的?
  2. RequestAnimationFrame回调的执行时机是在哪个阶段,执行频率是怎么样的?是否属于宏任务?
  3. 为什么说setTimeout不够精准,不适合用来做动画?

Event Loop

定义

为了协调事件、用户交互、脚本、UI渲染、网络任务等、浏览器必须使用eventloop。

执行流程

An event loop must continually run through the following steps for as long as it exists:

  1. Let oldestTask be the oldest task on one of the event loop’s task queues, if any, ignoring, in the case of a browsing context event loop, tasks whose associated Documents are not fully active. The user agent may pick any task queue. If there is no task to select, then jump to the microtasks step below.
  2. Set the event loop’s currently running task to oldestTask.
  3. Run oldestTask.
  4. Set the event loop’s currently running task back to null.
  5. Remove oldestTask from its task queue.

1-5. 从 task 队列(一个或多个)中选出最老的一个 task,执行它。

  1. Microtasks: Perform a microtask checkpoint.
  1. 执行 microtask 检查点。简单说,会执行 microtask 队列中的所有 microtask,直到队列为空。如果 microtask 中又添加了新的 microtask,直接放进本队列末尾。
  1. Update the rendering: If this event loop is a browsing context event loop (as opposed to a worker event loop), then run the following substeps.
  1. Let now be the value that would be returned by the Performance object’s now() method. [HRT]
  2. Let docs be the list of Document objects associated with the event loop in question, sorted arbitrarily except that the following conditions must be met:
  3. If there are top-level browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context’s top-level browsing context is in B.
  4. If there are a nested browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context is in B.
  5. For each fully active Document in docs, run the resize steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
  6. For each fully active Document in docs, run the scroll steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
  7. For each fully active Document in docs, evaluate media queries and report changes for that Document, passing in now as the timestamp. [CSSOMVIEW]
  8. For each fully active Document in docs, run CSS animations and send events for that Document, passing in now as the timestamp. [CSSANIMATIONS]
  9. For each fully active Document in docs, run the fullscreen steps for that Document, passing in now as the timestamp. [FULLSCREEN]
  10. For each fully active Document in docs, run the animation frame callbacks for that Document, passing in now as the timestamp.
  11. For each fully active Document in docs, run the update intersection observations steps for that Document, passing in now as the timestamp. [INTERSECTIONOBSERVER]
  12. For each fully active Document in docs, update the rendering or user interface of that Document and its browsing context to reflect the current state.
  1. 执行 UI render 操作:

    7.1-7.4. 判断 document 在此时间点渲染是否会『获益』。浏览器只需保证 60Hz 的刷新率即可(在机器负荷重时还会降低刷新率),若 eventloop 频率过高,即使渲染了浏览器也无法及时展示。所以并不是每轮 eventloop 都会执行 UI Render

    7.5-7.9. 执行各种渲染所需工作,如 触发 resize、scroll 事件、建立媒体查询、运行 CSS 动画等等

    7.10. 执行 animation frame callbacks

    7.11. 执行 IntersectionObserver callback

    7.12. 渲染 UI

Performance中观察Event Loop

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #SomeElementYouWantToAnimate {
        height: 200px;
        width: 200px;
        background: red;
      }
    </style>
  </head>
  <body>
    <div id="SomeElementYouWantToAnimate" style="position: absolute;"></div>
    <script>
      var start = null; 
      var element = document.getElementById("SomeElementYouWantToAnimate")
      function step(timestamp) {
        if (!start) start = timestamp
        var progress = timestamp - start
        element.style.left = Math.min(progress / 10, 200) + "px"
        if (progress < 2000) {
          window.requestAnimationFrame(step)
        }
      }
      // 动画
      window.requestAnimationFrame(step)
​
      const consoleText2 = () => {
        console.log('微任务');
      }
​
      const consoleText1 = () => {
        console.log('微任务');
      }
​
      const consoleText = () => {
        console.log('微任务');
      }
​
      function render() {
        consoleText();
        consoleText2();
        consoleText1();
        Promise.resolve().then(() => {
          consoleText2();
        })
      }
​
      Promise.resolve().then(render)
    </script>
  </body>
</html>

从浏览器的火焰图中,我们证实以下信息:

  1. 可以看到 micro task 只是 task 的一部分,宏任务执行完就会执行所有的微任务。
  2. RequestAnimationFrame的执行时机在绘制之前(但是回调中注册的微任务也会在本轮事件循环中执行)

Task

task 又称 macrotask。

一个 eventloop 有一或多个 task 队列。每个 task 由一个确定的 task 源提供。从不同 task 源而来的 task 可能会放到不同的 task 队列中。例如,浏览器可能单独为鼠标键盘事件维护一个 task 队列,所有其他 task 都放到另一个 task 队列。通过区分 task 队列的优先级,使高优先级的 task 优先执行,保证更好的交互体验。

task 源包括:

  • DOM 操作任务源:如元素以非阻塞方式插入文档
  • 用户交互任务源:如鼠标键盘事件。用户输入事件(如 click) 必须使用 task 队列
  • 网络任务源:如 XHR 回调
  • history 回溯任务源:使用 history.back() 或者类似 API

此外 setTimeout、setInterval、IndexDB 数据库操作等也是任务源。总结来说,常见的 task 任务有:

  • 事件回调
  • XHR 回调
  • IndexDB 数据库操作等 I/O
  • setTimeout / setInterval
  • history.back

MicroTask

每一个 eventloop 都有一个 microtask 队列。microtask 会排在 microtask 队列而非 task 队列中。

一般来说,microtask 包括:

  • Promise.then

    Promise 规范中提及 Promise.then 的具体实现由平台把握,可以是 microtask 或 task。当前的共识是使用 microtask 实现。

  • MutationObserver

  • Object.observe

RequestAnimationFrame的执行时机

在解读规范的过程中,我们发现RequestAnimationFrame回调的执行时机有两个特征:

  1. 在重新渲染之前执行
  2. 很可能在宏任务之后不调用

所以:RequestAnimationFrame的回调执行时机其实是不确定的。正常情况、60fps的情况下是一秒执行60次。但是由于浏览器存在掉帧、宏任务执行之后不执行UI Render流程等情况,导致RequestAnimationFrame的回调不执行。

Tips: RequestAnimationFrame回调中注册的微任务会在本次事件循环中执行。

正常情况下的 RequestAnimationFrame的表现

比较稳定,以60fps的刷新率执行ReqeustAnimationFrame的回调

将当前Tab置于后台的表现

将当前Tab置于后台时,浏览器在rendering opportunity中发现当前Tab在后台运行,所以不会进入UI Render阶段,ReqeustAnimationFrame的回调也就不会执行。

高刷显示器下RequestAnimationFrame的表现

用刚买的MBP14试一下,发现RequestAnimationFrame的执行频率达到了8.3ms一次!120hz果然不是骗人的!

image.png

总结

  1. 事件循环首先会从宏任务队列中拿出一个宏任务执行,接着会执行微任务队列中所有的微任务,这个时候如果有新的微任务产生,也会在本次循环中执行掉。执行完之后就进入UI Render阶段,UI Render之前有一个rendering opportunity,浏览器会根据刷新率、页面性能、是否在后台运行、上下文是否可见等来决定是否进行重新渲染。假设确定需要重新渲染的话,在渲染之前会执行resize、scroll、RequestAnimation、IntersectionObserver的回调、最终重新渲染页面。
  2. RequestAnimationFrame的回调执行时机是每一轮事件循环的重新渲染之前。但是并不是每次事件循环都伴随着重新渲染,所以RequesAnimationFrame的回调执行频率是不确定的,RequestAnimationFrame是否属于宏任务?这个有争议,从eventloop的流程上来看执行task、执行microtask、UI render三部分,requesAnimationFrame说是宏任务不太合适,但是从回调函数的执行过程上来看和宏任务是一致的,回调函数中产生的微任务都会在本轮的时间循环中执行。
  3. setTimeout定时器只是在定时器结束的时候将回调放到宏任务队列中,并不是意味着到一定时间点就马上执行,可能需要等前面的任务先执行完成之后再执行。并且如果如果时间间隔过短(小于16.7ms),浏览器会判定这两次宏任务之间是不需要进行UI Render的,会造成掉帧,动画效果不会按照我们预期的执行。