20

跟着whatwg看一遍事件循环

 4 years ago
source link: http://www.cnblogs.com/xiaoyuxy/p/13186860.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

前言

对于单线程来说,事件循环可以说是重中之重了,它为任务分配不同的优先级,井然有序的调度。让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阶段');
        }
    }
);

6juaMry.jpg!web

事件循环有一个或多个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

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

22UZZ33.jpg!web

小结

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

比如:

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

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

提前祝大家端午节nb

参考

composite

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK