共计 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,用来防止微任务死循环。
微任务排队算法:
- 如果未提供 event loop,设置一个隐式 event loop。
- 如果未提供 document,设置一个隐式 document.
- 创建一个 Task 作为新的微任务
- 设置 setp、source、document 到新的 Task 上
- 设置 Task 的环境配置对象为空集
- 添加到 event loop 的微任务队列中
微任务检查算法:
- 如果微任务检查标志为 true,直接 return
- 设置微任务检查标志为 true
-
如果微任务队里不为空 (也就是说微任务添加的微任务也会在这个循环中出现,直到微任务队列为空):
- 从微任务队列中找出最老的任务 (防饿死)
- 设置当前执行任务为这个最老的任务
- 执行
- 重置当前执行任务为 null
- 通知环境配置对象的 promise 进行 reject 操作
- 清理 indexdb 事务 (不太明白这一步,如果有读者了解,烦请点拨一下)
- 设置微任务检查标志为 false
处理模型
event loop 会按照下面这些步骤进行调度:
- 找到一个可执行的 Task 队列,如果没有则跳转到下面的微任务步骤
- 让最老的 Task 作为 Task 队列中第一个可执行的 Task,并将其移除
- 将最老的 Task 作为 event loop 的可执行 Task
- 记录任务开始时间点
- 执行 Task 中的 setp 对应的步骤 (上文中 Task 结构中的 step)
- 设置 event loop 的可执行任务为 null
- 执行微任务检查算法
- 设置 hasARenderingOpportunity(是否可以渲染的 flag) 为 false
- 记住当前时间点
-
通过下面步骤记录任务持续时间
- 设置顶层浏览器环境为空
- 对于每个最老 Task 的脚本执行环境配置对象,设置当前的顶级浏览器上下文到其上
- 报告消耗过长的任务,并附带开始时间,结束时间,顶级浏览器上下文和当前 Task
- 如果在 window 环境下,会根据硬件条件决定是否渲染,比如刷新率,页面性能,页面是否在后台,不过渲染会定期出现,避免页面卡顿。值得注意的是,正常的刷新率为 60hz,大概是每秒 60 帧,大约 16.7ms 每帧,如果当前浏览器环境不支持这个刷新率的话,会自动降为 30hz,而不是丢帧。而李兰其在后台的时候,聪明的浏览器会将这个渲染时机降为每秒 4 帧甚至更低,事件循环也会减少 (这就是为什么我们可以用 setInterval 来判断时候能打开其他 app 的判断依据的原因)。如果能渲染的话会设置 hasARenderingOpportunity 为 true。
除此之外,还会在触发 resize、scroll、建立媒体查询、运行 css 动画等,也就是说浏览器几乎大部分用户操作都发生在事件循环中,更具体点是事件循环中的 ui render 部分。之后会进行 requestAnimationFrame 和 IntersectionObserver 的触发,再之后是 ui 渲染
-
如果下面条件都成立,那么执行空闲阶段算法,对于开发者来说就是调用 window.requestIdleCallback 方法
- 在 window 环境下
- event loop 中没有活跃的 Task
- 微任务队列为空
- hasARenderingOpportunity 为 false
借鉴网上的一张图来粗略表示下整个流程
小结
上面就是整个事件循环的流程,浏览器就是按照这个规则一遍遍的执行,而我们要做的就是了解并适应这个规则,让浏览器渲染出性能更高的页面。
比如:
- 非首屏相关性能打点可以放到 idle callback 中执行,减少对页面性能的损耗
- 微任务中递归添加微任务会导致页面卡死,而不是随着事件循环一轮轮的执行
- 更新元素布局的最好时机是在 requestAnimateFrame 中
- 尽量避免频繁获取元素布局信息,因为这会触发强制 layout(哪些属性会导致强制 layout?),影响页面性能
- 事件循环有多个任务队列,他们互不冲突,但是用户交互相关的优先级更高
- resize、scroll 等会伴随事件循环中 ui 渲染触发,而不是根据我们的滚动触发,换句话说,这些操作自带节流
- 等等,欢迎补充
最后感谢大家阅读,欢迎一起探讨!
提前祝大家端午节 nb
参考
composite
深入探究 eventloop 与浏览器渲染的时序问题