阅读 1400

HTML Standard系列:Event loop、requestIdleCallback 和 requestAnimationFrame

上篇回顾:HTML Standard系列:浏览器是如何解析页面和脚本的

简介

本文目的

  • 理解 Event Loop 设计目的和执行逻辑及其和渲染的关系
  • 理解 requestIdleCallBack/requestAnimationFrame 设计目的
  • 理解 Event Loop 和 IdleCallBack/AnimationFrame/渲染时机的关系

可以带着这些问题阅读本文

  • 为什么需要 Event Loop?
  • Event Loop 和渲染到底有什么关系?
  • 浏览器是如何实现 requestAnimationFrame 的,为什么可以在 Event Loop 的模型下保持JS动画的帧数?
  • 浏览器是如何实现 requestIdleCallBack 的,为什么 react 选择它来调度diff任务,它是这么计算浏览器空闲时间的?

Event Loop介绍

在浏览器的实现上,诸如渲染任务、JavaScript 脚本执行、User Interaction、网络处理都跑在同一个线程上,当执行其中一个类型的任务的时候意味着其他任务的阻塞,为了有序的对各个任务按照优先级进行执行浏览器实现了我们称为 Event Loop 调度流程。

这种设计模型导致了 JavaScript 天生异步的特点,意味着诸如 Ajax 等浏览器接口调用的回调将会发生在 Event Loop 未来的循环中,而不是在阻塞当前 Task。

与这种模型相对的并发模型,类似 iOS 中渲染和脚本执行也是在同一个线程中,当 iOS 开发人员发起网络请求的时候,一般通过一个优先级较低的线程去完成请求任务,任务线程完成任务后唤起主线程执行网络请求后的工作。

我们看一段简单的代码对比。

JavaScript中请求一张图片URL的表现:

// 将一个网络请求任务推入Event Loop中的任务队列中
http.request('some.img').then(
    // 将回调推入任务队列中排队,并在网络任务完成后置位可执行任务,等待执行(这里暂时忽视微任务和宏任务的区别)
    (imgUrl) => imgElement.src = imgUrl;
)
复制代码

我们看看iOS中不阻塞UI的请求一张图片如何做:

// 将请求任务主动推到额外线程的队列上
DispatchQueue.global().async {
    // 在额外的线程中同步请求
    let imageData = doRequestStuff()
    // 完成后主动将后续任务推入到主线程中完成。
    DispatchQueue.main.async {
        UIImage(data: imageData)
    }
}
复制代码

可以看到两者对于开发者而言最大的区别在于主动和被动,JavaScript的开发者只负责发起请求,随后的任务调度全部交给了隐藏在幕后的Event Loop,iOS开发者则需要主动维护多线程之间的关系。

requestIdleCallback、requestAnimationFrame介绍

为什么这两个方法要和 Event Loop 一起讲?根据上面的例子,我们发现 Event Loop 的实现大大简化UI开发,一般开发者只需要将要执行的任务推入 Event Loop 的队列上就行了,有 Event Loop 自然不会阻塞UI渲染。

Event Loop 虽然优点很多,但还是存在一定问题,Event Loop 对多线程开发模型就行了抽象,隐藏了复杂的细节,比如我们压根不用管网络请求在浏览器内部是并发请求的,隐藏细节就意味着复杂场景开发者拥有的自由度会降低。

典型的问题的就是 long task,当 event loop 中某个任务执行时间超过了50ms,用户就可能会感到卡顿;另外一个问题就是 evetloop 中任务过多,导致高优先级的任务无法及时执行(我们无法控制任务的优先级);比如Js动画效果。

了解JS定时器同学应该知道,settimeout 和 setinterval 的定时并非准确的,考虑如下代码:

// 我们期待这个动画帧数为20帧
var i = 1;
setInterval(() => {
    element.style.width = `${i++}px`
}, 50)

// 在某些情况下我插入了一堆任务到队列中
for(var j = 0; j < 10000, j++) {
    setTimeout(() => {
        doSomeStuff()
    }, 99)
}
复制代码

