手写一个 setTimeout
并在前端环境中考虑性能问题,这通常意味着模拟 setTimeout
的行为,而不是真正地重新实现浏览器底层的定时器机制 。因为 setTimeout
是浏览器原生提供的 API,它依赖于浏览器内部的事件循环和定时器队列,我们无法直接用 JavaScript 访问或控制这些底层机制。
然而,我们可以使用其他浏览器提供的异步 API(如 requestAnimationFrame
)来模拟 setTimeout
的非阻塞、延迟执行行为,并在此过程中关注性能。
模拟 setTimeout
的核心思路与性能考量
- 非阻塞性 (Non-blocking) :这是最重要的性能考量。一个好的
setTimeout
模拟必须不能阻塞主线程。这意味着我们不能使用while
循环进行忙等(busy-waiting),因为这会导致页面卡死。 - 精度 (Accuracy) :原生
setTimeout
的精度受限于浏览器事件循环和操作系统调度。我们的模拟也会有精度限制。例如,使用requestAnimationFrame
(rAF) 会将精度限制在浏览器的刷新率(通常为 60fps,即约 16.7ms)。 - 资源消耗 (Resource Consumption) :当没有活跃的计时器时,模拟的循环应该停止,以避免不必要的 CPU 消耗。当有计时器时,循环也应该尽可能高效。
- 内存管理 (Memory Management) :确保执行完毕的计时器能够被正确清理,避免内存泄漏。
- 多计时器管理 (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 执行。