Published on

渲染帧与事件循环与react concurrent

Authors

事件循环与一帧的关系

一帧的时间内,浏览器会尽可能地执行JavaScript代码在一帧末尾进行页面渲染。 而不是在执行每段JavaScript代码后都进行渲染。即并不是每一轮消息循环结束时都会产生一个渲染任务。 这要根据屏幕刷新率、页面性能、页面是否在后台运行等来共同决定,通常来说这个渲染间隔是固定的。通常决定渲染时机的因素有如下几点:

  • 显示器一般帧率为 60fps(每 16.66ms 渲染一次),如果页面性能维持不了 60fps,浏览器会降到 30fps 以保证渲染能够进行下去。
  • 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
  • 如果浏览器判定当前改动不会引起视觉变化或者 requestAnimationFrame 回调为空时,则会跳过渲染。
  • 在页面 resize、页面 scroll、requestAnimationFrame 调用、IntersectionObserver 触发显示、元素显示、隐藏或结构变化时,一般在渲染间隔到来时会推送一个渲染任务。

浏览器一帧的组成

anatomy-of-a-frame

对于此图浏览器并不需要执行所有步骤,具体情况取决于哪些步骤是必需的。例如,如果没有新的 HTML 要解析,那么解析 HTML 的步骤就不会触发。

一帧生命流程详细解释

1.开始新的一帧。垂直同步信号触发,开始渲染新的一帧图像。

2.事件的处理。从合成线程将输入的数据,传递到主线程的事件处理函数.

3.requestAnimationFrame。此处适合做动画或更新屏幕显示内容

4.解析 HTML(Parse HTML)

5.重新计算样式(Recalc Styles)。为新添加或变更的内容计算样式

6.布局(Layout)。计算每个可见元素的几何信息(每个元素的位置和大小)。一般作用于整个文档,计算成本通常和 DOM 元素的大小成比例。

7.更新图层树(Update Layer Tree)。创建层叠上下文,为元素的深度进行排序

8.Paint。过程分为两步:第一步,对所有新加入的元素,或进行改变显示状态的元素,记录 draw 调用(这里填充矩形,那里写点字);第二步是栅格化(Rasterization,见后文),在这一步实际执行了 draw 的调用,并进行纹理填充。Paint 过程记录 draw 调用,一般比栅格化要快,但是两部分通常被统称为“painting”。

9.合成(Composite)。图层和图块信息计算完成后,被传回合成线程进行处理。这将包括 will-change、重叠元素和硬件加速的 canvas 等。

10.栅格化规划(Raster Scheduled)和栅格化(Rasterize)。在 Paint 任务中记录的 draw 调用在此步骤执行。

11.帧结束。各个层的所有的块都被栅格化成位图后,新的块和输入数据(可能在事件处理程序中被更改过)被提交给 GPU 线程。

12.requestIdleCallback(若还有剩余时间)。在帧结束时,主线程还有点时间,requestIdleCallback 可能会被触发。

execute-of-requestIdleCallback

13.发送帧。图块被 GPU 线程上传到 GPU。GPU 使用四边形和矩阵(所有常用的 GL 数据类型)将图块 draw 在屏幕上

两种图层(拓展阅读)

在工作流程中深度的排序有两种版本。

首先是层叠上下文,比如有 2 个绝对定位的重叠的 div。更新图层树(Update Layer Tree) 是流程的一部分,保证 z-index 和类似的属性受到重视。

然后是合成图层,合成线程负责计算出每一个位图在屏幕上的位置,交给 GPU 进行最终呈现。 这里来解释一下,渲染进程实际上是在沙盒里边运行的,其没有操作硬件的能力,所以这里必须要交给 GPU 进程过渡。 比较神奇的是,css 样式 transform 并不是在光栅化中生成像素点,而是在这一步真正要画的时候决定的,就是在 GPU 做一个矩阵变换

注意点

  1. reflow(回流)是什么? 回流的本质是重新计算布局树。当进行了影响布局的操作后(cssom改变),会引发一次 layout,如下图
reflow

开发过程中,代码应该避免频繁修改布局树(height/width/margin/padding...)

  1. repaint(重绘)是什么 ? 本质是重新根据分层信息计算绘制的指令,回流一定会引起重绘
repaint
  1. 为什么 transform 效率高 ? 计算 transform 是在最后一步(draw)时,针对本层级的元素,在 GPU 中执行变换的,不会引起回流和重绘

  2. requestAnimationFrame 的回调有两个特点

    1. 在重新渲染前调用。 保证了在渲染任务执行之前执行完想要改变的元素,保证了动画的流畅,不会拖延到下一帧再渲染
    2. 回调合并执行。 多个requestAnimationFrame的callback函数会在同一帧内执行,而不是放到下一帧执行。
  3. requestIdleCallback 如果浏览器的工作比较繁忙的时候,不能保证它会提供空闲时间去执行 rIC 的回调,而且可能会长期的推迟下去。所以如果你需要保证你的任务在一定时间内一定要执行掉,那么你可以给 rIC 传入第二个参数 timeout。 这会强制浏览器不管多忙,都在超过这个时间之后去执行 rIC 的回调函数。所以要谨慎使用,因为它会打断浏览器本身优先级更高的工作。

拓展的一些关于渲染相关的问题

渲染帧与JavaScript

 function btn() {
        console.log('test btn')
        // btn(); 第一种情况
        // setTimeout(btn,0) 第二种情况
        // Promise.resolve().then(btn) 第三种情况
    }
    const normal = document.getElementById("normal")
    normal.addEventListener('click', btn)

上面的三种死循环 UI表现如何 会卡死嘛?

1.针对第一种情况。同步任务,直接调用btn函数会导致同步的无限递归,这个操作会迅速耗尽调用栈空间,在很短的时间内引发“RangeError: Maximum call stack size exceeded”错误。

sync_frame

2.针对第二种情况。task任务队列,UI可正常滚动 不会抛出栈溢出异常。setTimeout 引入了延迟,将下一个btn调用放到事件循环队列中,而不是直接递归调用。这允许浏览器在两次调用之间处理其他事件,包括UI事件,比如滚动和渲染。因此,即使btn函数持续调用,页面也不会立即卡死。

task_frame

3.针对第二种情况。micro-task任务对列,通过Promise不断地以递归方式创建微任务,无限递归的生成微任务 微任务队列永远不会为空 js线程会一直执行微任务 浏览器没有机会去执行其他宏任务,如UI渲染或事件监听器的调用, 这会导致UI渲染被阻塞,界面无法响应用户操作,体验到的结果就如同UI卡死一样。

microtask_frame

Recap:

1.task:JavaScript引擎使用事件循环来管理异步操作,task任务在每次事件循环迭代中执行,task任务之间会有断点,每个task任务完成后,调用栈都会清空,然后处理下一个宏任务。这意味着即使是连续安排的多个宏任务,也不会导致调用栈累积而溢出。 2.microtask:微任务在当前task任务完成后、下一个task任务开始前执行。虽然微任务是连续执行的,但每个微任务都是独立入栈的;即使它们形成了长队列,每次只处理一个微任务,处理完后就从调用栈中弹出。因此,即使微任务队列很长,每次执行完毕都会清空调用栈,不会累积导致栈溢出

Quoter:

https://juejin.cn/post/7355063847382810635#heading-2

并发模型与事件循环

JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。

多个运行时互相通信

一个 web worker 或者一个跨域的 iframe有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

React concurrent原理

React concurrent模式的原理便是通过requestAnimationFrame 与 MessageChannel 实现的

Quoter