显然这个动画在要执行第二帧的JS脚本的时候,前面排了10000个任务,而这段脚本排在队列中10001(实际情况应该更复杂),虽然浏览器总是在执行任务后进行渲染工作,但关键的脚本没有执行,渲染的界面自然还是原来的,这就造成视觉效果上的卡顿。

于是乎 requestAnimationFrame 就出现了,它的定义就是在浏览器下次绘制之前将会执行这个方法的回调,具体浏览器如何实现了这个方法,可以保证我们的动画避免被长队列任务所延迟我们接下来再讲。

// 例子,来自MDN
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);
复制代码

对于 requestIdleCallback 而言,最出名的应用应该就是 react 了,react 使用了 requestIdleCallback 进行 diff 任务的调度工作,避免了单个 diff 任务耗时过长,而导致界面卡顿的问题,这个方法的回调将在 Event Loop 空闲的时候唤起,并提供浏览器接下来可以使用的空闲时间(即下一帧渲染之前拥有的时间)。

Event Loop

在 HTML Standard 中是这么描述 Event loop 的:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop.

我们每个浏览器界面都有对应的 Event loop,细心的同学可能看到上面写的是 evet loops ,我们每个界面并不止一个 event loop ,而是有多个 event loop,不同的 event loop 管理的方向是完全不同的:

  • window event loop(通常我们讲的都是这个)
  • worker event loop(每个 worker 线程都有个与之关联的 event loop)
  • worklet event loop(woklet 是可以访问渲染引擎的线程,我们一般用不到)

我们接下来讲的 event loop 都默认指代 window event loop,后者我们应用并不多,worker 可以看作一个不能访问 dom 的 JavaScript 运行环境,而 worklet 还处于草案阶段,主要应用于需要超高性能场景( worklet 中跑的是机器码而不是 JavaScript )。

所谓的 Event Loop 就是我们界面从创建到销毁,浏览器中不停执行的一些步骤,以及为了执行这些步骤而持有的一些固有属性。

每个 event loop 持有以下属性:

  • 多个 task queue( taskqueue 是一个 set 而非 queue),task就是常说的宏任务,具体任务可以是:Events、Parsing、Callbacks
  • 一个 mircotask queue,也就是微任务队列

疑问点1:为什么要有多个 task queque?因为浏览器可以为不同的 queque 分配不同的优先级,从而优先处理某种类型任务。

疑问点2:为什么 task queue 不是队列,而是集合?因为浏览器总是会挑选可执行的任务去执行,而不是根据进入队列的时间。

event loop 存在期间将会一直执行下列的步骤:

  • 选取一个有可执行任务的 task queue,并且执行其中最老的任务
  • 执行 microtask,直到 mircrotask queue 为空
  • update render,检测是否有渲染机会( rendering opportunity),渲染机会根据物理环境决定(依赖机子性能),如果有渲染机会,浏览器便会执行绘制工作
  • 如果接下来没有要执行的 task/microstask,event loop 将会计算空闲时间。

可见浏览器并非每次tick都会执行绘制工作,而是根据物理环境的实际情况决定。

比如:某次task插入一个p元素,task结束后并不意味着本次tick会将相应的element绘制到界面上。

为什么有了宏任务还需要微任务?

我们习惯把宏任务和微任务都理解成 JavaScript 异步执行的一种形式。

事实上只有宏任务是异步的,而微任务是对宏任务代码执行顺序的再分配;宏任务执行完后总是会执行完所有微任务,这种意义上微任务是阻塞主线程的,如果你在某个微任务中不断创建新的微任务,毫无疑问界面会出现假死。所有微任务的意义在于执行一些总是想在某个任务完成后再执行的代码。

这时候可能很多小伙伴想到 Promise,我个人认为 Promise 的控制反转特性才是它大放异彩的原因(大多时候我们喜欢的是 Promise 的语法、链式调用;其实并不关心是否是微任务),而不是因为 Promise.then 是个微任务。

