JS 定时器拾遗

今天看 JS 定时器定义的时候,发现了两个被我遗漏的点:

  1. HTML 标准 提到,5 层定时器嵌套后最小间隔不能小于 4ms

Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

  1. setTimeout() 方法不只接收两个参数,而是从第三个参数开始的所有其他参数都会被传递给定时器会执行的函数,参见 setTimeout MDN
1
2
3
4
5
6
7
8
setTimeout(code)
setTimeout(code, delay)

setTimeout(functionRef)
setTimeout(functionRef, delay)
setTimeout(functionRef, delay, param1)
setTimeout(functionRef, delay, param1, param2)
setTimeout(functionRef, delay, param1, param2, /* …, */ paramN)

4ms 时延

首先来看第一条,我比较好奇为什么会有这样的规定。

于是来到了这篇文章 聊聊定时器 setTimeout 的时延问题

文中提到,从 Chrome 源码看,浏览器开发者有能力不限制 setTimeout() 的时延,但是考虑到部分 web 开发者滥用 setTimeout 导致 CPU spinning 的问题,所以基于性能和 CPU 占用取了一个平衡值 4ms。

零时延定时器

文中还给出了一段使用 postMessage() 实现零时延的 setTimeout(),如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(function () {
var timeouts = [];
var messageName = 'message-zeroTimeout';

// 保持 setTimeout 的形态,只接受单个函数的参数,延迟始终为 0。
function setZeroTimeout(fn) {
timeouts.push(fn);
window.postMessage(messageName, '*');
}

function handleMessage(event) {
if (event.source == window && event.data == messageName) {
event.stopPropagation();
if (timeouts.length > 0) {
var fn = timeouts.shift();
fn();
}
}
}

window.addEventListener('message', handleMessage, true);

// 把 API 添加到 window 对象上
window.setZeroTimeout = setZeroTimeout;
})();

即使用 postMessage,浏览器没有限制这个方法的时延,文中提到用处:

React 把任务切分成很多片段,这样就可以通过把任务交给 postMessage 的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务。

考虑到上述被我遗漏的定时器的第二点,此处应该记下调用 setZeroTimeout() 时的参数,并将之传递给 fn(),于是调整如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(function () {
var timeouts = [];
var messageName = 'message-zeroTimeout';

// setTimeout 可以接收额外的参数,以传递给调用的函数本身
function setZeroTimeout(fn, ...args) {
timeouts.push({fn, args});
window.postMessage(messageName, '*');
}

function handleMessage(event) {
if (event.source == window && event.data == messageName) {
event.stopPropagation();
if (timeouts.length > 0) {
var { fn, args } = timeouts.shift();
fn(...args);
}
}
}

window.addEventListener('message', handleMessage, true);

// 把 API 添加到 window 对象上
window.setZeroTimeout = setZeroTimeout;
})();

被忽略的额外参数

根据上述的第二点,我又联想到一件事。回顾一下经典的 varlet 区别问题中的如下例子:

1
2
3
4
5
for (var i = 1; i <= 10; i++) {
setTimeout(() => {
console.log(i)
}, 1000 * i)
}

对于上述代码,运行的结果是隔 1s 输出一次 11,原因是 var 声明的变量所在的作用域是全局作用域,当 setTimeout() 执行时,这个全局的 i 已经变为了 11,因此定时器输出了 11。

基于此,可以预见的改进方案有:

  1. 使用函数作用域约束 i 的值
1
2
3
4
5
6
7
for (var i = 1; i <= 10; i++) {
(function (i) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
})(i);
}
  1. 使用 let 声明一个块级作用域的 i
1
2
3
4
5
for (let i = 1; i <= 10; i++) {
setTimeout(() => {
console.log(i)
}, 1000 * i)
}

这是常用的两种方法。但是联想到上述的第二点,其实还有一种改进的方案:

1
2
3
for (var i = 1; i <= 10; i++) {
setTimeout(console.log, 1000 * i, i)
}

这里,将 i 作为参数传递给 setTimeout(),由于函数作用域的存在,实际传递给执行函数——此处为 console.log 的参数不受外面的全局 i 的影响。

温故而知新