跟着whatwg看一遍事件循环

3次阅读

共计 4200 个字符,预计需要花费 11 分钟才能阅读完成。

前言

对于单线程来说,事件循环可以说是重中之重了,它为任务分配不同的优先级,井然有序的调度。让 js 解析,用户交互,页面渲染等互不冲突,各司其职。

我们书写的代码无时无刻都在和事件循环打交道,要想写出更流畅,我们就必须深入了解事件循环,下面我们将从规范中翻译和解读整个流程。

以下内容来自 whatwg 文档,均为个人理解,若有不对,烦请指出,我会第一时间修改,避免误导他人!

正文

为了协调用户操作,js 执行,页面渲染,网络请求等事件,每个宿主中,存在事件循环这样的角色,并且该角色在当前宿主中是唯一的。

简单解释一下宿主:宿主是一个 ECMAScript 执行上下文,一般包含执行上下文栈,运行时执行环境,宿主记录和一个执行线程,除了这个执行线程外,其他的专属于当前宿主。例如,某些浏览器在不同的 tabs 使用同一个执行线程。

不仅如此,事件循环又存于在各个不同场景,有浏览器环境下的,worker 环境下的和 Worklet 环境下的。

Worklet 是一个轻量级的 web worker,可以让开发者访问更底层的渲染工作线,也就是说你可以通过 Worklet 去干预浏览器的渲染环境。

提到了 worklet,那就顺便看一个例子 (需开启服务,不要以 file 协议运行),通过这个例子,可以看到事件循环不同阶段触发了什么钩子函数:

<!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>
            .fancy {background-image: paint(headerHighlight);
                display: layout(sample-layout);
                background-color: green;
            }
        </style>
    </head>
    <body>
        <h1 class="fancy">My Cool Header</h1>
        <script>
            console.log('开始');
            CSS.paintWorklet.addModule('./paint.js');
            CSS.layoutWorklet.addModule('./layout.js');

            requestAnimationFrame(() => {console.log('requestAnimationFrame');
            });
            Promise.resolve().then(() => {console.log('微任务');
            });
            setTimeout(function () {document.querySelector('.fancy').style.height = '150px';
                ('translateZ(0)');

                Promise.resolve().then(() => {console.log('新一轮的微任务');
                });
                requestAnimationFrame(() => {console.log('新一轮的 requestAnimationFrame');
                });
            }, 2000);
            console.log(2);
        </script>
    </body>
</html>
// paint.js
registerPaint(
    'headerHighlight',
    class {static get contextOptions() {console.log('contextOptions');
            return {alpha: true};
        }

        paint(ctx) {console.log('paint 函数');
        }
    }
);

// ========================== 分割线

// layout.js
registerLayout(
    'sample-layout',
    class {async intrinsicSizes(children, edges, styleMap) {}

        async layout(children, edges, constraints, styleMap, breakToken) {console.log('layout 阶段');
        }
    }
);

事件循环有一个或多个 Task 队列,每个 Task 队列都是 Task 的一个集合。其中 Task 不是指我们的某个函数,而是一个上下文环境,结构如下:

  • step:一系列任务将要执行的步骤
  • source:任务来源,常用来对相关任务进行分组和系列化
  • document:与当前任务相关的 document 对象,如果是非 window 环境则为 null
  • 环境配置对象:在任务期间追踪记录任务状态

这里的 Task 队列不是 Task,是一个集合,因为取出一个 Task 队列中的 Task 是选择一个可执行的 Task,而不是出队操作。

微任务队列是一个入对出对的队列。

这里说明一下,Task 队列为什么有多个,因为不同的 Task 队列有不同的优先级,进而进行次序排列和调用,有没有感觉 react 的 fiber 和这个有点类似?

举个例子,Task 队列可以是专门负责鼠标和键盘事件的,并且赋予鼠标键盘队列较高的优先级,以便及时响应用户操作。另一个 Task 队列负责其他任务源。不过也不要饿死任何一个 task,这个后续处理模型中会介绍。

Task 封装了负责以下任务的算法:

  • Events: 由专门的 Task 在特定的 EventTarget(一个具有监听订阅模式列表的对象) 上分发事件对象
  • Parsing: html 解析器标记一个或多个字节,并处理所有生成的结果 token
  • Callbacks: 由专门的 Task 触发回调函数
  • Using a resource: 当该算法获取资源的时候,如果该阶段是以非阻塞方式发生,那么一旦部分或者全部资源可用,则由 Task 进行后续处理
  • Reacting to DOM manipulation: 通过 dom 操作触发的任务,例如插入一个节点到 document

事件循环有一个当前运行中的 Task,可以为 null,如果是 null 的话,代表着可以接受一个新的 Task(新一轮的步骤)。

