Skip to content
与原生
概述
setInterval 是 JavaScript 中最常见的周期性定时器,但它有一个已知问题:当单次回调的执行时间超过设定的间隔时间时,后续回调会不断累积,形成任务堆积。本节通过链式 setTimeout 实现一种“自定义间隔”,使得后续回调只在上一次执行结束后才开始计时,从而避免堆积。与原生 setInterval 不同的是,这里还允许首次延迟和后续周期间隔分别指定,并且取消机制采用外部状态标记,而非直接清除定时器句柄。
基本概念
- 首次延迟(delay):第一次执行回调前的等待时间。
- 周期间隔(period):从最近一次回调执行结束到下一次调度开始之间的等待时间。
- 链式调度:每次回调执行完毕之后再设置下一次
setTimeout,前后两次执行之间至少相隔一个period。 - 取消标记:由于没有统一的清除句柄可以回收,取消操作通过记录一个布尔标记实现。下一次回调触发时先检查该标记,若为
true则不再继续调度。
工作原理
与原生 setInterval 在固定的全局时间点上尝试触发回调不同,这里的调度链只在每次 fn 执行结束后才启动下一轮 setTimeout。这意味着:
- 两次回调执行的实际间隔总是
period + 上次 fn 的执行耗时(再加上事件循环中无法预知的调度延迟)。 - 如果
fn执行耗时已经超过period,调度链不会堆积任务,而是一个接一个地执行下去,每次之间不再有额外静默期。
取消并不是即时终止。设置标记后,当前正在等待的 setTimeout 回调仍会触发,但在该回调中检测到取消标记后就直接返回,不会执行 fn 也不会再设置下一次调度。
基本用法
API
customInterval(fn, delay, period)
fn: () => void— 待周期性执行的函数。delay: number— 首次执行前的延迟(毫秒)。period: number— 后续每次执行的周期间隔(毫秒)。- 返回值:
number— 定时器标识,用于customClearInterval。
customClearInterval(id)
id: number— 由customInterval返回的标识。- 作用:将对应定时器标记为“已取消”,下一次
setTimeout触发时会终止调度链。
示例
基本调度与取消
ts
const id = customInterval(
() => console.log('tick'),
1000, // 1 秒后首次执行
2000 // 之后每 2 秒执行一次
);
// 10 秒后取消
setTimeout(() => {
customClearInterval(id);
console.log('cleared');
}, 10000);输出:
(大约 1 秒后) tick
(再过 2 秒) tick
(再过 2 秒) tick
(再过 2 秒) tick
(大约 10 秒后) cleared与原生 setInterval 的对比
下面的例子模拟一个每次执行耗时约 200ms 的任务,并分别用原生 setInterval 和自定义 customInterval 以 100ms 间隔调度。
ts
function heavyTask() {
const start = Date.now();
while (Date.now() - start < 200) { /* 模拟耗时 200ms */ }
console.log('done', Date.now() % 10000);
}
// 原生 setInterval:间隔 100ms,但回调需要 200ms,任务会堆积
const nativeId = setInterval(heavyTask, 100);
// 自定义 interval:间隔 100ms,保证两次回调执行之间至少间隔 100ms
const customId = customInterval(heavyTask, 0, 100);
setTimeout(() => {
clearInterval(nativeId);
customClearInterval(customId);
}, 2000);原生 setInterval 表现:由于 heavyTask 的执行时间(200ms)大于间隔时间(100ms),浏览器每次在间隔时间点都会再次将新的 heavyTask 加入事件队列,导致回调几乎一个紧接一个地执行,产生堆积。
customInterval 表现:第一次 heavyTask 在 setTimeout 触发后开始执行,耗时约 200ms;执行完毕后才设置下一次 100ms 延迟的 setTimeout,因此两次 heavyTask 执行之间的实际间隔约为 300ms,不会出现任务堆积。
实现代码
ts
type IntervalFn = () => void;
interface CustomIntervalMap {
[id: number]: boolean;
}
const intervalMap: CustomIntervalMap = {};
let uid = 0;
function customInterval(fn: IntervalFn, delay: number, period: number): number {
const id = ++uid;
intervalMap[id] = false;
const schedule = (ms: number) => {
setTimeout(() => {
if (intervalMap[id]) return; // 已取消
fn();
schedule(period); // 后续使用周期间隔
}, ms);
};
schedule(delay); // 首次使用 delay
return id;
}
function customClearInterval(id: number): void {
intervalMap[id] = true;
}注意点
- 取消标记
intervalMap[id]只有在当前setTimeout触发时才会被读取。如果定时器正处于等待阶段,调用customClearInterval不会提前终止这次等待,只是在下一次回调执行前被拦截。 - 如果在
fn中抛出异常,schedule(period)之后的逻辑不会执行,调度链会直接终止。在实际使用中,可以根据需求决定是否捕获异常并继续调度:
ts
const schedule = (ms: number) => {
setTimeout(() => {
if (intervalMap[id]) return;
try {
fn();
} catch (e) {
// 决定是否继续调度
console.error(e);
return; // 这里选择终止链
}
schedule(period);
}, ms);
};- 不要使用类似
count++ * period + delay的累加方式计算下一次延迟。这种写法会使每次的延迟不断增大,无法形成固定节奏。
限制
- 精度:受事件循环和浏览器对
setTimeout的最小延迟限制(例如嵌套层级较深时最小延迟为 4ms)影响,实际间隔可能会略大于设定的period。 - 取消的即时性:
customClearInterval只是设置了取消标记,只有当对应的setTimeout回调执行时才能终止后续调度,无法提前“唤醒”等待中的定时器。 - 标识符回收:
uid只增不减,没有回收机制。但对于绝大多数运行周期有限的页面或脚本来说,这不会成为实际问题。
应用
这种“自定义间隔”的模式适用于以下几种场景:
- 回调执行时间不稳定且可能超过间隔时间:例如需要轮询某个接口或执行一个耗时不确定的计算。使用链式
setTimeout可以确保在上一次操作完成之后才开始下一次,避免请求堆积或计算重叠。 - 需要区分首次延迟与后续间隔:某些场景(如等待预热完成后再以固定节奏执行)希望在首次执行前有一个较长的延迟,后续则以较短间隔运行。
- 需要更精细的终止控制:虽然取消机制依然是“标记式”的,但可以很容易地扩展为 Promise 化的终止等待,而不用像
clearInterval那样只能立即清除。 - 与异步流程结合:配合
async/await,可以在fn中执行异步操作并等待其完成后再进入下一轮调度,而setInterval会将异步回调的返回视为立即完成,无法感知真实结束时间。
参考链接
- LeetCode 题目:2805. 自定义间隔
- MDN:setInterval
- MDN:setTimeout
