Skip to content

定时器与间隔器:JavaScript 中的时间魔法师

时间控制的艺术

在 Web 开发中,我们经常需要控制代码的执行时机——也许是延迟几秒后显示一个提示,也许是每隔一段时间刷新数据,又或者是在用户停止输入后才发起搜索请求。JavaScript 提供了一套优雅的定时器 API,让我们能够精确地编排代码在时间轴上的表演。

这就像一个乐团指挥,手中的指挥棒不是用来演奏音乐,而是用来告诉每个乐器何时该发声。setTimeoutsetInterval 就是我们手中的指挥棒,让代码在正确的时刻开始执行。

setTimeout:延迟执行大师

setTimeout 是最基础的定时器,它允许你在指定的时间后执行一段代码。这个函数接收两个主要参数:要执行的函数和延迟的毫秒数。

基础用法

javascript
// 最简单的用法:3秒后执行函数
setTimeout(function () {
  console.log("3秒过去了!");
}, 3000);

// 使用箭头函数更简洁
setTimeout(() => {
  console.log("欢迎回来!");
}, 2000);

// 也可以传递一个命名函数
function greet() {
  console.log("Hello, World!");
}
setTimeout(greet, 1000);

当调用 setTimeout 时,JavaScript 会在指定时间后将你的函数放入任务队列。需要注意的是,如果主线程正在忙于其他事务,实际执行时间可能会稍有延迟。

传递参数

setTimeout 支持在回调函数中使用额外的参数:

javascript
// 直接传递参数给回调函数
function sendMessage(recipient, message) {
  console.log(`To ${recipient}: ${message}`);
}

setTimeout(sendMessage, 1000, "Sarah", "Meeting at 3pm");
// 1秒后输出: To Sarah: Meeting at 3pm

// 使用箭头函数包装(更常见的做法)
const user = { name: "Michael", email: "[email protected]" };

setTimeout(() => {
  console.log(`Sending email to ${user.name}`);
  // 发送邮件的逻辑...
}, 2000);

清除定时器

每个 setTimeout 调用都会返回一个唯一的定时器 ID,你可以用它来取消尚未执行的定时器:

javascript
// 创建一个可取消的定时器
const timerId = setTimeout(() => {
  console.log("这条消息可能不会出现");
}, 5000);

// 在3秒后取消定时器
setTimeout(() => {
  clearTimeout(timerId);
  console.log("定时器已取消");
}, 3000);

// 实际应用:可取消的操作
class CancellableOperation {
  constructor() {
    this.timerId = null;
  }

  scheduleTask(callback, delay) {
    // 如果已有待执行的任务,先取消
    if (this.timerId) {
      clearTimeout(this.timerId);
    }

    this.timerId = setTimeout(() => {
      callback();
      this.timerId = null;
    }, delay);
  }

  cancel() {
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
      console.log("操作已取消");
    }
  }
}

// 使用示例
const operation = new CancellableOperation();
operation.scheduleTask(() => console.log("任务执行"), 5000);
// 用户改变主意...
operation.cancel();

零延迟的真相

当你将延迟设置为 0 时,代码并不会立即执行:

javascript
console.log("1. 开始");

setTimeout(() => {
  console.log("3. setTimeout 回调");
}, 0);

console.log("2. 结束");

// 输出顺序:
// 1. 开始
// 2. 结束
// 3. setTimeout 回调

这是因为 setTimeout 的回调总是被放入任务队列,等待当前执行栈清空后才会执行。即使延迟是 0,它也要等到同步代码执行完毕。这个特性常被用来将代码推迟到下一个事件循环周期执行。

javascript
// 利用零延迟打破长任务
function processLargeArray(array) {
  const chunks = [];
  const chunkSize = 1000;

  for (let i = 0; i < array.length; i += chunkSize) {
    chunks.push(array.slice(i, i + chunkSize));
  }

  function processChunk(index) {
    if (index >= chunks.length) {
      console.log("处理完成!");
      return;
    }

    // 处理当前块
    chunks[index].forEach((item) => {
      // 处理每个元素
    });

    // 使用 setTimeout 让出主线程,避免阻塞 UI
    setTimeout(() => processChunk(index + 1), 0);
  }

  processChunk(0);
}

