当我们聊定时器时,到底在聊什么

语言: CN / TW / HK

00

目录

  • 背景

    • 定时器在电商平台的应用

    • 不同场景对定时器精度的要求

  • 常见的定时器方案

    • setInterval

    • 链式调用 setTimeout 

  • 实现高复杂度计时业务时存在的一些问题

    • 计时补偿

    • 定时器计时间隔超过阈值

    • 冗余计算与卡帧

  • 另一种前端定时器方案

    • 浏览器页面渲染机制

    • requestAnimationFrame

    • requestIdleCallback

    • 定时器设计思想

    • 使用方式与效果

  • 结语 


01

导读

在前端的业务中,经常会出现需要计时的场景。比如页面中需要统计停留时长,以达成一些活动任务的要求;又比如页面需要进行倒计时,用来预热一些特定时刻才能开启的活动;再比如页面中需要展示一些动画,用于使页面看起来更美观。这样的场景数不胜数,覆盖了可以说是几乎所有的行业,其中是以电商、游戏中最为常见。


02

背景

2.1 定时器在电商平台的应用

电商行业中,最常见的定时器应用场景就是抢购活动页面。如下图所示:


电商平台抢购页面

抢购页面中,通常会出现两种状态的抢购活动。一种是正在进行的活动,需要在活动中展示当前活动的剩余时间;另一种是即将开始的活动,展示的是活动开始时间。从页面的表现来看,正在进行的活动需要不停地更新活动倒计时,而即将开始的活动则展示一个静态的时间,实际上两种活动状态都需要进行定时器计时,只不过一个是显式的,另一个是隐式的。

相比于其他定时器的应用场景来说,电商平台对定时器精度的要求可以说是最高的。

2.2 不同场景对定时器精度的要求

显而易见,不同的业务场景对定时器精度的要求是不一样的。在一些 APP 中,往往会有一些浏览特定页面换取奖励的任务,类似下图这样:


浏览页面一定时长换取积分

这个页面也用到了定时器,但几乎对定时器的精度没有任何要求,用户只要在这个页面停留一定的时长就可以。假如我们设定,需要用户在页面停留 10 秒能够获取奖励,实际上定时器不需要精确的计算 10 秒,稍长或者稍短一些也没有影响。

但是在电商平台中,由于有些抢购活动的商品价值较高,通常会设置有限的数量,往往会形成购买的人数远大于商品数量的情况。这种情况下,如果由于定时器计算活动开始时间存在误差,而导致用户没有抢到商品,甚至会让抢购活动起到相反的作用,造成一些负面的影响。


03

常见的定时器方案

我们来看一下常见的定时器如何实现上面的业务场景。

3.1 setInterval

setInterval 可以说是最常见的定时器方案了,绝大多数需要使用定时器的场景都可以用它来解决。另一个常见的定时器是 setTimeout,不过常用来进行延时任务的处理,原因是 setTimeout 只进行一次计时。

MDN 官网对 setInterval 的描述是该定时器会重复调用一个函数或执行一个代码段,在每次调用之间具有固定的时间延迟。使用它至少需要两个参数,其中第一个参数是计时到期后执行的函数或代码,也就是回调函数;第二个参数就是计时的毫秒数。

使用 setInterval 实现计时的代码如下:

function update() {  ...};
setInterval(() => { update();}, 1000);

这段代码创建了一个计时周期为 1000 毫秒的定时器,每次计时到期后都会执行一次 update 函数,用来处理相关的业务逻辑。

在绝大多数情况下,setInterval 都能够正常的运行,但在一些极端场景下,它也存在着缺陷。