这里有一些奇怪的点,Promise 的规范是应该属于 ECMAScript 编写,本应和 HTML Standard 没有关系,但因为 Promise 的特殊性,浏览器基本是照着 HTML Standard 的规范去实现的 Promise,在 ECMAScript 中 Promise.then 注册的不叫 microtask 而是称为 job。

requestAnimationFrame

讲完 Event Loop 似乎前端开发者压根无法把握住渲染前的那一个点,为了解决这个问题 w3c 定义了 requestAnimationFrame 方法,该方法的回调将会在浏览器的下一次绘制前。

调用 requestAnimationFrame,将会将回调推入 animation frame request callback list,而一个非空的 animation frame request callback list,将会使浏览器周期性的向 event loop 中添加一个任务去执行 requestAnimationFrame 注册的回调,这里的周期没有指明,但我们很容易推测和刚刚 event loop 中的渲染时机(rendering opportunity)有关。

直到现在我们依然无法看到使用 requestAnimationFrame 相对于 setinterval 构建 JS 动画的优越性,大家都是周期性向 event loop 推送任务,为什么 requestAnimationFrame 就要更稳定呢?

答案藏在 event loop 中在多个 task queue 之间优先级不一致中,每个 task 拥有一个 task source 属性,决定了 task 归属到哪个 task queque,而 task queque 拥有不同的执行优先级,显然由 animation frame request callback list 非空而创建的任务优先级是要高于 timer 的。

animation frame request callback list 中所有的回调函数,将会在一次任务内全部执行,意味着同步的多次调用 requestAnimationFrame,将会在下一次渲染前的一次任务内按顺序全部执行。

requestIdleCallback

在讲 requestIdleCallback 之前,我们先回忆一下 react16 新推出的基于 fiber 架构,在 react16 之前 react 使用 stack reconciler,那 react 声称 fiber 解决了什么问题呢?

首先我们先看看 stack reconciler 存在什么问题,依然是 JS 动画的例子,如果我们使用 requestAnimationFrame 调制我们的动画,如果不存在 long task,动画的帧数将得到保证;但是如果某个 task 执行时间超过50ms,没人可以保证界面不卡顿;这就是 stack reconciler 的问题,存在单次 diff 时间过长的问题。

而 react 推出 fiber 就是为了解决这个问题,提高动画的流畅度,将任务切分到多个帧之间,保证子任务不会出现成为 long task,提供 fiber 这种核心能力的便是 requestIdleCallback。

调用 requestIdleCallback 方法,将使浏览器在空闲时段调用该方法的回调函数。

让我们回到 Event Loop 的执行流程,可以得知在 Event Loop 没有需要执行的任务的时候会计算空闲时间,空闲时间的计算有两种情况。

当存在需要连续渲染的帧,空闲时间将会是帧的频率减去执行任务时间,再减去执行绘制的时间。

当一段时间内没有绘制和任务发生的时候,空闲时间将尽可能的大,但是不会超过50ms。

50ms这个魔法数字来自大数据的分析,有研究表面高于50ms/帧的画面会让人觉得卡顿,所以我们时常要求当个任务不能过长,就是这个原因。

同样调用 requestIdleCallback 方法的回调并不会直接进入 task queque,而是在每轮 event loop 结束之前会计算 idleperiod,如果 idleperiod 大于0,才会将任务放进队列中。

提示:idleperiod 的时间除了和渲染频率有关,还和最近要执行的定时器有着一定的关系,idleperiod 总是会小于下个定时器要执行的时间。

同步的调用多次 requestIdleCallback,该方法的回调执行可能会分布在不同的帧上,每执行完一次回调,浏览器会检查是否还有剩余的空闲时间,如果没有,会将执行控制权交还 event loop,如果有才会继续执行下一个回调,听起来是不是和 react fiber 的调度很像。

总结

本文和上一篇这个系统的文章的重点都在于弄清楚整个界面的生命周期和运转过程,理解绘制和脚本执行直接的关系。

我相信 react 的开发人员如果没读过规范,是不能设计出fiber这样的架构的,这些规范知识提供了高性能 web 开发的理论基础。

上篇:HTML Standard系列:浏览器是如何解析页面和脚本的