setInterval:循环执行专家

setInterval 用于以固定的时间间隔重复执行代码,非常适合需要周期性运行的任务。

基础用法

javascript
// 每秒更新一次时钟
function updateClock() {
  const now = new Date();
  console.log(now.toLocaleTimeString());
}

const clockInterval = setInterval(updateClock, 1000);

// 每5秒检查一次新消息
const messageChecker = setInterval(() => {
  console.log("检查新消息...");
  // 实际的消息检查逻辑
}, 5000);

停止间隔器

setTimeout 类似,setInterval 也返回一个 ID,用于停止循环:

javascript
// 倒计时示例
function createCountdown(seconds) {
  let remaining = seconds;

  const intervalId = setInterval(() => {
    console.log(`剩余时间: ${remaining}秒`);
    remaining--;

    if (remaining < 0) {
      clearInterval(intervalId);
      console.log("时间到!");
    }
  }, 1000);

  // 返回控制对象
  return {
    stop() {
      clearInterval(intervalId);
      console.log("倒计时已停止");
    },
    getRemaining() {
      return remaining;
    },
  };
}

// 使用示例
const countdown = createCountdown(10);

// 可以随时停止
setTimeout(() => {
  countdown.stop();
}, 5000);

setInterval 的陷阱

setInterval 有一个容易被忽视的问题:如果回调函数的执行时间超过了间隔时间,会导致回调堆积。

javascript
// 问题示例:回调执行时间可能超过间隔
setInterval(() => {
  // 假设这个操作需要 1.5 秒
  const startTime = Date.now();
  while (Date.now() - startTime < 1500) {
    // 模拟耗时操作
  }
  console.log("完成一次操作");
}, 1000);
// 回调会堆积,导致性能问题

// 更好的方案:使用递归的 setTimeout
function betterInterval(callback, delay) {
  let timerId;

  function execute() {
    callback();
    timerId = setTimeout(execute, delay);
  }

  timerId = setTimeout(execute, delay);

  return {
    stop() {
      clearTimeout(timerId);
    },
  };
}

// 使用递归 setTimeout,确保每次执行完成后才安排下一次
const task = betterInterval(() => {
  console.log("安全执行", new Date().toISOString());
  // 即使这里的操作耗时较长,也不会导致回调堆积
}, 1000);

requestAnimationFrame:动画的最佳拍档

对于动画而言,setTimeoutsetInterval 并不是最佳选择。requestAnimationFrame 专门为动画设计,它会在浏览器下一次重绘之前调用你的回调函数,通常是每秒 60 次(60fps)。

为什么选择 requestAnimationFrame

javascript
// 使用 setInterval 的动画(不推荐)
function animateWithInterval() {
  let position = 0;
  const element = document.querySelector(".box");

  setInterval(() => {
    position += 1;
    element.style.transform = `translateX(${position}px)`;
  }, 16); // 试图达到 60fps
}

// 使用 requestAnimationFrame 的动画(推荐)
function animateWithRAF() {
  let position = 0;
  const element = document.querySelector(".box");

  function animate() {
    position += 1;
    element.style.transform = `translateX(${position}px)`;

    if (position < 300) {
      requestAnimationFrame(animate);
    }
  }

  requestAnimationFrame(animate);
}

requestAnimationFrame 的优势:

  1. 与屏幕刷新同步:动画更流畅,无撕裂
  2. 自动暂停:页面不可见时自动停止,节省资源
  3. 电池友好:移动设备上更省电
  4. 性能优化:浏览器可以将多个动画合并处理

基于时间的动画

使用 requestAnimationFrame 时,应该基于实际经过的时间来计算动画进度,而不是假设固定的帧率:

javascript
function createAnimation(element, duration, distance) {
  let startTime = null;

  function animate(currentTime) {
    if (!startTime) {
      startTime = currentTime;
    }

    // 计算经过的时间
    const elapsed = currentTime - startTime;
    // 计算进度(0 到 1)
    const progress = Math.min(elapsed / duration, 1);

    // 应用缓动函数(这里使用简单的 ease-out)
    const easeOut = 1 - Math.pow(1 - progress, 3);

    // 计算当前位置
    const currentPosition = distance * easeOut;
    element.style.transform = `translateX(${currentPosition}px)`;

    // 如果动画未完成,继续
    if (progress < 1) {
      requestAnimationFrame(animate);
    }
  }

  requestAnimationFrame(animate);
}

