跳到主要内容

定时器

Timers

Timers

诸如setTimeoutsetInterval属于定时器类型的任务类型,在页面事件循环的机制中,定时器类型的任务最终也会被放到任务队列中去处理。

从 whatwg 规范定义的内容来看,无论是setTimeout还是setInterval都会经过一个初始化定时器的步骤,并且它们俩用的都是同一套算法,同一个定时器任务列表,见 —— timer-initialisation-steps,大致的步骤如下:

  • 如果提供了previous handle,则让当前定时任务的handlehandle就是当前定时任务的 Id)等于previous handle;如果没有,取一个大于 0 的整数来作为handle,并在定时器任务列表(list of active timers)中添加一条记录;

  • 准备将回调函数执行封装成一个任务:

    • 如果当前定时器任务列表中此handle的任务已经被清除,那么就终止算法过程;
    • 在任务中调用回调函数
    • 判断当前重复执行的标记位(repeat flag),如果为 true 则回头调用这个算法;
  • 设置timeout

  • 判断任务队列中currently running task是否是之前设定过的定时任务,如果是的话设置嵌套级别nesting level等于currently running tasktimer nesting level,如果不是则设置嵌套级别nesting level等于 0;

  • 如果timeout小于 0,设置timeout等于 0;如果嵌套级别nesting level大于 5,设置timeout等于 4

  • nesting level += 1

  • 让当前定时任务的timer nesting level 等于nesting level

  • 返回handle,然后继续并行运行此算法

  • 等待延迟时间结束,将定时任务排列到任务队列中去执行。

从浏览器引擎的角度来看,实际上还会存在一种延迟执行的任务队列DelayedIncomingQueue,用于保存定时器任务。主线程在执行 JS 的过程中如果遇到setTimeout之类的定时器,会根据其回调函数和当前发起setTimeout的时间,以及延迟执行的时间去创建一个回调任务,保存到延迟执行的任务队列中,然后继续执行当前任务。在等待setTimeout中设置的延迟以后,就会将延迟队列中的任务取出并添加到任务队列中去等待执行。

如果在延迟队列中的任务尚未执行,可以使用clearTimeout从延迟队列中删除任务。

image-20200821182409351

in parallel

whatwg - in-parallel

并行运行指的是,某一个算法与规范中定义的其它算法会同时运行,而不是算法中的步骤会并行运行,算法中的步骤仍然会一个接一个地按顺序执行。相比之下,要立即运行的操作必须中断任务队列中的 currently running task,先执行它,然后再恢复之前的 currently running task。

为了避免不同的并行运行的算法在操作相同数据时发生竞争关系( race conditions ),会使用一个并行队列。并行队列具有一个算法队列,算法队列中的步骤必须连续一个接一个的按顺序执行。

将执行步骤放入并行队列中,就是将步骤排列到算法队列中去执行。

setTimeout

MDN - setTimeout

setTimeout的语法有两种:

setTimeout(handler, timeout, [arguments... ])

handler:回调函数

timeout:毫秒数

arguments:传递给 handler function 的参数

返回一个正整数,表示当前计时器的 Id,可以使用clearTimeout()方法来根据 Id 清除某个计时器

setTimeout(code, timeout)

code: 一串代码字符串

timeout:毫秒数

不推荐这种形式的回调,一方面,字符串形式的代码需要二次解析成可执行代码,另一方面,这种形式容易带来和eval()一样的安全风险

关于setTimeout需要注意以下几点:

  • timeout 是毫秒数,也就是秒乘以 1000,并且当 timeout 设置小于 0 时,会被按照 0 处理;
  • timeout 设置的延迟时间不是经过该时间就精确执行的时间,要算上 CPU 延迟和任务队列等待执行任务的时间;也就是设置 timeout = 0,也还是会导致其一定程度的延迟执行;
  • 根据 whatwg 规范的定义,setTimeout可以嵌套调用,但是当嵌套五次以后,执行回调的间隔时间将会被节流到至少 4ms,见 —— whatwg - timers部分的 Note;
function cb() {
setTimeout(cb, 0);
}
setTimeout(cb, 0);

image-20200820161521104

  • 而对于未激活的浏览器标签页面内的定时器,也将会被节流,最小执行回调的间隔时间为 1000ms;
  • 延迟执行时间的保存使用 32 个 bit 来存储,其中还有一个符号位,也就是延迟执行的最大设置时间只能到 2^31 - 1 = 2147483647ms,如果超过这个时间,就相当于是 0 来看待;
  • 最后值得注意的就是在setTimeout中传入的回调函数会发生this指向变化的问题,这个在作用域的介绍里已经解释过了,一般解决方式是使用箭头函数或者使用bind

用 setTimeout 实现 setInterval

