前端模拟一个setTimeout

手写一个 setTimeout 并在前端环境中考虑性能问题,这通常意味着模拟 setTimeout 的行为,而不是真正地重新实现浏览器底层的定时器机制 。因为 setTimeout 是浏览器原生提供的 API,它依赖于浏览器内部的事件循环和定时器队列,我们无法直接用 JavaScript 访问或控制这些底层机制。

然而,我们可以使用其他浏览器提供的异步 API(如 requestAnimationFrame)来模拟 setTimeout 的非阻塞、延迟执行行为,并在此过程中关注性能。

模拟 setTimeout 的核心思路与性能考量

  1. 非阻塞性 (Non-blocking) :这是最重要的性能考量。一个好的 setTimeout 模拟必须不能阻塞主线程。这意味着我们不能使用 while 循环进行忙等(busy-waiting),因为这会导致页面卡死。
  2. 精度 (Accuracy) :原生 setTimeout 的精度受限于浏览器事件循环和操作系统调度。我们的模拟也会有精度限制。例如,使用 requestAnimationFrame (rAF) 会将精度限制在浏览器的刷新率(通常为 60fps,即约 16.7ms)。
  3. 资源消耗 (Resource Consumption) :当没有活跃的计时器时,模拟的循环应该停止,以避免不必要的 CPU 消耗。当有计时器时,循环也应该尽可能高效。
  4. 内存管理 (Memory Management) :确保执行完毕的计时器能够被正确清理,避免内存泄漏。
  5. 多计时器管理 (Multiple Timers) :一个真正的 setTimeout 可以同时管理多个独立的计时器。我们的模拟也应该支持这一点。

使用 requestAnimationFrame 模拟 setTimeout

requestAnimationFrame 是一个非常适合在浏览器中进行非阻塞、时间相关操作的 API,因为它与浏览器的渲染周期同步。

js 复制代码
// my-custom-timeout.js

// 存储所有活跃的计时器
const timerQueue = [];
// 为每个计时器生成唯一的 ID
let nextTimerId = 1;
// requestAnimationFrame 的 ID,用于控制动画循环的启停
let animationFrameId = null;

/**
 * 模拟原生的 setTimeout 函数。
 * 使用 requestAnimationFrame 来实现非阻塞的延迟执行。
 *
 * @param {Function} callback - 延迟执行的回调函数。
 * @param {number} delay - 延迟的毫秒数。
 * @returns {number} 计时器的唯一 ID,可用于 customClearTimeout。
 */
function customSetTimeout(callback, delay) {
  const id = nextTimerId++;
  // 使用 performance.now() 获取高精度时间戳,比 Date.now() 更适合测量时间间隔
  const startTime = performance.now();

  timerQueue.push({
    id,
    callback,
    delay,
    startTime,
  });

  // 如果动画循环未运行,则启动它
  if (animationFrameId === null) {
    startTimerLoop();
  }

  return id;
}

/**
 * 模拟原生的 clearTimeout 函数。
 * 用于取消由 customSetTimeout 创建的计时器。
 *
 * @param {number} id - 要取消的计时器的 ID。
 */
function customClearTimeout(id) {
  const index = timerQueue.findIndex(timer => timer.id === id);
  if (index !== -1) {
    timerQueue.splice(index, 1);
  }

  // 如果队列中没有更多计时器,则停止动画循环以节省资源
  if (timerQueue.length === 0 && animationFrameId !== null) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = null;
  }
}

/**
 * 启动并维护 requestAnimationFrame 循环,检查并执行到期的计时器。
 * 这是一个递归函数,每次浏览器重绘前都会被调用。
 */
function startTimerLoop() {
  const currentTime = performance.now();

  // 从后向前遍历队列,以便安全地移除已执行的计时器
  for (let i = timerQueue.length - 1; i >= 0; i--) {
    const timer = timerQueue[i];
    // 检查当前时间是否已超过计时器的计划执行时间
    if (currentTime >= timer.startTime + timer.delay) {
      // 执行回调函数,并用 try-catch 捕获可能发生的错误,防止影响其他计时器
      try {
        timer.callback();
      } catch (e) {
        console.error("Error in customSetTimeout callback:", e);
      }
      // 从队列中移除已执行的计时器
      timerQueue.splice(i, 1);
    }
  }

  // 如果队列中仍有计时器,则请求下一帧动画,继续循环
  if (timerQueue.length > 0) {
    animationFrameId = requestAnimationFrame(startTimerLoop);
  } else {
    // 如果队列为空,则停止循环,释放资源
    animationFrameId = null;
  }
}