// 使用示例
const box = document.querySelector(".animated-box");
createAnimation(box, 2000, 400); // 2秒内移动400px

取消动画

javascript
class Animation {
  constructor(element) {
    this.element = element;
    this.animationId = null;
    this.isRunning = false;
  }

  start() {
    if (this.isRunning) return;

    this.isRunning = true;
    let rotation = 0;

    const rotate = () => {
      rotation += 2;
      this.element.style.transform = `rotate(${rotation}deg)`;

      if (this.isRunning) {
        this.animationId = requestAnimationFrame(rotate);
      }
    };

    this.animationId = requestAnimationFrame(rotate);
  }

  stop() {
    this.isRunning = false;
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }
}

// 使用
const spinner = new Animation(document.querySelector(".spinner"));
spinner.start();

// 5秒后停止
setTimeout(() => spinner.stop(), 5000);

实用模式:防抖与节流

定时器最常用的应用场景之一就是控制高频事件的触发频率。

防抖(Debounce)

防抖的核心思想是:在事件停止触发一段时间后才执行回调。这就像电梯门——只有当没人进出一段时间后,门才会关闭。

javascript
function debounce(func, delay, immediate = false) {
  let timerId = null;

  return function (...args) {
    const context = this;

    // 是否立即执行
    const callNow = immediate && !timerId;

    // 清除之前的定时器
    clearTimeout(timerId);

    // 设置新的定时器
    timerId = setTimeout(() => {
      timerId = null;
      if (!immediate) {
        func.apply(context, args);
      }
    }, delay);

    // 立即执行
    if (callNow) {
      func.apply(context, args);
    }
  };
}

// 实际应用:搜索输入
const searchInput = document.querySelector("#search");
const searchResults = document.querySelector("#results");

const performSearch = debounce(async function (query) {
  console.log(`搜索: ${query}`);
  // 实际的搜索 API 调用
  // const results = await fetch(`/api/search?q=${query}`);
  searchResults.textContent = `显示 "${query}" 的结果`;
}, 300);

searchInput.addEventListener("input", (e) => {
  performSearch(e.target.value);
});

// 实际应用:窗口大小调整
const handleResize = debounce(() => {
  console.log("窗口大小已稳定");
  // 重新计算布局
}, 250);

window.addEventListener("resize", handleResize);

节流(Throttle)

节流的核心思想是:确保函数在指定时间内最多执行一次。这就像水龙头的流量控制——不管你怎么拧,水流速度都有个上限。

javascript
function throttle(func, limit) {
  let inThrottle = false;
  let lastArgs = null;
  let lastThis = null;

  return function (...args) {
    if (inThrottle) {
      // 保存最后一次调用的参数
      lastArgs = args;
      lastThis = this;
      return;
    }

    func.apply(this, args);
    inThrottle = true;

    setTimeout(() => {
      inThrottle = false;
      // 如果在节流期间有调用,执行最后一次
      if (lastArgs) {
        func.apply(lastThis, lastArgs);
        lastArgs = null;
        lastThis = null;
      }
    }, limit);
  };
}

// 实际应用:滚动事件
const handleScroll = throttle(() => {
  const scrollPercent =
    (window.scrollY /
      (document.documentElement.scrollHeight - window.innerHeight)) *
    100;
  console.log(`滚动进度: ${scrollPercent.toFixed(1)}%`);
}, 100);

window.addEventListener("scroll", handleScroll);

// 实际应用:按钮点击防止重复提交
const submitButton = document.querySelector("#submit");

const handleSubmit = throttle(async () => {
  console.log("提交表单...");
  submitButton.textContent = "提交中...";
  submitButton.disabled = true;

  try {
    // await submitForm();
    console.log("提交成功");
  } finally {
    submitButton.textContent = "提交";
    submitButton.disabled = false;
  }
}, 2000);

submitButton.addEventListener("click", handleSubmit);

防抖 vs 节流:何时使用