事件循环有微任务队列,默认为空,其中的任务由微任务排队算法创建。

事件循环有一个执行微任务检查点,默认为 false,用来防止微任务死循环。

微任务排队算法:

  1. 如果未提供 event loop,设置一个隐式 event loop。
  2. 如果未提供 document,设置一个隐式 document.
  3. 创建一个 Task 作为新的微任务
  4. 设置 setp、source、document 到新的 Task 上
  5. 设置 Task 的环境配置对象为空集
  6. 添加到 event loop 的微任务队列中

微任务检查算法:

  1. 如果微任务检查标志为 true,直接 return
  2. 设置微任务检查标志为 true
  3. 如果微任务队里不为空 (也就是说微任务添加的微任务也会在这个循环中出现,直到微任务队列为空):

    1. 从微任务队列中找出最老的任务 (防饿死)
    2. 设置当前执行任务为这个最老的任务
    3. 执行
    4. 重置当前执行任务为 null
  4. 通知环境配置对象的 promise 进行 reject 操作
  5. 清理 indexdb 事务 (不太明白这一步,如果有读者了解,烦请点拨一下)
  6. 设置微任务检查标志为 false

处理模型

event loop 会按照下面这些步骤进行调度:

  1. 找到一个可执行的 Task 队列,如果没有则跳转到下面的微任务步骤
  2. 让最老的 Task 作为 Task 队列中第一个可执行的 Task,并将其移除
  3. 将最老的 Task 作为 event loop 的可执行 Task
  4. 记录任务开始时间点
  5. 执行 Task 中的 setp 对应的步骤 (上文中 Task 结构中的 step)
  6. 设置 event loop 的可执行任务为 null
  7. 执行微任务检查算法
  8. 设置 hasARenderingOpportunity(是否可以渲染的 flag) 为 false
  9. 记住当前时间点
  10. 通过下面步骤记录任务持续时间

    1. 设置顶层浏览器环境为空
    2. 对于每个最老 Task 的脚本执行环境配置对象,设置当前的顶级浏览器上下文到其上
    3. 报告消耗过长的任务,并附带开始时间,结束时间,顶级浏览器上下文和当前 Task
  11. 如果在 window 环境下,会根据硬件条件决定是否渲染,比如刷新率,页面性能,页面是否在后台,不过渲染会定期出现,避免页面卡顿。值得注意的是,正常的刷新率为 60hz,大概是每秒 60 帧,大约 16.7ms 每帧,如果当前浏览器环境不支持这个刷新率的话,会自动降为 30hz,而不是丢帧。而李兰其在后台的时候,聪明的浏览器会将这个渲染时机降为每秒 4 帧甚至更低,事件循环也会减少 (这就是为什么我们可以用 setInterval 来判断时候能打开其他 app 的判断依据的原因)。如果能渲染的话会设置 hasARenderingOpportunity 为 true。

除此之外,还会在触发 resize、scroll、建立媒体查询、运行 css 动画等,也就是说浏览器几乎大部分用户操作都发生在事件循环中,更具体点是事件循环中的 ui render 部分。之后会进行 requestAnimationFrame 和 IntersectionObserver 的触发,再之后是 ui 渲染

  1. 如果下面条件都成立,那么执行空闲阶段算法,对于开发者来说就是调用 window.requestIdleCallback 方法

    1. 在 window 环境下
    2. event loop 中没有活跃的 Task
    3. 微任务队列为空
    4. hasARenderingOpportunity 为 false

借鉴网上的一张图来粗略表示下整个流程

小结

上面就是整个事件循环的流程,浏览器就是按照这个规则一遍遍的执行,而我们要做的就是了解并适应这个规则,让浏览器渲染出性能更高的页面。

比如:

  1. 非首屏相关性能打点可以放到 idle callback 中执行,减少对页面性能的损耗
  2. 微任务中递归添加微任务会导致页面卡死,而不是随着事件循环一轮轮的执行
  3. 更新元素布局的最好时机是在 requestAnimateFrame 中
  4. 尽量避免频繁获取元素布局信息,因为这会触发强制 layout(哪些属性会导致强制 layout?),影响页面性能
  5. 事件循环有多个任务队列,他们互不冲突,但是用户交互相关的优先级更高
  6. resize、scroll 等会伴随事件循环中 ui 渲染触发,而不是根据我们的滚动触发,换句话说,这些操作自带节流
  7. 等等,欢迎补充

最后感谢大家阅读,欢迎一起探讨!

提前祝大家端午节 nb

参考

composite

深入探究 eventloop 与浏览器渲染的时序问题

正文完
 0