上文说过,setTimeoutsetInterval用的是同一个list of active timers,并且算法步骤也基本一样。所以完全可以用setTimeout实现一个setInterval,一般来说,很容易想到递归的方式,即在setTimeout回调中再次调用函数:

function _setInterval(fn, timeout) {
setTimeout(() => {
fn();
_setInterval(fn, timeout);
}, timeout);
}

// test
function test() {
console.log(1);
}

_setInterval(test, 1000);

但是这种方式设置的setInterval没法清除啊,这往下走肯定奔着内存泄漏去了,可以利用 Chromium 那个延迟队列的方式来做:

  • 创建一个数组用来保存 interval 的任务;
  • 每次使用setTimeout来创建延迟任务的时候,先检查数组中是否存在当前 interval 的 Id,如果存在就设置定时任务,并且在回调中再次调用setTimeout创建下一个定时任务;
  • 根据 Id 清除setInterval任务的时候,删除数组中相应的元素即可;
  • 最后设置setInterval的时候,需要最后更新一下全局 interval 的 Id,这样保证下一次设置的setInterval不会重复
let delayStack = [];
let id = 1;
function _setInterval(fn, timeout) {
let intervalId = id;
let interval = {
id: intervalId,
func: fn,
};

delayStack.push(interval);

const _fn = () => {
if (delayStack.find(item => item.id === intervalId)) {
setTimeout(() => {
fn();
_fn();
}, timeout);
}
};

_fn();

id++;
}

function _clearInterval(clearId) {
const index = delayStack.findIndex(item => item.id === clearId);
delayStack.splice(index, 1);
}

requestAnimationFrame

在过去 CSS 还不是太牛逼的时候,可以利用 JS 的setTimeout或者setInterval来做动画。但是setTimeout等定时器受限于浏览器处理任务的事件循环机制,也就是定时结束的回调函数只是推入任务队列等待执行,而不是定时结束立即执行。举个例子,定时器计算时间期间,页面又触发了大量 DOM 回调事件,那么定时器回调函数就可能在 DOM 响应后执行。

同时,大多数普通显示器的屏幕刷新率都在 60Hz,也就是每秒呈现 60 帧画面,可以计算出每帧画面的间隔时间为:

1000ms/60=16.66666ms1000ms / 60 = 16.66666ms

也就是显示器在绘制出上一帧画面之后,最快需要经过上面的时间间隔才能绘制呈现下面的一帧画面。而即使将setTimeout的时间设置为16.66,也无法保证跟上屏幕刷新的速度,这就导致动画掉帧的情况。

requestAnimationFrame是 HTML 规范定义的 API,用于跟随屏幕刷新率定时执行回调函数,可用于制作性能要求高的动画效果。

const animationId = window.requestAnimationFrame((timeStamp: DOMHighResTimeStamp) => {
//...再次调用 requestAnimationFrame
});

window.cancelAnimationFrame(animationId)

requestAnimationFrame方法只有一个参数,是传入一个回调函数,该回调函数会接受一个双精度时间戳数字DOMHighResTimeStamp作为参数,表示调用当前回调函数时一个精确的时间值;该时间戳的精度值可以精确到 5μs(微秒)。

image-20200824171500907

机制

在事件循环的机制中,requestAnimationFrame的回调函数会在当前任务以及微任务都执行完,并且准确下一帧动画渲染之前被调用,所以requestAnimationFrame总能保证每次执行回调函数的时间间隔和浏览器的刷新频率保持一致,从此不用担心应该为动画间隔指定多少延迟时间而感到焦虑了。

从 whatwg 的规范来看,每一个页面实际上有一个map结构用来保存每次通过requestAnimationFrame设置的回调函数,见 —— run requestAnimationFrame,而到事件循环机制中去需要去执行requestAnimationFrame设置的回调函数的时候,会遍历map,取出回调函数执行,见 —— run the animation frame callback

下面这段程序设置在页面中从左往右移动 200px,动画时间 2s,速度0.1px/ms

<div id="app" style="width: 100px; height: 100px; background: red"></div>

const element = document.getElementById('app');
let start, previousTimeStamp;
let done = false

function step(timestamp) {
if (start === undefined) {
start = timestamp;
}
const elapsed = timestamp - start;

if (previousTimeStamp !== timestamp) {
// Math.min() is used here to make sure the element stops at exactly 200px
const count = Math.min(0.1 * elapsed, 200);
element.style.transform = 'translateX(' + count + 'px)';
if (count === 200) done = true;
}

if (elapsed < 2000) { // Stop the animation after 2 seconds
previousTimeStamp = timestamp
!done && window.requestAnimationFrame(step);
}
}

window.requestAnimationFrame(step);