🔥JavaScript 定时器详解及实践指南

71 阅读3分钟

JavaScript 定时器是异步编程的核心工具之一,用于延迟执行代码或周期性执行任务。常见的定时器 API 包括 setTimeout、setInterval 和 requestAnimationFrame,它们在浏览器的事件循环机制中工作,行为各有特点。本文将详解其原理、优缺点,并通过 10 个案例说明常见问题及解决方法。

一、定时器核心 API 原理

  1. setTimeout(fn, delay)
    • 延迟 delay 毫秒后执行一次 fn(单位:毫秒,默认 0)。
    • 特点:回调函数进入 "任务队列",需等待主线程空闲后执行,实际延迟可能大于 delay。
  1. setInterval(fn, interval)
    • 每隔 interval 毫秒重复执行 fn。
    • 特点:若前一次回调未执行完,后一次会 "堆积",导致执行间隔不稳定。
  1. 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;
  • 始终在不需要时清除定时器,避免内存泄漏。

通过理解事件循环机制和实践中的细节处理,才能充分发挥定时器的作用。