场景推荐方案理由
搜索输入防抖用户停止输入后才搜索
窗口 resize防抖等待调整完成后再计算
按钮提交节流防止重复点击
滚动加载节流定期检查位置
鼠标移动节流限制事件触发频率
表单验证防抖用户停止输入后验证

定时器的精度问题

JavaScript 定时器并不保证精确执行,尤其在高负载或后台标签页中:

javascript
// 演示定时器精度
function testTimerAccuracy() {
  let count = 0;
  const expectedInterval = 100;
  let lastTime = Date.now();
  const drifts = [];

  const intervalId = setInterval(() => {
    const now = Date.now();
    const actualInterval = now - lastTime;
    const drift = actualInterval - expectedInterval;
    drifts.push(drift);
    lastTime = now;
    count++;

    if (count >= 20) {
      clearInterval(intervalId);

      const avgDrift = drifts.reduce((a, b) => a + b, 0) / drifts.length;
      console.log(`平均偏差: ${avgDrift.toFixed(2)}ms`);
      console.log(`最大偏差: ${Math.max(...drifts)}ms`);
      console.log(`最小偏差: ${Math.min(...drifts)}ms`);
    }
  }, expectedInterval);
}

testTimerAccuracy();

自校准定时器

对于需要精确计时的场景,可以实现自校准定时器:

javascript
function createPreciseInterval(callback, interval) {
  let expected = Date.now() + interval;
  let timeoutId;

  function step() {
    const drift = Date.now() - expected;
    callback();

    expected += interval;
    // 下次执行时间减去漂移量,进行校准
    const nextDelay = Math.max(0, interval - drift);
    timeoutId = setTimeout(step, nextDelay);
  }

  timeoutId = setTimeout(step, interval);

  return {
    stop() {
      clearTimeout(timeoutId);
    },
  };
}

// 使用精确定时器
const preciseTimer = createPreciseInterval(() => {
  console.log("精确执行:", Date.now());
}, 1000);

// 10秒后停止
setTimeout(() => preciseTimer.stop(), 10000);

实际应用场景

轮询机制

javascript
class Poller {
  constructor(options = {}) {
    this.url = options.url;
    this.interval = options.interval || 5000;
    this.onData = options.onData || (() => {});
    this.onError = options.onError || console.error;
    this.maxRetries = options.maxRetries || 3;

    this.timerId = null;
    this.retryCount = 0;
    this.isRunning = false;
  }

  async poll() {
    try {
      const response = await fetch(this.url);
      const data = await response.json();

      this.retryCount = 0; // 重置重试计数
      this.onData(data);
    } catch (error) {
      this.retryCount++;

      if (this.retryCount >= this.maxRetries) {
        this.onError(new Error("达到最大重试次数"));
        this.stop();
        return;
      }

      this.onError(error);
    }

    if (this.isRunning) {
      this.timerId = setTimeout(() => this.poll(), this.interval);
    }
  }

  start() {
    if (this.isRunning) return;

    this.isRunning = true;
    this.poll();
    console.log("轮询已启动");
  }

  stop() {
    this.isRunning = false;
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
    }
    console.log("轮询已停止");
  }
}

// 使用示例
const notificationPoller = new Poller({
  url: "/api/notifications",
  interval: 10000,
  onData: (data) => {
    console.log("收到新通知:", data);
    // 更新 UI
  },
  onError: (error) => {
    console.warn("轮询出错:", error.message);
  },
});

// 页面加载时启动
notificationPoller.start();

// 页面隐藏时暂停
document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    notificationPoller.stop();
  } else {
    notificationPoller.start();
  }
});

打字机效果

javascript
function typeWriter(element, text, speed = 50) {
  return new Promise((resolve) => {
    let index = 0;
    element.textContent = "";

    function type() {
      if (index < text.length) {
        element.textContent += text.charAt(index);
        index++;
        setTimeout(type, speed);
      } else {
        resolve();
      }
    }

    type();
  });
}

// 使用示例
async function showMessages() {
  const display = document.querySelector("#typewriter");

  await typeWriter(display, "Welcome to our website!", 80);
  await new Promise((resolve) => setTimeout(resolve, 1000));
  await typeWriter(display, "How can we help you today?", 60);
}

showMessages();

自动保存

