全局的 setTimeout()
方法设置一个定时器,一旦定时器到期,就会执行一个函数或指定的代码片段,但是需要注意的是,setTimeout
并不是 ECMAScript
标准的一部分,不过几乎每一个 JS 运行时都支持了这个函数。定时器的使用比较简单,这里不再阐述,我们这篇文章主要聊下关于 setTimeout 有最小时延的相关知识。
一、为什么定时器有最小时延
其实 setTimeout 是有最小时延的,这是为什么呢?有很多因素会导致 setTimeout 的回调函数执行比设定的预期值更久,这里列举一些导致定时器出现时延的原因:
1. 函数过度嵌套
MDN 文档:在浏览器中,每调用一次定时器的最小间隔是 4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),5 层以上的定时器嵌套会导致至少 4ms 的延迟。
let a = performance.now();
setTimeout(() => {
let b = performance.now();
console.log(b - a);
setTimeout(() => {
let c = performance.now();
console.log(c - b);
setTimeout(() => {
let d = performance.now();
console.log(d - c);
setTimeout(() => {
let e = performance.now();
console.log(e - d);
setTimeout(() => {
let f = performance.now();
console.log(f - e);
setTimeout(() => {
let g = performance.now();
console.log(g - f);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
在浏览器中的打印结果大概是这样的,和规范一致,第五次执行的时候延迟来到了 4ms 以上:
2. 非活动标签的超时
为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟,如果一个页面正在使用网络音频 API 播放声音,也可以不执行该延迟。
这方面的具体情况与浏览器有关:
- Firefox 桌面版和 Chrome 针对不活动标签都有一个 1 秒的最小超时值
- 安卓版 Firefox 浏览器对不活动的标签有一个至少 15 分钟的超时,并可能完全卸载它们
3. 追踪型脚本的节流
Firefox 对它识别为追踪型脚本的脚本实施额外的节流,当在前台运行时,节流的最小延迟仍然是 4ms,然而,在后台标签中,节流的最小延迟是 10000 毫秒,即 10 秒,在文档首次加载后 30 秒开始生效。
4. 超时延迟
如果页面(或操作系统/浏览器)正忙于其他任务,超时也可能比预期的晚,需要注意的一个重要情况是,在调用 setTimeout()
的线程结束之前,函数或代码片段不能被执行。例如:
function foo() {
console.log("foo 被调用");
}
setTimeout(foo, 0);
console.log("setTimeout 之后");
// 控制台输出:
// setTimeout 之后
// foo 被调用
出现这个结果的原因是,尽管 setTimeout
以 0ms 的延迟来调用函数,但这个任务已经被放入了队列中并且等待下一次执行,并不是立即执行;队列中的等待函数被调用之前,当前代码必须全部运行完毕,因此这里运行结果并非预想的那样。
5. 在加载页面时推迟超时
当前标签页正在加载时,Firefox 将推迟触发 setTimeout()
计时器,直到主线程被认为是空闲的,类似于 window.requestIdleCallback(),或者直到加载事件触发完毕,才开始触发。
二、为什么定时器最小时延是4ms
我们首先要知道是不是存在具体的规范来指定了 4ms, 还是只是业界实践的既定事实?
1. HTML standard
在 HTML standard 8.6 Timers-2020/6/23 中对于 setTimeout()
有详细的描述,我们只看其中的 10-13 行:
- If timeout is less than 0, then set timeout to 0.
- If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
- Increment nesting level by one.
- Let task's timer nesting level be nesting level.
从上面的规范可以看出来:
- 如果设置的 timeout 小于 0,则设置为 0
- 如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms
到这里,我们似乎已经找到了 4ms 的出处,并且对于 setTimeout 的最小延迟有了更加精确的定义 - 需要同时满足嵌套层级超过 5 层,timeout 小于 4ms,才会设置 4ms
2. 浏览器源码
除了寻找规范的出处之外,还可以去浏览器的源码中寻找答案,我们进入到谷歌浏览器源码中来查找用来设置计时器延时的地方:
static const int maxIntervalForUserGestureForwarding = 1000; // One second matches Gecko.
static const int maxTimerNestingLevel = 5;
static const double oneMillisecond = 0.001;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static const double minimumInterval = 0.004;
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel)
intervalMilliseconds = minimumInterval;
代码逻辑很清晰,设置了几个常量:
-
maxTimerNestingLevel = 5
,也就是 HTML standard 当中提到的嵌套层级 -
minimumInterval = 0.004
,也就是 HTML standard 当中说的最小延迟
在第二段代码中我们会看到,首先会在 延迟时间 和 1ms 之间取一个最大值,换句话说,在不满足嵌套层级的情况下,最小延迟时间设置为 1ms,这也解释了为什么在 chrome 中测试 setTimeout
是上面的结果。
在 chromium 的注释中,解释了为什么要设置 minimumInterval = 4ms
。简单来讲,本身 chromium 团队想要设置更低的延迟时间(其实他们期望达到亚毫秒级别),但是由于某些网站对 setTimeout
这种计时器不良的使用,设置延迟过低会导致 CPU-spinning,因此 chromium 做了些测试,选定了 4ms
作为其 minimumInterval。
到这里为止,从浏览器厂商角度和 HTML standard 规范角度都解释了 4ms
的来源和其更加精确的定义。
三、如何实现立刻执行的定时器
假设我们就需要一个「立刻执行」的定时器呢?有什么办法绕过这个 4ms 的延迟吗,其实可以采用 postMessage
来实现真正 0 延迟的定时器:
(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
的回调函数的执行时机和 setTimeout
类似,都属于宏任务,所以可以简单利用 postMessage
和 addEventListener('message')
的消息通知组合,来实现模拟定时器的功能,再利用上面的嵌套定时器的例子来跑一下测试:
全部在 0.1 ~ 0.3 毫秒级别,而且不会随着嵌套层数的增多而增加延迟
1. 测试验证
从理论上来说,由于 postMessage
的实现没有被浏览器引擎限制速度,一定是比 setTimeout 要快的,这里用数据进行验证:分别用 postMessage 版定时器和传统定时器做一个递归执行计数函数的操作,看看同样计数到 100 分别需要花多少时间
function runtest() {
let output = document.getElementById('output');
let outputText = document.createTextNode('');
output.appendChild(outputText);
function printOutput(line) {
outputText.data += line + '\n';
}
let i = 0;
let startTime = Date.now();
// 通过递归 setZeroTimeout 达到 100 计数
// 达到 100 后切换成 setTimeout 来实验
function test1() {
if (++i == 100) {
let endTime = Date.now();
printOutput(
'100 iterations of setZeroTimeout took ' +
(endTime - startTime) +
' milliseconds.'
);
i = 0;
startTime = Date.now();
setTimeout(test2, 0);
} else {
setZeroTimeout(test1);
}
}
setZeroTimeout(test1);
// 通过递归 setTimeout 达到 100 计数
function test2() {
if (++i == 100) {
let endTime = Date.now();
printOutput(
'100 iterations of setTimeout(0) took ' +
(endTime - startTime) +
' milliseconds.'
);
} else {
setTimeout(test2, 0);
}
}
}
结论如下:
2. Performance 面板分析
我们打开 Performance 面板,看看更直观的可视化界面中,两个版本的定时器是如何分布的:
左边的 postMessage
版本的定时器分布非常密集,大概在 5ms 以内就执行完了所有的计数任务
而右边的 setTimeout
版本相比较下分布的就很稀疏了,而且通过上方的时间轴可以看出,前四次的执行间隔大概在 1ms 左右,到了第五次就拉开到 4ms 以上
3. 无延迟的定时器的作用
你可能会有疑问,什么应用场景下需要用到无延迟的定时器?其实在 React 的源码中,做时间切片的部分就用到了:
const channel = new MessageChannel();
const port = channel.port2;
// 每次 port.postMessage() 调用就会添加一个宏任务
// 该宏任务为调用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask;
const scheduler = {
scheduleTask() {
// 挑选一个任务并执行
const task = pickTask();
const continuousTask = task();
// 如果当前任务未完成,则在下个宏任务继续执行
if (continuousTask) {
port.postMessage(null);
}
},
};
React 把任务切分成很多片段,这样就可以通过把任务交给 postMessage
的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务。