怎样用 setTimeout 实现 setInterval?两者有什么区别?
在 setTimeout 的回调里先执行任务,再安排下一次 setTimeout,就能循环执行,用一个变量保存最新的定时器 ID 以便取消。区别主要在异步任务上:setInterval 只管按间隔触发回调,回调里发出的请求还没回来,下一次照样触发;链式 setTimeout 可以等请求完成后再安排下一次,还能根据结果调整间隔或停止。
简答
比如每 5 秒轮询一次订单状态。可以用
setInterval(poll, 5000),也可以在setTimeout的回调里执行完poll再安排下一次setTimeout,一次接一次,效果也是循环执行。手写时要注意取消。每一轮都会产生新的定时器 ID,所以要用一个变量保存最新的 ID,取消时清掉它,再加一个标志,防止正在执行的这一轮又安排下一轮。
两者的区别主要体现在异步任务上。
setInterval只管按间隔触发回调,回调里发出的请求还没回来,下一次照样触发,请求就会堆在一起。链式setTimeout可以等请求完成以后再安排下一次,所以同一时间只有一个请求,还能根据结果调整间隔或者停止。MDN 也建议,逻辑执行时间可能超过间隔时,用链式setTimeout。要注意,两者都不保证准时。主线程忙、页面在后台标签页时,实际间隔都会比设定的长;HTML 规范还规定,定时器嵌套超过 5 层后,小于 4 毫秒的延迟会被提高到 4 毫秒。
手写 mySetInterval
下面的 mySetInterval 返回一个取消函数。示例让回调执行 3 次后取消,延迟写成 10 毫秒只是为了运行得快。
function mySetInterval(callback, delay) {
let timer = null;
let stopped = false;
function tick() {
callback();
// 回调里可能已经调用了取消函数,这时不再安排下一轮。
if (!stopped) timer = setTimeout(tick, delay);
}
timer = setTimeout(tick, delay);
return function cancel() {
stopped = true;
clearTimeout(timer);
};
}
const calls = [];
const cancel = mySetInterval(() => {
calls.push(calls.length + 1);
if (calls.length === 3) cancel();
}, 10);
setTimeout(() => {
console.log(calls.join(',')); // 1,2,3
}, 100);
100 毫秒后打印 1,2,3。按 10 毫秒一轮,这段时间足够跑好几轮,结果只有 3 次,说明取消以后没有再执行。
为什么除了 clearTimeout 还要一个 stopped 标志?因为第 3 次是在回调里面调用 cancel() 的。这时 timer 指向的是已经触发的那个定时器,清掉它没有作用;callback() 返回后,tick 还会接着安排下一轮。有了 stopped,tick 就知道已经取消了,不再安排。
为什么不能直接返回定时器 ID?因为每一轮的 ID 都不一样,返回第一个 ID,之后调用 clearTimeout 清掉的只是第一轮,循环还会继续。所以把最新的 ID 放在闭包里,通过返回的函数来取消。
异步任务为什么更适合链式 setTimeout
回调是同步代码时,两种写法差别不大。按 HTML 规范的定时器算法,setInterval 也是在回调运行完以后才重新开始计时的。差别出在异步任务上:回调只是发出请求就返回了,对定时器来说这一轮已经结束。
如果回调发出一个请求,请求要 3 秒,用 setInterval(poll, 1000) 会每秒发一个新请求,前面的还没回来,后面的又发出去了。链式 setTimeout 可以在请求完成以后再安排下一次:
async function poll() {
try {
await fetchStatus();
} finally {
timer = setTimeout(poll, 1000);
}
}
这样同一时间只有一个请求,还能根据返回结果决定下一次的间隔,比如失败了就把间隔加长,状态完成了就不再安排。
为什么定时器不准时
定时器到期后,回调只是被放进任务队列,要等主线程把当前的同步代码和微任务执行完才会运行,见《事件循环中,任务和微任务按什么顺序执行?》。所以延迟是“至少等这么久”,不是“正好这么久”。
另外 MDN 写明,为了省电,浏览器会对后台标签页的定时器限制最小间隔。HTML 规范规定,定时器嵌套层数超过 5 层以后,小于 4 毫秒的延迟会被提高到 4 毫秒,这一条对 setTimeout 链和 setInterval 都适用。需要准确计时的场景,比如倒计时,应该每次用 Date.now() 算出剩余时间,而不是靠累加次数。
常见追问
组件卸载时忘了清除定时器会怎样?
回调会继续执行,它通过闭包引用的数据也释放不掉。在 React 里应该在
useEffect的清理函数中调用clearInterval,或者调用手写版本返回的取消函数。
setInterval 的回调执行时间超过间隔,会不会叠在一起执行?
不会同时执行,JavaScript 主线程一次只能跑一个回调,HTML 规范里
setInterval也是在回调运行完以后才安排下一次。真正会叠起来的是回调里发出的异步请求,因为请求不占主线程,定时器照样按间隔触发,新请求会不停地发出去。