HTML 规范篇幅极长,阐述了很多前端技术的底层细节。我没有雄心通篇翻译,只打算挑部分感兴趣的内容。译文中如果存在表达不恰当的内容欢迎提出您宝贵的意见。
3. 事件处理模型
只要事件循环存在,就会重复执行下面的步骤:
- 选择事件循环中的一个任务队列作为 taskQueue ,选择的方式由实现方定义 ,但是选中的任务队列至少要包含一个可执行的任务。如果没有这样的任务队列,会转而执行下面微任务的步骤。
译者注: taskQueue 的选择方式与实现方对用户代理的实现有关。
注意 微任务队列不是任务队列,所以不会在这一步被选中。但是微任务源关联的任务队列可能被选中。这种情况下,下一步选中的任务最初是一个微任务,但是会作为旋转事件循环的一部分被移除。
- taskQueue 中的第一个可执行任务将赋值给 oldestTask ,然后这个任务会从 taskQueue 中移除。
- 将事件循环的当前正在执行的任务赋给 oldestTask 。
- 将当前时间精度赋值给 taskStartTime 。
- 执行 oldestTask 中的 steps 。
- 将事件循环的当前正在执行的任务再置为 null 。
- 微任务:运行微任务执行检查。
- 将 hasARenderingOpportunity 置 false 。
- 将当前时间精度赋值给 now 。
- 执行以下步骤来汇报任务执行时间:
- 将顶级浏览上下文 set 置为空集合。
- 遍历 oldestTask 的脚本执行环境对象的集合,将每一个环境对象的顶级浏览上下文加入顶级浏览上下文 set 。
- 携带 taskStartTime、now(任务的结束时间)、顶级查询上下文 set 、oldestTask,执行长任务汇报 。
译者注:长任务汇报,执行超过50ms的任务将汇报给用户代理。
- 如果在窗口事件循环,更新渲染:
-
将 docs 置为与当前事件循环关联的代理的所有 document 。在满足下面条件的前提下,可以任意排序:
- 作为容器的文档 document 要排在其从属前。
- 有相同容器的文档,排序的顺序与他们在容器中的顺序一致。 在下面遍历docs的过程中,处理document的顺序要与排序一致。
-
渲染机会(
rendering opportunity):删除docs中所有不显示的document。如果用户代理能为用户展示该浏览上下文的内容,该浏览上下文就有渲染机会。这里会考虑到硬件刷新率和用户代理的性能限制。需要注意的是即时内容出现在视口外也是可以渲染的。
浏览上下文的渲染机会是由硬件因素(比如屏幕刷新率)和其他因素(比如页面性能和页面是否在后台)确定。渲染机会通常会定期出现。
本规范不强制用任何特定的模式选择渲染机会。但是比如浏览器想要达到60Hz刷新率,那么渲染机会最慢也要一秒出现60次(大约16.7毫秒)。如果浏览器发现某个浏览上下文无法维持这样的刷新率,则该浏览上下文的渲染机会可能会被降到每秒30次,避免间歇的丢帧。与此类似,如果浏览上下文不可见,用户代理可能将它的渲染机会给予频率降低到每秒4次,或者更低。
-
如果 docs 非空,将 hasARenderingOpportunity 置 true 。
-
渲染冗余:从 docs 中删除满足以下两个条件的所有 document 对象:
- 用户代理认为更新这个浏览上下文不会有明显的效果。
- document 的动画帧回调映射是空的。
-
从 docs 中删除由于其他原因使得用户代理认为最好跳过渲染更新的 document 。
『渲染机会』那一步可以防止用户代理在无法向用户渲染新内容(没有渲染机会)时更新渲染。『渲染冗余』那一步可以保证用户代理在没有新的要绘制的内容时更新渲染。而当前这一步防止 steps 意外执行,比如某些任务需要在其他任务后立即执行,确保此时可以插入微任务执行检查(不插入动画帧回调)。具体上说,用户代理可能希望 timer 回调合并在一起,中间不要插入渲染更新。
-
对于 docs 中的每个完全激活的 document ,会迅速将浏览上下文是顶级上下文的 document 作为自动聚焦候选。
-
对于 docs 中的每个完全激活的 document ,在运行 document 的 resize 操作时,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,在运行 document 的滚动操作时,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,在评估媒体查询并汇报变化时,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,在更新动画并触发事件时,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,在运行 document 的全屏操作时,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,如果用户代理检测到与 CanvasRenderingContext2D 或 OffscreenCaasRenderingContext2D 关联的上下文已经丢失,需要为此类情况执行上下文丢失步骤:
- 如果上下文是 CanvasRenderingContext2D ,则上下文的 canvas 属性取值 canvas ,否则取值上下文的关联 OffscreenCanvas 对象。
- 上下文的
context lost置 true 。 - 将渲染上下文重置为上下文提供的默认值。
- canvas 上触发名为 contextlost 的事件,携带一个默认值 true ,事件结果将赋值给 shouldRestore 。
- shouldRestore 如果是 false ,终止后面几步。
- 尝试创建上下文属性的备份并将其与上下文关联从而还原上下文。如果失败,则中止后面几步。
- 上下文的
context lost置 false 。 - canvas 上触发一个 contextrestored 事件。
-
对于 docs 中的每个完全激活的document,执行该 document 的动画帧回调,会将 now 以时间戳传入。
-
对于 docs 中的每个完全激活的 document ,执行该 document 的更新重叠关系检查,会将 now 以时间戳传入。
-
为 docs 中的每个 Document 对象调用渲染时间点标记算法。
-
对于 docs 中的每个完全激活的document,更新该 document 的渲染或用户界面及其浏览上下文来反映当前状态。
-
- 如果下面的条件都成立:
- 是窗口事件循环
- 该事件循环的任务队列中没有 document 为激活状态的任务
- 事件循环的微任务队列为空
- hasARenderingOpportunity 是 false 那么:
- computeDeadline 做下面几步:
- 将事件循环的上一次空闲开始时间加50赋值给 deadline
50ms 的限制是保证页面对未来新的用户动作的响应可以被人类感知接受。 译者注:50ms 即每秒20帧更新,人眼看起来可动。
- hasPendingRenders 置 false
- 遍历该事件循环的同循环窗口,赋值给 windowInSameLoop
- 如果 windowInSameLoop 的动画帧回调映射非空,或者用户代理认为 windowInSameLoop 可能阻塞渲染,hasPendingRenders 将置 false
- 将 windowInSameLoop 的响应时间映射表的值集合赋值给 timerCallbackEstimates
- 遍历 timerCallbackEstimates ,如果某个的 timeoutDeadline 小于 deadline ,更新 deadline 保持 deadline 最小。
- 如果 hasPendingRenders 是 true ,那么:
- 将该事件循环上一次渲染机会时间增加一段时间(1000 / 当前刷新率)赋值给 nextRenderDeadline 。 刷新率可以指硬件也可以指软件实现。对于60Hz刷新率, nextRenderDeadline 将在上次渲染机会时间后加约16.67ms。
- 如果 nextRenderDeadline 比 deadline 小,返回 nextRenderDeadline 。
- 返回 deadline 。
- 遍历同循环窗口,带入 computeDeadline 为每个 window 对象执行空闲时间算法。
- 如果这是一个 worker 事件循环,那么:
- 如果该事件循环的代理的作用域全局对象满足 DedicatedWorkerGlobalScope 而且用户代理认为此时更新渲染比较好,那么:
- 将当前时间精度赋值给 now 。
- 执行 DedicatedWorkerGlobalScope 的动画帧回调,将 now 以时间戳传入。
- 更新这个专用 worker 的渲染,以反映当前的状态。
与窗口事件循环中对更新渲染的阐述类似,用户代理可以控制专用 worker 的更新频率。
- 如果该事件循环的任务队列没有任务,而且 WorkerGlobalScope 对象的 closing 为 true,就要销毁事件循环,终止后面的步骤,执行 Web Workers 一章中 初始化 worker 的步骤。
- 如果该事件循环的代理的作用域全局对象满足 DedicatedWorkerGlobalScope 而且用户代理认为此时更新渲染比较好,那么:
当用户代理要做微任务执行检查:
- 如果该事件循环的微任务执行检查标识是 true ,直接返回。
- 将该事件循环的微任务执行检查标识置为 true 。
- 在该事件循环的微任务队列非空前提下,循环下面操作:
- 将该事件循环的微任务队列推出队首赋值给 oldestMicrotask 。
- 将 oldestMicrotask 作为事件循环的当前正在执行的任务。
- 执行 oldestMicrotask 。
期间可能会执行用户脚本的回调,执行过后会清除,然后会再次调用微任务执行检查算法,这就是我们需要微任务执行检查标识来避免重入的原因。
- 再将当前执行的任务置回 null 。
- 处理环境设置对象所负责的事件循环,通知环境设置对象上所有reject的promise
- 运行事件循环的【清理任务】
- 运行 ClearKeptObjects
WeakRef.prototype.deref()返回的对象会一直保持活性,直到后面某次ClearKeptObjects中,对象被垃圾回收。
- 将该事件循环的微任务执行检查标识置 false
当有一个异步算法等待返回值时,用户代理会将一个微任务入队,执行以下步骤,算法暂停执行(微任务执行时,算法将恢复执行,如下所述:)
1. 执行算法的同步部分。
2. 按算法的实现,在合适的时机恢复异步算法执行。
旋转事件循环直到满足条件的过程等同于以下步骤:
- 将当前事件循环的当前正在执行的任务作为 task
task也可能是微任务
- 将task的源作为source
- 将js执行栈的副本作为oldStack
- 清空js执行栈
- 执行微任务执行检查
- 异步执行:
- 等待条件满足
- 入队一个完成如下操作的微任务:
- 将oldStack作为js执行栈
- 旋转事件循环,然后执行原始算法后面的语句
这里恢复了task的执行
- 暂停 task 直到调用它的算法将其恢复
这将促使事件循环的核心步骤和微任务执行检查继续执行
本规范旋转事件循环的方式和其他规范的其他算法(类似编程语言的函数调用)不同,本规范的实现更像是一个宏,通过混入一些步骤和操作,节省一些上层的码量和缩进。
例如:
某一个算法完成下面的工作:
- 做一些工作
- 旋转事件循环,直到一些条件达成
- 做其他事情 上面是简化版,经过宏处理,详细过程如下:
- 做一些工作
- 将js执行栈的副本作为oldStack
- 清空js执行栈
- 执行微任务执行检查
- 异步执行:
- 等到一些条件达成
- 入队一个完成如下操作的任务:
- 将oldStack作为js执行栈
- 做其他事情
例如:
下面是一个更完整的示例,其中事件循环依靠向异步队列推入任务旋转。使用旋转事件循环的版本:
- 异步执行:
- 执行异步工作1
- 入队一个DOM任务源的任务:
- 执行任务1
- 旋转事件循环,直到一些条件达成
- 执行任务2
- 执行异步工作2 详细过程如下:
- 异步执行:
- 执行异步工作1
- 把 oldStack 置 null
- 入队一个DOM任务源的任务:
- 执行任务1
- 将js执行栈的副本作为oldStack
- 清空js执行栈
- 执行微任务执行检查
- 等到一些条件达成
- 入队一个完成如下操作的任务:
- 将oldStack作为js执行栈
- 执行任务2
- 执行异步工作2 由于历史原因,本规范中的一些算法要求用户代理在执行任务时暂停,直到满足条件。这里的意思是:
- 将当前状态更新出去,反映到任何与当前状态相关的文档、浏览上下文的呈现以及用户界面。
- 等待满足条件。当用户代理有一个暂停的任务时,相应的事件循环不能再运行其他任务,并且当前运行的任务中的任何脚本执行都会中断。然而,用户代理应该在暂停时保持对用户输入的响应,尽管不能完成什么,因为事件循环此时不处理任务。
注意 暂停对用户体验非常有害,尤其是在多个文档共享单个事件循环的情况下。规范鼓励用户代理尝试暂停的替代方案,比如:旋转事件循环,即使是不执行任何任务,简单的重复旋转,只要有可能做到这一点,同时保持与现有内容的兼容性。如果发现了不那么激进的web兼容的替代方案,那么规范将很乐意调整。
在此期间,实现者应该意识到,用户代理可能尝试的各种替代方案,事件循环的行为可能有微妙的改变,包括任务和微任务计时。虽然依此实现会违反暂停操作真实的语义,也应该继续。
总结
第三小节阐述了:
- 事件循环处理模型
- 事件循环旋转的实现
- 解释了微任务执行检查的操作 这里是理解事件循环的核心内容,规范的叙述像代码实现的描述。也许可以用js实现一些伪代码帮助理解。