例如定时器内的代码存在大量的计算,或者是若干 DOM 操作,这样一来,回调函数执行的时间会比较长,就有可能遇到前一次的代码还没有执行完,后一次的代码就已经被加入到执行队列。假如定时器的间隔设置为100 毫秒,而定时器内的代码需要执行 300 毫秒,就会出现连续执行两次回调函数的情况。比如下面的执行情况:

  1. 0 毫秒时执行 setInterval,100 毫秒后将要执行的代码插入事件队列;

  2. 100 毫秒后,定时器要执行的代码进入任务队列,任务队列空闲,代码执行;

  3. 200 毫秒时,定时器内的代码仍在执行之中,第二次的定时器代码被推入事件队列,等待任务队列空闲时执行;

  4. 300 毫秒时,第一次的定时器代码还在执行中,第二次的定时器代码在事件队列中等待执行,因为该定时器已经有第二次的代码在事件队列中等待了,所以这一次的代码不会被推入事件队列;

  5. 400 毫秒时,第一次的定时器代码执行完毕,任务队列空闲,下一个等待的代码执行,也就是第二次的定时器代码进入任务队列开始执行,同时第四次的定时器代码也被推入事件队列中等待执行。


极端情况下 setInterval 会连续执行

梳理过前几次的 setInterval 定时器代码执行情况后,发现第一次和第二次的代码执行间隔并不是预期的 100 毫秒,而是第一次的代码执行完,第二次的代码立即就已经在任务队列中等待执行了。而第三次的定时器代码,因为在第 300 毫秒时事件队列中已经有第二次的代码在等待,而当事件队列中没有该定时器的代码时,代码才会被推入事件队列排队,所以第三次的代码没有被推入事件队列。由此可见,在这种极端情况下,setInterval 定时器中的代码会连续执行,从而影响到我们的页面表现。

那么如何解决上述的这个问题呢?

3.2 链式调用 setTimeout

与 setInterval 相似,setTimeout 也是使用率非常高的定时器,但它只能计时一次。通过链式调用 setTimeout 定时器,则可以实现 setInterval 的功能。

let myInterval = (func, delay) => {  setTimeout(() => {    func();        myInterval(func, delay);  }, delay);};

myInterval 内部用 setTimeout 定时器,在指定的延时后将匿名函数推入事件队列,匿名函数中包含要执行的 func,以及 myInterval(func, delay)。匿名函数执行时,会先执行 func,然后递归调用 myInterval 来模拟 setInterval,递归调用的 myInterval中,又将执行 setTimeout,在 delay 延时后,将下一次的定时器代码推入任务队列等待执行。

可以发现,在将下一次的定时器代码推入事件队列时,上一次的代码无论如何都已经执行完了,所以不会出现 setInterval 的缺陷。


链式调用 setTimeout

使用链式调用 setTimeout 实现计时的代码如下:

myInterval(() => {  update();}, 1000);

可以看到,使用方式几乎与 setInterval 一模一样,只是需要在使用前先实现链式调用的函数。

链式调用 setTimeout 解决了 setInterval 在极端情况下的缺陷问题,同时,也带来了新的问题。


04

实现高复杂度计时业务时存在的一些问题

我们知道,当业务复杂度越高时,越会有一些意想不到的情况出现。在使用定时器的计时业务也是如此,下面来看一下在使用定时器时都可能会遇到哪些问题。

4.1 计时补偿

第一种情况,在业务中要求所有的计时都要在整点时进行,但我们无法保证代码执行的时间恰好是整点时间。遇到这种情况的时候,就需要我们对定时器执行做计时补偿,通过一些时间计算,确保定时器的执行能尽量靠近整点时间。

常见于计时单位为 1 秒的定时器中。

const now = Date.now();const nextFullSecond = (Math.floor(now / 1000) + 1)  * 1000;const fixTime = nextFullSecond - now;
function update() { ...};
function startInterval() { setInterval(() => { update(); }, 1000);};
setTimeout(() => { startInterval();}, fixTime);

第二种情况,长期使用链式调用 setTimeout 时引起的计时误差。由于 setTimeout 定时器的机制,会在执行时先继续执行当前任务队列中剩余的任务,再进行计时。这会导致回调函数执行的时间往往会略大于期望的计时时长,长此以往,误差会越来越大,这时也需要对定时器进行计时补偿。

let myInterval = (func, delay, fixTime = 0) => {  const startTime = Date.now();    setTimeout(() => {    func();        const endTime = Date.now();    fixTime = 1 + endTime - startTime - delay;        myInterval(func, delay, fixTime);  }, delay - fixTime);};

由于函数嵌套(层级达到一定深度),或者是由于已经执行的定时器回调函数阻塞,setTimeout 函数在零延迟的情况下等待时间仍会不小于10毫秒,所以我们在代码中设定的计时补偿值最小为 1。