javascript
class AutoSave {
  constructor(options = {}) {
    this.saveFunction = options.save;
    this.delay = options.delay || 3000;
    this.timerId = null;
    this.lastSavedData = null;
  }

  trigger(data) {
    // 如果数据没变化,不需要保存
    if (JSON.stringify(data) === JSON.stringify(this.lastSavedData)) {
      return;
    }

    // 清除之前的定时器
    if (this.timerId) {
      clearTimeout(this.timerId);
    }

    // 设置新的定时器
    this.timerId = setTimeout(async () => {
      try {
        await this.saveFunction(data);
        this.lastSavedData = JSON.parse(JSON.stringify(data));
        console.log("自动保存成功");
      } catch (error) {
        console.error("自动保存失败:", error);
      }
    }, this.delay);
  }

  saveNow(data) {
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
    }

    return this.saveFunction(data);
  }
}

// 使用示例
const autoSave = new AutoSave({
  delay: 2000,
  save: async (data) => {
    // 保存到服务器
    // await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
    console.log("保存数据:", data);
  },
});

// 文档编辑器场景
const editor = document.querySelector("#editor");
editor.addEventListener("input", () => {
  autoSave.trigger({ content: editor.value });
});

// 页面关闭前立即保存
window.addEventListener("beforeunload", () => {
  autoSave.saveNow({ content: editor.value });
});

常见问题与最佳实践

this 绑定问题

javascript
const timer = {
  count: 0,

  start() {
    // 问题:this 丢失
    setInterval(function () {
      this.count++; // this 是 window,不是 timer
      console.log(this.count); // NaN
    }, 1000);
  },

  startCorrect1() {
    // 方案1:使用箭头函数
    setInterval(() => {
      this.count++;
      console.log(this.count);
    }, 1000);
  },

  startCorrect2() {
    // 方案2:保存 this 引用
    const self = this;
    setInterval(function () {
      self.count++;
      console.log(self.count);
    }, 1000);
  },

  startCorrect3() {
    // 方案3:使用 bind
    setInterval(
      function () {
        this.count++;
        console.log(this.count);
      }.bind(this),
      1000
    );
  },
};

内存泄漏防范

javascript
// 组件销毁时清理定时器
class Component {
  constructor() {
    this.timers = [];
  }

  setTimeout(callback, delay) {
    const id = setTimeout(callback, delay);
    this.timers.push({ type: "timeout", id });
    return id;
  }

  setInterval(callback, delay) {
    const id = setInterval(callback, delay);
    this.timers.push({ type: "interval", id });
    return id;
  }

  destroy() {
    this.timers.forEach((timer) => {
      if (timer.type === "timeout") {
        clearTimeout(timer.id);
      } else {
        clearInterval(timer.id);
      }
    });
    this.timers = [];
    console.log("所有定时器已清理");
  }
}

// 使用示例
const component = new Component();
component.setInterval(() => console.log("运行中..."), 1000);
component.setTimeout(() => console.log("延迟执行"), 5000);

// 组件销毁时
// component.destroy();

最小延迟限制

浏览器对定时器有最小延迟限制,嵌套的定时器(超过 5 层)最小延迟为 4ms:

javascript
let start = Date.now();
let delays = [];

function nested(n) {
  if (n <= 0) {
    console.log("延迟记录:", delays);
    return;
  }

  const before = Date.now();
  setTimeout(() => {
    const delay = Date.now() - before;
    delays.push(delay);
    nested(n - 1);
  }, 0);
}

nested(10);
// 前几次延迟较小,后面会增加到 4ms 以上

总结

定时器是 JavaScript 中控制代码执行时机的核心工具。setTimeout 适合延迟执行,setInterval 适合周期任务,而 requestAnimationFrame 则是动画的首选。掌握防抖和节流模式,可以优雅地处理高频事件。

关键要点:

  • 定时器回调总是异步执行,即使延迟为 0
  • 使用 requestAnimationFrame 替代定时器做动画
  • 防抖适合等待用户停止操作,节流适合限制执行频率
  • 注意清理定时器以避免内存泄漏
  • 定时器精度不是绝对的,关键场景需要自校准

时间是代码执行的第四维度,善用定时器,让你的代码在正确的时刻绽放光彩。