定时器与异步任务
setTimeout、setInterval 是 JavaScript 中最基础的异步调度工具,配合微任务 queueMicrotask 与浏览器帧回调 requestAnimationFrame,可以精确控制"什么时候做什么事"。本文系统讲解这些 API 的用法、返回值、延迟规则,最后落地到防抖与节流两个高频实践。
一、setTimeout 基本用法与返回值
setTimeout(callback, delay) 在 delay 毫秒之后把 callback 放入任务队列,等当前任务执行完才会运行:
console.log("同步代码");
setTimeout(() => {
console.log("定时器回调");
}, 1000);
console.log("同步代码结束");
// 输出顺序:同步代码 → 同步代码结束 →(约 1 秒后)定时器回调返回值是定时器标识,用于之后清除,但浏览器和 Node.js 返回的类型不同:
| 环境 | setTimeout 返回值 | 类型 | 可用于 clearTimeout |
|---|---|---|---|
| 浏览器 | 自增的数字 id | number | 是 |
| Node.js | Timeout 对象 | object | 是(也支持 .unref() 等扩展) |
// 浏览器
const id = setTimeout(() => {}, 1000);
console.log(typeof id); // number
clearTimeout(id);
// Node.js
const t = setTimeout(() => {}, 1000);
console.log(t instanceof Object); // true,是 Timeout 对象
clearTimeout(t);二、clearTimeout 清除定时器
定时器注册后,只要还没到时间就可以取消:
const id = setTimeout(() => {
console.log("这行不会打印");
}, 3000);
clearTimeout(id); // 取消成功注意两点:
- 回调已经执行后再调
clearTimeout无意义,但不会报错。 - 回调执行之前调用才有效;多次清除也安全。
let count = 0;
const id = setTimeout(function tick() {
count++;
console.log(count);
}, 2000);
// 如果 1 秒后取消:count 永远停在 0
setTimeout(() => clearTimeout(id), 1000);三、setInterval 与 clearInterval
setInterval(callback, interval) 每隔 interval 毫秒执行一次回调,直到被清除:
const timer = setInterval(() => {
console.log("滴答", new Date().toLocaleTimeString());
}, 1000);
// 5 秒后停止
setTimeout(() => {
clearInterval(timer);
console.log("定时器已停止");
}, 5000);返回值同样遵循"浏览器返回数字、Node 返回 Timeout 对象"的规则,传给 clearInterval 即可。
四、setTimeout 与 setInterval 区别
两者虽然长相相似,但调度语义完全不同:
| 对比项 | setTimeout | setInterval |
|---|---|---|
| 执行次数 | 一次 | 无限次(直到清除) |
| 延迟起点 | 调用时刻起算 | 上次回调开始起算 |
| 回调耗时影响 | 无影响 | 可能造成堆积或跳过 |
| 典型用途 | 延迟执行、倒计时 | 轮询、心跳、周期任务 |
setInterval 的核心缺陷:当回调执行时间超过间隔时,下一次触发会被跳过(浏览器通常不会再排队多个),或者形成"回调堆积",时序不稳定。
五、嵌套 setTimeout 模拟 setInterval
用"回调内部再注册 setTimeout"的方式实现周期任务,彻底解决 setInterval 的堆积问题:
function repeat(fn, delay) {
function loop() {
fn();
timer = setTimeout(loop, delay); // 下一次从"本次执行完"才开始计时
}
let timer = setTimeout(loop, delay);
return {
stop() { clearTimeout(timer); },
};
}
const job = repeat(() => {
console.log("执行任务", new Date().toLocaleTimeString());
}, 1000);
// job.stop() 停止| 对比项 | setInterval | 嵌套 setTimeout |
|---|---|---|
| 两次回调间隔 | 从回调开始起算,执行慢会堆积/跳过 | 从回调结束起算,间隔稳定 |
| 执行慢的兜底 | 间隔被吞 | 自动顺延 |
| 动态调整间隔 | 不支持 | 每次可改 delay |
| 使用场景 | 简单轮询 | 动画步进、异步任务链 |
六、setTimeout(fn, 0) 与最小延迟
setTimeout(fn, 0) 并不是"立即执行",而是"尽快排入任务队列"——至少等当前同步代码和微任务全部跑完:
console.log("A");
setTimeout(() => console.log("C"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("D");
// 输出:A → D → B → C(微任务 B 先于宏任务 C)真正的最小延迟由运行时钳制(clamp):
| 环境 | 最小延迟 | 说明 |
|---|---|---|
| 浏览器 | 0ms(非嵌套) | 未激活标签页还会放大到 1s+ |
| 浏览器嵌套调用 | 4ms | 嵌套第 5 层起生效(如递归 setTimeout) |
| Node.js | 1ms | 非零延迟统一钳制到至少 1ms |
// 浏览器中:嵌套 setTimeout 会被钳制到 4ms
let depth = 0;
function nest() {
depth++;
if (depth > 10) return;
setTimeout(nest, 0); // 从第 5 层起实际间隔 ≥ 4ms
}
nest();七、this 绑定问题
定时器回调由运行时以默认绑定调用,普通函数回调中的 this 不是定义时的对象:
const counter = {
value: 0,
start() {
setInterval(function () {
this.value++; // 错误!浏览器中 this 是 window,Node 中是 undefined
}, 1000);
},
};两种修复方式:
const counter = {
value: 0,
start() {
// 方式一:箭头函数捕获外层 this
setInterval(() => { this.value++; }, 1000);
// 方式二:bind 固定 this
setInterval(function () { this.value++; }.bind(this), 1000);
},
};八、参数传递
setTimeout、setInterval 从第三个参数开始,会原样传给回调:
function greet(name, city) {
console.log(`你好 ${name},来自 ${city}`);
}
setTimeout(greet, 500, "张三", "北京"); // 你好 张三,来自 北京
// 注意:延迟必须写在参数之前
setTimeout(greet, 500, "李四", "上海");兼容性提醒:向回调传参是 HTML5 规范,老旧环境(如 IE9 及以前)不支持,此时用箭头函数包裹更稳妥:
setTimeout(() => greet("王五", "广州"), 500);九、queueMicrotask 与 Promise 微任务
queueMicrotask(callback) 把回调注册为微任务,在"当前宏任务结束后、下一个宏任务开始前"执行,优先级高于定时器:
queueMicrotask(() => console.log("微任务"));
Promise.resolve().then(() => console.log("Promise 微任务"));
setTimeout(() => console.log("定时器(宏任务)"), 0);
// 输出:微任务 → Promise 微任务 → 定时器(宏任务)| 对比项 | queueMicrotask / Promise.then | setTimeout(fn, 0) |
|---|---|---|
| 任务类型 | 微任务 | 宏任务 |
| 执行时机 | 当前宏任务收尾时 | 下一个宏任务阶段 |
| 相对顺序 | 先执行 | 后执行 |
| 适合场景 | 快速续接、避免深层回调 | 让出主线程、节流高频事件 |
queueMicrotask 与 Promise.resolve().then(fn) 等价,但语义更直接、少一次 Promise 包装。微任务队列中的代码要避免死循环,否则会阻塞后续宏任务。
十、requestAnimationFrame(浏览器)
requestAnimationFrame(callback) 让回调在下一帧绘制之前执行,频率自动对齐屏幕刷新率(常见 60Hz,即约 16.7ms 一帧):
let start = performance.now();
function step(now) {
const elapsed = now - start;
box.style.transform = `translateX(${Math.min(elapsed * 0.1, 300)}px)`;
if (elapsed < 3000) requestAnimationFrame(step); // 继续下一帧
}
requestAnimationFrame(step);取消方式:cancelAnimationFrame(id),id 由 rAF 返回:
const id = requestAnimationFrame(step);
cancelAnimationFrame(id); // 不再调度下一帧| 对比项 | requestAnimationFrame | setTimeout |
|---|---|---|
| 执行时机 | 每帧绘制前(对齐刷新率) | 到点入队 |
| 帧率适配 | 自动跟随屏幕刷新率 | 固定延迟,可能掉帧或超帧 |
| 页面隐藏时 | 自动暂停,省电 | 继续运行(可被节流) |
| 回调参数 | 自动传入时间戳 | 无 |
| 适用场景 | 动画、canvas 绘制、布局测量 | 通用延迟任务 |
原则:动画用 rAF,延迟用 setTimeout,两者不要混用于同一逻辑。
十一、requestIdleCallback 简介(浏览器)
requestIdleCallback(callback) 在浏览器空闲时段执行回调,优先级最低,适合不紧急的后台任务:
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回本次空闲还剩多少毫秒
while (deadline.timeRemaining() > 0 && queue.length) {
process(queue.shift()); // 在剩余空闲时间内分批处理
}
}, { timeout: 2000 }); // 兜底:最多等 2 秒必须执行使用建议:
- 适合:埋点上报、非关键日志、懒加载前的预处理、分批渲染大数据。
- 不适合:任何影响视觉的任务(下一帧就要看到结果),这类任务请用 rAF。
deadline.didTimeout为true表示因timeout兜底被强制执行。
十二、定时器在事件循环中的位置
在事件循环中,定时器属于宏任务:每个定时器到期后,其回调被放入宏任务队列,在下一轮"定时器阶段"取出执行;而微任务(Promise、queueMicrotask)在每个宏任务结束后立即清空。
宏任务队列:setTimeout → setInterval → I/O → 渲染(浏览器)
↓ 每个宏任务结束后
微任务队列:Promise.then / queueMicrotask(全部清空后才进入下一个宏任务)setTimeout(() => console.log("定时器 1"), 0);
setTimeout(() => console.log("定时器 2"), 0);
Promise.resolve().then(() => console.log("微任务 1"));
Promise.resolve().then(() => console.log("微任务 2"));
// 输出:微任务 1 → 微任务 2 → 定时器 1 → 定时器 2两条铁律:微任务永远先于同轮宏任务;多个定时器按到期时间排序执行。
十三、防抖与节流实现
防抖与节流是控制高频事件(输入、滚动、缩放)触发频率的经典手法,基于定时器实现。
13.1 防抖 debounce:只执行最后一次
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 取消上一次
timer = setTimeout(() => fn.apply(this, args), delay); // 重新计时
};
}
// 用法:输入停止 300ms 后才请求
const onInput = debounce((keyword) => {
console.log("搜索:", keyword);
}, 300);13.2 节流 throttle:固定频率执行
function throttle(fn, interval = 300) {
let last = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remain = interval - (now - last);
if (remain <= 0) {
clearTimeout(timer);
last = now;
fn.apply(this, args); // 立即执行一次
} else if (!timer) {
timer = setTimeout(() => { // 间隔内最后一次补执行
last = Date.now();
timer = null;
fn.apply(this, args);
}, remain);
}
};
}
// 用法:滚动时最多每 300ms 执行一次
const onScroll = throttle(() => {
console.log("滚动位置:", window.scrollY);
}, 300);| 对比项 | 防抖 debounce | 节流 throttle |
|---|---|---|
| 执行特点 | 连续触发只执行最后一次 | 固定间隔执行(保证至少一次) |
| 延迟响应 | 有,触发后需等 delay | 有节奏,不攒最后一击 |
| 适合场景 | 搜索建议、窗口 resize 后重排 | 滚动/拖拽监听、按钮防连点 |
| 类比 | 电梯等人关门 | 地铁按班次发车 |
防抖和节流都接受"参数透传 + this 保留"(示例中通过 fn.apply(this, args) 实现),可安全用于对象方法。需要"立即执行版防抖"或"尾随节流"时,在基本版基础上扩展标志位即可。
定时器 API 虽简单,但延迟语义、this 绑定、宏微任务顺序这些细节直接决定代码行为,实战中按"动画走 rAF、微任务走 queueMicrotask、周期任务走嵌套 setTimeout、频率控制走防抖节流"的口诀选择即可。