4.2 定时器计时间隔超过阈值

定时器可接受的计时间隔是有上限的,目前 Chrome 浏览器下的最大值是 2147483647,即 2 的 32 次方减 1。在不大于这个值的情况下, 定时器可以正常计时执行,一旦超出这个值,定时器会将计时间隔设为 1 然后立即执行其中的回调函数。


定时器设置计时间隔超过阈值时的表现

由图中可以看到,第一个定时器执行后,回调函数中的代码始终没有执行,证明定时器在正常计时。第二个定时器设定的计时间隔超过了阈值,可以看到定时器回调函数中的代码立即执行了。

4.3 冗余计算与卡帧

目前大多数设备的屏幕刷新率为 60Hz(60次/秒)。而浏览器的功能主要是基于网站提供的内容(HTML、CSS、JavaScript 以及其他资源),经过解析、排版、绘制、格栅、合成等一系列浏览器内核的处理流程,把处理结果通过系统调用发送给操作系统,最终呈现在屏幕上。也就是说,如果在页面中有一个动画或者渐变的效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的速率应当与设备屏幕的刷新率保持一致。

如果浏览器多次重绘都集中在设备屏幕的一次刷新周期内,那么最终呈现在屏幕上的,将只是最后一次重绘的内容,也就是说在本次刷新周期中,额外的重绘与计算都将带来额外的性能损耗。

而浏览器如果在处理内容的流程上花费了过多的时间,那么浏览器重绘的内容将落后于屏幕的刷新率,这个时候页面就会看起来变得卡顿,对用户体验产生负面影响。


05

另一种前端定时器方案

既然 JavaScript 提供的定时器存在一些弊端,那有没有一种定时器能够解决以上这些问题呢?答案当然是有的。接下来,结合定时器原理与浏览器的渲染机制,介绍一个新的定时器方案。

5.1 浏览器页面渲染机制

不同的浏览器渲染机制虽然略有不同,但整体思路仍然相差不多。下面我们着重介绍一下 Chrome 浏览器中,每一帧的图像从计算到呈现在屏幕之上的过程。


图像进入屏幕的完整过程

从上图能够看到,浏览器渲染主要由渲染进程(Renderer Process)负责,其中大部分阶段都集中在主进程(Main Thread)。

  1. 新的一帧开始(Frame Start),由操作系统垂直同步信号触发,开始渲染新的一帧图像;

  2. 输入事件的处理(Input event handlers)之前,合成线程(Compositor Thread)接收到的用户 UI 交互输入在这一刻会被传入主线程(Main Thread),触发相关事件的回调,包括 touch event、input event、scroll event、click event 等;

  3. 执行 requestAnimationFrame 回调。这是更新屏幕显示内容的理想位置,因为现在有全新的输入数据,又非常接近即将到来的垂直同步信号。其他的可视化任务,比如样式计算,因为是在本次任务之后,所以现在是变更元素的理想位置。但需要注意的是,要避免强制同步布局;

  4. 解析HTML(parse HTML),如果有 DOM 变动,那么会有解析 DOM 这一过程;

  5. 重新计算样式(Recalc Styles)没如果在 JS 执行过程中修改了样式或改动了 DOM,那么便会执行这一步,重新计算指定元素及其子元素的样式。可能要计算整个 DOM 树,也可能缩小范围,取决于具体改动了什么;

  6. 布局(Layout),如果有涉及元素位置信息的 DOM 改动或样式改动,那么浏览器会重新计算所有元素的位置、尺寸信息,计算成本通常和 DOM 元素大小成比例。而单纯修改 color、background 等信息则不会触发回流;

  7. 更新图层树(Update Layer Tree),这一步创建层叠上下文,为元素的深度进行排序;

  8. 绘制(paint),计算得出更新图层的绘制指令。过程分两步:第一步,对所有新加入的元素,或进行改变现实状态的元素,记录 draw 调用;第二步是格栅化,在这一步实际执行了 draw 的调用,并进行纹理填充;

  9. 合成(Composite),图层和图块信息计算完成后,被传回合成线程进行处理;

  10. 如果此时主线程在下一帧到来之前还有时间的话,会执行 requestIdleCallback 回调;

  11. 帧结束(Frame End),各个层的所有的块都被格栅化成位图后,新的块和输入数据被提交给 GPU 线程;

  12. 最后,图块被 GPU 线程上传到 GPU,GPU 使用四边形和矩阵将图块 draw 在屏幕上。

