JavaScript 定时器是异步编程的核心工具之一,用于延迟执行代码或周期性执行任务。常见的定时器 API 包括 setTimeout、setInterval 和 requestAnimationFrame,它们在浏览器的事件循环机制中工作,行为各有特点。本文将详解其原理、优缺点,并通过 10 个案例说明常见问题及解决方法。
一、定时器核心 API 原理
- setTimeout(fn, delay)
-
- 延迟 delay 毫秒后执行一次 fn(单位:毫秒,默认 0)。
-
- 特点:回调函数进入 "任务队列",需等待主线程空闲后执行,实际延迟可能大于 delay。
- setInterval(fn, interval)
-
- 每隔 interval 毫秒重复执行 fn。
-
- 特点:若前一次回调未执行完,后一次会 "堆积",导致执行间隔不稳定。
- requestAnimationFrame(fn)
-
- 与浏览器重绘同步,通常每秒执行 60 次(约 16ms 一次)。
-
- 特点:适合动画场景,页面隐藏时会暂停,性能更优。
二、定时器优缺点对比
| 定时器 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| setTimeout | 简单灵活,单次执行 | 延迟不精确,受主线程阻塞影响 | 单次延迟任务(如弹窗提示) |
| setInterval | 适合周期性任务 | 可能累积执行,间隔不稳定 | 轮询、定时刷新(非高频) |
| requestAnimationFrame | 与浏览器渲染同步,性能好 | 依赖浏览器刷新频率,无法指定精确间隔 | 动画效果(如进度条、动效) |
三、10 个实战案例及解决方法
案例 1:基础使用 setTimeout 延迟执行
问题:需要 2 秒后打印消息,但实际延迟可能更长。
代码:
console.log('开始');
setTimeout(() => {
console.log('2秒后执行');
}, 2000);
console.log('结束'); // 先执行,体现异步特性
分析:setTimeout 是异步任务,主线程先执行同步代码,再执行回调。
解决:无问题,这是正常行为,需理解 JavaScript 单线程异步机制。
案例 2:setInterval 周期性执行的累积问题
问题:若回调执行时间超过间隔,会导致多次回调堆积。
代码:
// 间隔 100ms,但回调执行 200ms,会导致堆积
let count = 0;
const timer = setInterval(() => {
count++;
console.log('执行次数:', count);
// 模拟耗时操作
let start = Date.now();
while (Date.now() - start < 200) {}
}, 100);
分析:setInterval 不关心前一次回调是否完成,到时间就加入队列,导致密集执行。
解决:用 setTimeout 递归调用,确保前一次执行完再调度下一次:
let count = 0;
function loop() {
count++;
console.log('执行次数:', count);
// 模拟耗时操作
let start = Date.now();
while (Date.now() - start < 200) {}
// 前一次执行完再调度
setTimeout(loop, 100);
}
loop();
案例 3:setTimeout 嵌套导致的延迟放大
问题:嵌套的 setTimeout 延迟会被浏览器限制(超过 5 层后最小延迟为 4ms)。
代码:
let i = 0;
function nested() {
console.log(`第 ${i++} 次,延迟 1ms`);
if (i < 10) {
setTimeout(nested, 1); // 实际延迟可能逐渐变为 4ms
}
}
nested();
分析:HTML5 规范规定,嵌套层级 ≥5 时,setTimeout 最小延迟为 4ms,避免恶意代码阻塞浏览器。
解决:若需高频执行,改用 requestAnimationFrame 或 Web Worker。
案例 4:忘记清除定时器导致内存泄漏
问题:组件销毁后定时器仍在运行,导致内存泄漏。
代码:
// 假设在 React/Vue 组件中
class MyComponent {
componentDidMount() {
this.timer = setInterval(() => {
console.log('轮询中...');
}, 1000);
}
// 忘记清除定时器
// componentWillUnmount() { clearInterval(this.timer); }
}
分析:定时器持有组件引用,导致组件无法被垃圾回收。
解决:在组件销毁 / 页面卸载时清除定时器:
// React 示例
componentWillUnmount() {
clearInterval(this.timer); // 清除定时器
}
// 页面卸载时
window.addEventListener('beforeunload', () => {
clearInterval(timer);
});
案例 5:this 指向丢失问题
问题:定时器回调中的 this 指向全局对象(非严格模式)或 undefined(严格模式)。
代码:
const obj = {
name: '测试',
start() {
setTimeout(function() {
console.log(this.name); // 输出 undefined(严格模式)
}, 1000);
}
};
obj.start();
分析:普通函数作为回调时,this 指向由调用方式决定(此处为全局 /undefined)。
解决:用箭头函数(继承外部 this)或 bind 绑定:
// 箭头函数
setTimeout(() => {
console.log(this.name); // 输出 '测试'
}, 1000);
// 或 bind
setTimeout(function() {
console.log(this.name);
}.bind(this), 1000);
案例 6:requestAnimationFrame 实现平滑动画
问题:用 setInterval 做动画可能卡顿(与浏览器渲染不同步)。
代码:
// 卡顿的动画
let left = 0;
const box = document.getElementById('box');
setInterval(() => {
left += 1;
box.style.left = left + 'px';
}, 16); // 假设 60fps,但可能与渲染不同步
分析:setInterval 时间固定,若浏览器渲染延迟,会导致动画跳帧。
解决:改用 requestAnimationFrame,与渲染同步:
let left = 0;
const box = document.getElementById('box');
function animate() {
left += 1;
box.style.left = left + 'px';
if (left < 100) {
requestAnimationFrame(animate); // 下一帧继续
}
}
animate();
案例 7:高频定时器的性能问题
问题:setInterval(..., 10) 高频执行导致主线程阻塞,页面卡顿。
代码:
// 高频更新 DOM,导致性能问题
let count = 0;
setInterval(() => {
document.getElementById('counter').innerText = count++;
}, 10);
分析:每 10ms 更新一次 DOM,超过浏览器渲染能力(60fps 约 16ms / 帧),导致重绘频繁。
解决:降低频率或用 requestAnimationFrame 合并更新:
let count = 0;
function update() {
document.getElementById('counter').innerText = count++;
requestAnimationFrame(update); // 与渲染同步,约 16ms 一次
}
update();
案例 8:定时器与异步操作的冲突
问题:定时器回调与接口请求同时执行,导致数据顺序错误。
代码:
// 假设先发起请求,再设置定时器,但请求可能后完成
fetch('/data')
.then(res => res.json())
.then(data => {
console.log('接口数据:', data);
});
setTimeout(() => {
console.log('定时器执行,假设依赖接口数据'); // 可能先执行,导致错误
}, 1000);
分析:定时器与异步请求无依赖关系,执行顺序不可控。
解决:用 Promise 或 async/await 控制顺序:
async function run() {
const res = await fetch('/data');
const data = await res.json();
console.log('接口数据:', data);
// 确保接口完成后再执行定时器逻辑
setTimeout(() => {
console.log('依赖接口数据的操作:', data);
}, 1000);
}
run();
案例 9:setTimeout(0) 的宏任务特性
问题:setTimeout(fn, 0) 并非立即执行,而是插入到任务队列尾部。
代码:
console.log('1');
setTimeout(() => {
console.log('3');
}, 0);
console.log('2'); // 输出:1 2 3
分析:setTimeout(fn, 0) 会将回调放入宏任务队列,等待同步代码执行完后执行。
解决:若需更高优先级,可使用 queueMicrotask(微任务,优先于宏任务):
console.log('1');
queueMicrotask(() => {
console.log('3'); // 微任务,在同步代码后、宏任务前执行
});
setTimeout(() => {
console.log('4');
}, 0);
console.log('2'); // 输出:1 2 3 4
案例 10:模拟精确的倒计时
问题:setInterval(..., 1000) 做倒计时会因延迟累积导致误差。
代码:
let seconds = 10;
const timer = setInterval(() => {
seconds--;
console.log(`剩余 ${seconds} 秒`);
if (seconds <= 0) clearInterval(timer);
}, 1000); // 实际间隔可能 >1000ms,导致总时间偏长
分析:每次回调执行有延迟,累积后总时间超过预期。
解决:基于初始时间计算实时剩余时间,而非依赖间隔:
const totalSeconds = 10;
const startTime = Date.now();
function updateCountdown() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const remaining = totalSeconds - elapsed;
if (remaining > 0) {
console.log(`剩余 ${remaining} 秒`);
setTimeout(updateCountdown, 100); // 高频检查,确保精度
} else {
console.log('倒计时结束');
}
}
updateCountdown();
四、总结
JavaScript 定时器是异步编程的基础,选择合适的 API 并规避常见问题(如内存泄漏、执行顺序、精度误差)是关键。记住:
- 单次延迟用 setTimeout,但需注意嵌套延迟限制;
- 周期性任务优先用递归 setTimeout 而非 setInterval;
- 动画场景必用 requestAnimationFrame;
- 始终在不需要时清除定时器,避免内存泄漏。
通过理解事件循环机制和实践中的细节处理,才能充分发挥定时器的作用。