// --- 性能考虑点总结 ---

// 1. 非阻塞性:
//    - 使用 `requestAnimationFrame` 替代 `while` 循环。`rAF` 是浏览器提供的异步 API,它会在浏览器下一次重绘之前执行回调,不会阻塞主线程,确保 UI 保持流畅和响应。
//    - 这是与原生 `setTimeout` 最重要的性能共性。

// 2. 精度与限制:
//    - `rAF` 的执行频率与显示器刷新率同步(通常 60Hz,约 16.7ms/帧)。这意味着即使你设置了 1ms 的延迟,回调也可能在下一个 16.7ms 的帧中才被检查并执行。因此,它不适合需要极高时间精度的场景。
//    - 浏览器在后台标签页可能会暂停或降低 `rAF` 的执行频率,这也会影响计时器的精度,但有助于节省资源。

// 3. 资源消耗:
//    - 动态启停循环:当 `timerQueue` 为空时,`cancelAnimationFrame` 会被调用,停止 `rAF` 循环,避免不必要的 CPU 消耗。
//    - 当有活跃计时器时,`rAF` 会持续运行,但这是其设计使然,且浏览器会对其进行优化。

// 4. 内存管理:
//    - 及时清理:每次计时器回调执行后,它会立即从 `timerQueue` 中移除,防止闭包或数据引用导致的内存泄漏。

// 5. 高精度时间戳:
//    - 使用 `performance.now()`:相比 `Date.now()`,`performance.now()` 提供更高分辨率(通常精确到微秒)的时间戳,并且不受系统时钟调整的影响,这使得它更适合用于测量时间间隔和计算延迟。

// 6. 错误处理:
//    - 回调函数包裹在 `try-catch` 中:防止单个计时器回调中的错误导致整个 `startTimerLoop` 崩溃,影响其他计时器的正常执行。

// --- 示例用法 ---

console.log("脚本开始执行,时间:", performance.now());

const timer1 = customSetTimeout(() => {
  console.log("回调 1 (200ms) 执行了!实际时间:", performance.now());
}, 200);

const timer2 = customSetTimeout(() => {
  console.log("回调 2 (500ms) 执行了!实际时间:", performance.now());
  // 尝试清除第一个计时器(如果它还没执行的话)
  customClearTimeout(timer1);
  console.log("尝试清除回调 1");
}, 500);

const timer3 = customSetTimeout(() => {
  console.log("回调 3 (100ms) 执行了!实际时间:", performance.now());
}, 100);

const timer4 = customSetTimeout(() => {
  console.log("回调 4 (1000ms) 执行了!实际时间:", performance.now());
}, 1000);

// 立即清除一个尚未执行的计时器
const timerToClear = customSetTimeout(() => {
  console.log("这个回调应该不会执行!");
}, 50);
customClearTimeout(timerToClear);
console.log("已清除一个 50ms 的计时器");

// 模拟一些主线程的持续活动,观察是否阻塞
let count = 0;
const intervalId = setInterval(() => {
  // console.log("模拟一些主线程活动...", count);
  if (count++ > 20) { // 运行一段时间后停止
    clearInterval(intervalId);
  }
}, 50);

console.log("主线程继续执行,不会被阻塞。");

// 预期输出顺序可能因为 rAF 的调度而略有不同,但大致的执行顺序和时间间隔会符合预期。
// 100ms 的回调 3 会最先执行 (在下一个 rAF 帧中检查到)。
// 200ms 的回调 1 可能会执行,也可能被 500ms 的回调 2 清除。
// 500ms 的回调 2 执行。
// 1000ms 的回调 4 执行。
相关推荐
小小小小宇2 小时前
TS泛型笔记
前端
小小小小宇2 小时前
前端canvas手动实现复杂动画示例
前端
codingandsleeping2 小时前
重读《你不知道的JavaScript》(上)- 作用域和闭包
前端·javascript
小小小小宇3 小时前
前端PerformanceObserver使用
前端
zhangxingchao4 小时前
Flutter中的页面跳转
前端
烛阴4 小时前
Puppeteer入门指南:掌控浏览器,开启自动化新时代
前端·javascript
全宝5 小时前
🖲️一行代码实现鼠标换肤
前端·css·html
萌萌哒草头将军6 小时前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js
芝士加6 小时前
Playwright vs MidScene:自动化工具“双雄”谁更适合你?
前端·javascript