在一个完整的渲染周期中,有两个阶段提供了回调函数供我们来执行代码,它们就是 requestAnimationFrame 和 requestIdleCallback。其中前者能够稳定执行,而后者则只会在当前这一帧还有空余时间时执行。

5.2 requestAnimationFrame

requestAnimationFrame 使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。回调函数会被传入 DOMHighResTimeStamp 参数,这个参数表示当前被 requestAnimationFrame 触发的回调函数开始执行的时间点。也可以通俗的理解为从页面开始到 requestAnimationFrame 的回调函数开始执行的时间。

正是如此,requestAnimationFrame 成为了我们解决定时器问题的首选方案,让我们再来梳理一下这个回调函数的特点:

  1. 每个帧周期都会执行一次;

  2. 执行时机恰好是在浏览器开始进入渲染动作之前;

  3. 提供一个回调函数作为参数,可以执行 JavaScript 代码。

值得注意的是,浏览器的节能机制也会影响到 requestAnimationFrame。为了节省 CPU、GPU和电力,浏览器会在页面处于非激活状态时停时刷新,同时 requestAnimationFrame 回调函数的执行也会被停职,直到页面再次被激活。

5.3 requestIdleCallback

MDN 官网上对 requestIdleCallback 的介绍是这样的:个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。是除了 requestAnimationFrame 之外在帧周期中另一个可以执行代码的地方。

使用 requestIdleCallback,将函数执行放在渲染之后的空闲时间之中。与 requestAnimationFrame 相比,区别是一个在渲染之前,一个在渲染之后,换句话说就是使用 requestIdleCallback 会比使用requestAnimationFrame 在渲染过程中整体落后一帧,但优点是这种方案可以最大化的利用 JS 执行进程。使用方式如下:

const tasks = [];const unnecessaryWork = (deadlint) => {  while(deadlint.timeRemaining() > 0 && tasks.length > 0) {    const task = tasks.shift();    ...  }  if (tasks.length > 0) {    requestIdleCallback(unnecessaryWork);  }};requestIdleCallback(unnecessaryWork);

需要注意的是,requestIdleCallback 还只是一个实验中的功能,如果我们想要使用它,需要进行 polyfill。


requestIdleCallback 的兼容性

5.4 定时器设计思想

JavaScript 是通过事件循环机制来实现任务调度的,当我们用 setTimeout 注册了一个异步任务后,这个异步任务会在适当的时候被推入任务队列。不同的 Runtime(如 Chrome、Node.js、Deno等)虽然对 setTimeout 的实现都不一样,但大体的思路是相同的。就是在执行 setTimeout 的时候将 timer 插入一个优先队列(或红黑树),然后在事件循环的每一个 tick 去检查 timer 事件,当检查到 timer 事件触发的时候,取出它对应的回调函数进行操作。


定时器设计思想

与 setTimeout 类似,我们在注册一个计时任务时,也将 timer 插入一个优先队列。只不过事件循环是在每一个 tick 检查 timer 事件,而我们是在每一个帧循环中进行检查。

定时器有两个核心函数。update 是定时器的基础,通过 requestAnimationFrame 回调函数形成定时器 tick。在 update 函数中,还会进行时间线维护和回调任务处理。pollTimerTask 函数负责处理注册的 timer 回调,先检查 timerQueue 中是否存在需要执行的 timer,如果存在的话将这个 timer 中的回调任务按照次序执行一遍。如果遇到需要周期执行的任务,则在任务执行完毕之后再次推入 timerQueue。pollTimerTask 函数代码如下:

