关于 Event Loop 的文章不少,可是有不少只是在讲「宏任务」、「微任务」,我先提出几个问题:javascript
requestAnimationFrame
在哪一个阶段执行,在渲染前仍是后?在 microTask
的前仍是后?requestIdleCallback
在哪一个阶段执行?如何去执行?在渲染前仍是后?在 microTask
的前仍是后?resize
、scroll
这些事件是什么时候去派发的。这些问题并非刻意想刁难你,若是你不知道这些,那你可能并不能在遇到一个动画需求的时候合理的选择 requestAnimationFrame
,你可能在作一些需求的时候想到了 requestIdleCallback
,可是你不知道它运行的时机,只是胆战心惊的去用它,祈祷不要出线上 bug。css
这也是本文想要从规范解读入手,深挖底层的动机之一。本文会酌情从规范中排除掉一些比较晦涩难懂,或者和主流程不太相关的概念。更详细的版本也能够直接去读这个规范,不过比较费时费力。html
咱们先依据HTML 官方规范从浏览器的事件循环讲起,由于剩下的 API 都在这个循环中进行,它是浏览器调度任务的基础。前端
为了协调事件,用户交互,脚本,渲染,网络任务等,浏览器必须使用本节中描述的事件循环。vue
从任务队列中取出一个宏任务并执行。java
检查微任务队列,执行并清空微任务队列,若是在微任务的执行中又加入了新的微任务,也会在这一步一块儿执行。git
进入更新渲染阶段,判断是否须要渲染,这里有一个 rendering opportunity
的概念,也就是说不必定每一轮 event loop 都会对应一次浏览 器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,一般来讲这个渲染间隔是固定的。(因此多个 task 极可能在一次渲染之间执行)github
map of animation frame callbacks
为空,也就是帧动画回调为空,能够经过 requestAnimationFrame
来请求帧动画。若是上述的判断决定本轮不须要渲染,那么下面的几步也不会继续运行:web
This step enables the user agent to prevent the steps below from running for other reasons, for example, to ensure certain tasks are executed immediately after each other, with only microtask checkpoints interleaved (and without, e.g., animation frame callbacks interleaved). Concretely, a user agent might wish to coalesce timer callbacks together, with no intermediate rendering updates. 有时候浏览器但愿两次「定时器任务」是合并的,他们之间只会穿插着
microTask
的执行,而不会穿插屏幕渲染相关的流程(好比requestAnimationFrame
,下面会写一个例子)。算法
对于须要渲染的文档,若是窗口的大小发生了变化,执行监听的 resize
方法。
对于须要渲染的文档,若是页面发生了滚动,执行 scroll
方法。
对于须要渲染的文档,执行帧动画回调,也就是 requestAnimationFrame
的回调。(后文会详解)
对于须要渲染的文档, 执行 IntersectionObserver 的回调。
对于须要渲染的文档,从新渲染绘制用户界面。
判断 task队列
和microTask
队列是否都为空,若是是的话,则进行 Idle
空闲周期的算法,判断是否要执行 requestIdleCallback
的回调函数。(后文会详解)
对于resize
和 scroll
来讲,并非到了这一步才去执行滚动和缩放,那岂不是要延迟不少?浏览器固然会马上帮你滚动视图,根据CSSOM 规范所讲,浏览器会保存一个 pending scroll event targets
,等到事件循环中的 scroll
这一步,去派发一个事件到对应的目标上,驱动它去执行监听的回调函数而已。resize
也是同理。
能够在这个流程中仔细看一下「宏任务」、「微任务」、「渲染」之间的关系。
task
队列并非咱们想象中的那样只有一个,根据规范里的描述:
An event loop has one or more task queues. For example, a user agent could have one task queue for mouse and key events (to which the user interaction task source is associated), and another to which all other task sources are associated. Then, using the freedom granted in the initial step of the event loop processing model, it could give keyboard and mouse events preference over other tasks three-quarters of the time, keeping the interface responsive but not starving other task queues. Note that in this setup, the processing model still enforces that the user agent would never process events from any one task source out of order.
事件循环中可能会有一个或多个任务队列,这些队列分别为了处理:
浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入获得最高优先级的响应,而剩下的优先级交给其余 Task
,而且保证不会“饿死”它们。
这个规范也致使 Vue 2.0.0-rc.7 这个版本 nextTick
采用了从微任务 MutationObserver
更换成宏任务 postMessage
而致使了一个 Issue。
目前因为一些“未知”的缘由,jsfiddle 的案例打不开了。简单描述一下就是采用了 task
实现的 nextTick
,在用户持续滚动的状况下 nextTick
任务被延后了好久才去执行,致使动画跟不上滚动了。
迫于无奈,尤大仍是改回了 microTask
去实现 nextTick
,固然目前来讲 promise.then
微任务已经比较稳定了,而且 Chrome 也已经实现了 queueMicroTask
这个官方 API。不久的将来,咱们想要调用微任务队列的话,也能够节省掉实例化 Promise
在开销了。
从这个 Issue 的例子中咱们能够看出,稍微去深刻了解一下规范仍是比较有好处的,以避免在遇到这种比较复杂的 Bug 的时候一脸懵逼。
下面的章节中我们来详细聊聊 requestIdleCallback
和 requestAnimationFrame
。
如下内容中
requestAnimationFrame
简称为rAF
在解读规范的过程当中,咱们发现 requestAnimationFrame
的回调有两个特征:
咱们来分析一下,为何要在从新渲染前去调用?由于 rAF
是官方推荐的用来作一些流畅动画所应该使用的 API,作动画不可避免的会去更改 DOM,而若是在渲染以后再去更改 DOM,那就只能等到下一轮渲染机会的时候才能去绘制出来了,这显然是不合理的。
rAF
在浏览器决定渲染以前给你最后一个机会去改变 DOM 属性,而后很快在接下来的绘制中帮你呈现出来,因此这是作流畅动画的不二选择。下面我用一个 setTimeout
的例子来对比。
假设咱们如今想要快速的让屏幕上闪烁 红
、蓝
两种颜色,保证用户能够观察到,若是咱们用 setTimeout
来写,而且带着咱们长期的误解「宏任务之间必定会伴随着浏览器绘制」,那么你会获得一个预料以外的结果。
setTimeout(() => {
document.body.style.background = "red"
setTimeout(() => {
document.body.style.background = "blue"
})
})
复制代码
能够看出这个结果是很是不可控的,若是这两个 Task
之间正好遇到了浏览器认定的渲染机会,那么它会重绘,不然就不会。因为这俩宏任务的间隔周期过短了,因此很大几率是不会的。
若是你把延时调整到 17ms
那么重绘的几率会大不少,毕竟这个是通常状况下 60fps
的一个指标。可是也会出现不少不绘制的状况,因此并不稳定。
若是你依赖这个 API 来作动画,那么就极可能会形成「掉帧」。
接下来咱们换成 rAF
试试?咱们用一个递归函数来模拟 10 次颜色变化的动画。
let i = 10
let req = () => {
i--
requestAnimationFrame(() => {
document.body.style.background = "red"
requestAnimationFrame(() => {
document.body.style.background = "blue"
if (i > 0) {
req()
}
})
})
}
req()
复制代码
这里因为颜色变化太快,gif
录制软件没办法截出这么高帧率的颜色变换,因此各位能够放到浏览器中本身执行一下试试,我这边直接抛结论,浏览器会很是规律的把这 10 组也就是 20 次颜色变化绘制出来,能够看下 performance 面板记录的表现:
在第一节解读规范的时候,第 4 点中提到了,定时器宏任务可能会直接跳过渲染。
按照一些常规的理解来讲,宏任务之间理应穿插渲染,而定时器任务就是一个典型的宏任务,看一下如下的代码:
setTimeout(() => {
console.log("sto")
requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
console.log("sto")
requestAnimationFrame(() => console.log("rAF"))
})
queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))
复制代码
从直觉上来看,顺序是否是应该是:
mic
mic
sto
rAF
sto
rAF
复制代码
呢?也就是每个宏任务以后都紧跟着一次渲染。
实际上不会,浏览器会合并这两个定时器任务:
mic
mic
sto
sto
rAF
rAF
复制代码
如下内容中
requestIdleCallback
简称为rIC
。
咱们都知道 requestIdleCallback
是浏览器提供给咱们的空闲调度算法,关于它的简介能够看 MDN 文档,意图是让咱们把一些计算量较大可是又没那么紧急的任务放到空闲时间去执行。不要去影响浏览器中优先级较高的任务,好比动画绘制、用户输入等等。
React 的时间分片渲染就想要用到这个 API,不过目前浏览器支持的不给力,他们是本身去用 postMessage
实现了一套。
首先看一张图,很精确的描述了这个 API 的意图:
固然,这种有序的 浏览器 -> 用户 -> 浏览器 -> 用户
的调度基于一个前提,就是咱们要把任务切分红比较小的片,不能说浏览器把空闲时间让给你了,你去执行一个耗时 10s
的任务,那确定也会把浏览器给阻塞住的。这就要求咱们去读取 rIC
提供给你的 deadline
里的时间,去动态的安排咱们切分的小任务。浏览器信任了你,你也不能辜负它呀。
50ms
的
deadline
呢?是由于浏览器为了提早应对一些可能会突发的用户交互操做,好比用户输入文字。若是给的时间太长了,你的任务把主线程卡住了,那么用户的交互就得不到回应了。50ms 能够确保用户在无感知的延迟下获得回应。
MDN 文档中的幕后任务协做调度 API 介绍的比较清楚,来根据里面的概念作个小实验:
屏幕中间有个红色的方块,把 MDN 文档中requestAnimationFrame的范例部分的动画代码直接复制过来。
草案中还提到:
当浏览器判断这个页面对用户不可见时,这个回调执行的频率可能被下降到 10 秒执行一次,甚至更低。这点在解读 EventLoop 中也有说起。
若是浏览器的工做比较繁忙的时候,不能保证它会提供空闲时间去执行 rIC
的回调,并且可能会长期的推迟下去。因此若是你须要保证你的任务在必定时间内必定要执行掉,那么你能够给 rIC
传入第二个参数 timeout
。
这会强制浏览器无论多忙,都在超过这个时间以后去执行 rIC
的回调函数。因此要谨慎使用,由于它会打断浏览器自己优先级更高的工做。
最长期限为 50 毫秒,是根据研究得出的,研究代表,人们一般认为 100 毫秒内对用户输入的响应是瞬时的。 将闲置截止期限设置为 50ms 意味着即便在闲置任务开始后当即发生用户输入,浏览器仍然有剩余的 50ms 能够在其中响应用户输入而不会产生用户可察觉的滞后。
每次调用 timeRemaining()
函数判断是否有剩余时间的时候,若是浏览器判断此时有优先级更高的任务,那么会动态的把这个值设置为 0,不然就是用预先设置好的 deadline - now
去计算。
这个 timeRemaining()
的计算很是动态,会根据不少因素去决定,因此不要期望这个时间是稳定的。
若是我鼠标不作任何动做和交互,直接在控制台经过 rIC
去打印此次空闲任务的剩余时间,通常都稳定维持在 49.xx
ms,由于此时浏览器没有什么优先级更高的任务要去处理。
而若是我不停的滚动浏览器,不断的触发浏览器的从新绘制的话,这个时间就变的很是不稳定了。
经过这个例子,你能够更加有体感的感觉到什么样叫作「繁忙」,什么样叫作「空闲」。
这个动画的例子很简单,就是利用rAF
在每帧渲染前的回调中把方块的位置向右移动 10px。
<!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> #SomeElementYouWantToAnimate { height: 200px; width: 200px; background: red; } </style>
</head>
<body>
<div id="SomeElementYouWantToAnimate"></div>
<script> var start = null var element = document.getElementById("SomeElementYouWantToAnimate") element.style.position = "absolute" function step(timestamp) { if (!start) start = timestamp var progress = timestamp - start element.style.left = Math.min(progress / 10, 200) + "px" if (progress < 2000) { window.requestAnimationFrame(step) } } // 动画 window.requestAnimationFrame(step) // 空闲调度 window.requestIdleCallback(() => { alert("rIC") }) </script>
</body>
</html>
复制代码
注意在最后我加了一个 requestIdleCallback
的函数,回调里会 alert('rIC')
,来看一下演示效果:
alert
在最开始的时候就执行了,为何会这样呢一下,想一下「空闲」的概念,咱们每一帧仅仅是把 left
的值移动了一下,作了这一个简单的渲染,没有占满空闲时间,因此可能在最开始的时候,浏览器就找到机会去调用 rIC
的回调函数了。
咱们简单的修改一下 step
函数,在里面加一个很重的任务,1000 次循环打印。
function step(timestamp) {
if (!start) start = timestamp
var progress = timestamp - start
element.style.left = Math.min(progress / 10, 200) + "px"
let i = 1000
while (i > 0) {
console.log("i", i)
i--
}
if (progress < 2000) {
window.requestAnimationFrame(step)
}
}
复制代码
再来看一下它的表现:
其实和咱们预期的同样,因为浏览器的每一帧都"太忙了",致使它真的就无视咱们的 rIC
函数了。
若是给 rIC
函数加一个 timeout
呢:
// 空闲调度
window.requestIdleCallback(
() => {
alert("rID")
},
{ timeout: 500 },
)
复制代码
浏览器会在大概 500ms
的时候,无论有多忙,都去强制执行 rIC
函数,这个机制能够防止咱们的空闲任务被“饿死”。
经过本文的学习过程,我本身也打破了不少对于 Event Loop 以及 rAF、rIC 函数的固有错误认知,经过本文咱们能够整理出如下的几个关键点。
requestAnimationFrame
在从新渲染屏幕以前执行,很是适合用来作动画。requestIdleCallback
在渲染屏幕以后执行,而且是否有空执行要看浏览器的调度,若是你必定要它在某个时间内执行,请使用 timeout
参数。resize
和scroll
事件其实自带节流,它只在 Event Loop 的渲染阶段去派发事件到 EventTarget
上。另外,本文也是对于规范的解读,规范里的一些术语比较晦涩难懂,因此我也结合了一些本身的理解去写这篇文章,若是有错误的地方欢迎各位小伙伴指出。
Vue 源码详解之 nextTick:MutationObserver 只是浮云,microtask 才是核心!(强烈推荐这篇文章)
1.若是本文对你有帮助,就点个赞支持下吧,你的「赞」是我创做的动力。
2.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。