...
const pollTimerTask = (time) => { if (timerQueue.length === 0) { return; }
while (timerQueue[0] && time >= timerQueue[0].time) { const timer = timerQueue.shift();
while (timer.tickerQueue.length) { const { id, callback, delay, loop, defer } = timer.tickerQueue.shift();
callback(time);
if (loop && idPool[id].exist) { let nextTime = timer.time + delay;
// 当回调函数执行时间超过多个执行周期时 if (time - nextTime > delay) { nextTime = nextTime + (Math.floor((time - nextTime) / delay) * delay);
// 延迟执行时,将 nextTime 推迟至下一个执行周期 defer && (nextTime += delay); }
registerTimerWithId({ id, callback, time: nextTime, delay, loop, defer }); } else { // 当回调函数不需要周期执行或在回调函数中执行 unregister 时 delete idPool[id]; } } }};
...

5.5 使用方式与效果

为了方便在不同的业务场景中使用,定时器提供了两个不同的 API。

第一个用于注册 tick 回调函数,这个回调函数完全与帧率同步,适合于运行一些 JavaScript 动画,以及作为一个游戏引擎的驱动来使用。

/** * 注册一个定时器 tick * @param {Function} callback 回调函数 * @returns {number} 返回一个 tickerId */register (callback: Function): number;


register 演示

可以看到,在启动定时器之后,register 函数中的回调函数在执行的时候,都会被传入一个距离上一次执行的间隔时间。经过计算,这个时间与当前浏览器的刷新频率也是吻合的,就是 60FPS。

第二个则更适合于大部分前端业务场景,通过不同的参数配置,可以确定不同的回调函数执行时机。具体配置如下:

/** * 注册一个定时器 * @param {object} options * @param {Function} options.callback - 定时器的回调函数 * @param {number} options.startTime - 定时器开始的时间戳,默认为当前定时器的系统时间(基于 start 方法传入的时间计算) * @param {boolean} options.loop - 定时器回调函数是否周期执行,默认为 false * @param {number} options.delay - 定时器回调函数周期执行的间隔,默认为 1000 毫秒,只有当 options.loop 为 true 时生效 * @param {immediate} options.immediate - 是否立即执行,默认为 false * @param {fix} options.fix - 将回调函数执行时间由毫秒修正为秒,默认为 false * @param {defer} options.defer - 当某一次回调执行时间超过 options.delay 时,是否立即执行,默认为 false。如果设为 true,将会跳过本次,在下个执行周期执行 * @returns {number} 返回一个 tickerId */setTimer (options: TimerConfig): number;


setTimer 常规演示

这是最基本的,类似于 setTimeout 的使用方式。可以看到定时器的回调函数在 2 秒后被执行了。


setTimer 演示 loop

这是类似 setInterval 的使用方式。定时器回调函数执行的周期为 2 秒,它也确实被准确地执行了。

setTimer 演示 fix

由于很多业务场景都需要定时器回调函数在整点时执行,这里为类似的场景做了扩展。当设置了 fix 参数时,定时器会自动修正回调函数执行的时机。从图中可以看出,定时器执行的时间并不是整点,但之后回调函数每次执行的时机都接近于整点。第一次执行会小于设定的间隔,就是因为定时器内部对执行时机进行了修正。


06

结语

常见的定时器 setTimeout 与 setInterval 适用于对计时精度要求不是很高的场景,它们都是 JavaScript 原生的定时器,没有额外的使用成本。而我们设计的基于帧循环的定时器,虽然能够很好的应对不同的业务场景并解除了诸多原生定时器的限制,但是也有额外的学习成本。当然,由于是基于帧循环,这种定时器计时也有着不大于一帧时长的误差。

我们最终还是要回归到业务,针对不同的业务场景选择更合适的技术方案。前端仍在不断的发展,浏览器的进化还会为我们提供更丰富的接口标准与开发工具。在技术发展的同时,我们也可以更优雅的更复杂实现业务场景了。


作者简介

莫日根,LBG 前端工程师,负责到家 APP 业务的开发工作,开源数据持久化工具 js-van 作者。


参考文献

[1] MDN Web Docs:http://developer.mozilla.org/zh-CN/

[2] The Anatomy Of A Frame:http://aerotwist.com/blog/the-anatomy-of-a-frame/

[3] 避免大型、复杂的布局和布局抖动 | Web | Google Developers:http://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing#avoid-layout-thrashing

本文分享自微信公众号 - 58技术(architects_58)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。