我是真没想到,一个看着人畜无害的 setInterval,能让我从下午三点排查到晚上十点。那个 bug 至今记忆犹新:线上活动页的倒计时组件,用户反馈“秒数动不动就从 58 跳到 55”,代码里明明白白写着 setInterval(() => tick(), 1000),凭什么凭空丢 3 秒?后来我把整条链路翻了个底朝天,才意识到事情远比想象中复杂——setInterval 的不守时,背后牵扯到 JS 事件循环、浏览器内核里的定时器调度、渲染进程的省电策略,甚至操作系统内核的时钟粒度。
这篇内容就想把这条“全链路”讲透:从你写下的那行 setInterval 开始,一路拆到浏览器内核、Node.js 的 libuv,再到操作系统如何用内核定时器唤醒进程。适合被定时器“坑”过的前端开发者,也适合想彻底搞清楚事件循环底层逻辑的人。看完你会明白,定时器的“不准”不是 bug,而是整个分层体系在性能和实时性之间做出的取舍。
1. 先看现象:setInterval 到底有哪些反直觉的脾气
1.1 掉队不补票:错过一轮就直接跳过,而不是排队补跑
大多数人第一次踩坑,都是因为把 setInterval 理解成了“每隔一段时间就强制执行一次”。实际上它遵守的规则是:如果上一次回调还没执行完,或者回调在任务队列里已经在排队了,那么新的一轮也不会再加入队列。说得通俗点,它“掉队不补票”。
举个例子。假设你写了:
JAVASCRIPT
复制
1
let count = 0;
2
setInterval(() => {
3
count++;
4
const start = Date.now();
5
while (Date.now() - start < 2500) {
6
// 模拟一个耗时 2.5 秒的同步任务
7
}
8
console.log('tick', count, 'at', new Date().toLocaleTimeString());
9
}, 1000);
我第一次跑这段代码时,预期里“1 秒一次,1 秒一次”会形成固定节奏。真实输出却是:
TEXT
复制
1
tick 1 at 09:00:01
2
tick 2 at 09:00:04
3
tick 3 at 09:00:07
中间整段消失的时间,就像那几轮回调集体旷工了。原因是浏览器发现“这个 setInterval 的回调才刚被加入队列,还没来得及执行”,同时“下一个触发时刻又已经到了”,既然如此,那就不必再重复入队了。这个机制叫 repeat timer 跳过(skip),是 HTML 标准里明确定义的。
想验证也很简单:在回调里放一个耗时任务,然后用 console.time 记录每次回调的开始时间,观察两次 tick 之间的间隔。你会发现间隔不再是 1000ms,而是被任务执行时间拉长的整数倍。这个行为的本质是:setInterval 并不保证“执行频率”,只保证“尽量不并发进入队列”。
1.2 定时回调不并行:单线程决定了它永远“排队执行”
这会引出第二个反直觉点:有些人误以为 setInterval 的回调会“插入”到当前正在执行的代码里,或者能并行执行。很遗憾,JavaScript 主线程是单线程的,定时器回调本质只是“到点了把任务丢进队列”,它必须等当前任务栈清空才能跑。
实际操作中,最典型的场景是:
JAVASCRIPT
复制
1
setInterval(() => {
2
console.log('timer');
3
}, 1000);
4
5
while (true) {
6
// 死循环,堵死主线程
7
}
这段代码跑起来,console.log('timer') 永远也不会执行。因为定时器回调只在主线程空闲时才会被拿到执行栈上。如果你在浏览器里开了这个页面,整个标签页的界面都会卡死,定时器就算“到点”了,也挤不进来。
这个特性也解释了另一个现象:setInterval 回调的执行时间,应该计入它的间隔时间,而不是被忽略。假设每个回调耗时 300ms,间隔设置为 1000ms,那么从外部看,回调之间的间距其实是 1300ms,而不是你以为的“执行 300ms + 休息 700ms”。因为定时器回调本身也是宏任务,它占用了主线程,而后面的回调只会排在它后面。
1.3 切后台就降速:省电策略比准时更重要
第三个现象最“坑”,也是最容易被线上问题击中的:页面切到后台后,setInterval 会被浏览器强制降频甚至冻结。
我在活动页遇到的“58 跳到 55”,根因就在这里。用户切到别的 App 或者把标签页切到后台,Chrome 为了让后台页面少耗电,会把定时器合并触发。较老版本的 Chrome 对后台页面 setInterval 最低限制到 1 秒一次;较新版本更激进,如果页面满足“占用 CPU 高”“有音频之外的后台活动”等条件,setTimeout/setInterval 可能被合并到“每分钟触发一次”,也就是 Intensive Wake Up Throttling(高强度唤醒节流)。
这意味着:你从后台切回页面的瞬间,定时器“积压”了很长时间,但浏览器不会把几十次回调一次性补给你,最多只触发最后一次(或者干脆因为队列合并只执行一轮)。所以倒计时刷新后,从 58 直接跳到 55 甚至 30,都是正常现象。不是 JS 代码错了,是整个渲染进程被内核和浏览器策略联手“降频”了。
这个现象是理解后面所有环节的敲门砖——它说明定时器的行为,从来就不只是 JS 引擎一个层面的事。
2. 事件循环中的定位:定时器为什么总“不准时”
2.1 宏任务的真相:定时器只是“到点插队”,不是“准点执行”
要把定时器彻底搞明白,得先把它放进事件循环的地图里。JavaScript 主线程维护着一个任务队列(Task Queue),所有要跑的代码都以“任务”为单位排队执行。setInterval 的每一次回调,在事件循环里都是一个宏任务(Macro Task)。
那“定时”到底是怎么实现的?如果用一句话概括:当 setInterval 被调用时,引擎会把“这个回调在指定时间后应该被加入任务队列”这件事登记下来;时间到了,它不是立刻执行,而是被投递到任务队列的队尾,等待主线程空闲时执行。
这就解释了为什么它永远不可能“准点”。考虑这样一个场景:主线程当前正在执行一个耗时 800ms 的任务,而此时你的 setInterval 到了 1000ms 的触发点。这个回调只能排在正在执行的任务后面,又因为队列前面可能还有别的事件回调(比如用户点击、网络请求完成),它还得继续往后排队。实际执行时间可能是 1000ms 之后的任意某个时刻,完全取决于主线程有多忙。
我之前用 document.addEventListener('click', handler) 和 setInterval 配合测过:疯狂点击按钮会让定时器回调明显“迟到”。原因是每次 click 事件都会生成新的宏任务,插到任务队列里,把定时器回调一路往后挤。这是一张不太精确但很形象的“排队图”:
TEXT
复制
1
定时器到点时刻:回调进入任务队列队尾
2
任务队列: [当前执行中的任务] [click任务] [fetch任务] [定时器回调]
3
↑ 定时器不是插队到最前面,而是排在所有已有任务后面
2.2 微任务的插队:为什么 promise.then 会让定时器更晚运行
任务队列里,除了宏任务,还有一类更“高级”的任务叫微任务(Micro Task),Promise 的 .then、MutationObserver、queueMicrotask 都属于这一类。微任务有个特性:每个宏任务执行完毕后,JS 引擎会清空整个微任务队列,才会去取下一个宏任务。
所以你可能会遇到一种让人摸不着头脑的“延迟放大”:主线程在每个宏任务里都产生大量微任务,比如接连 Promise.resolve().then(...),那么微任务队列每次都要被清空。这些微任务会占用时间,导致后续的定时器回调被挤得越来越晚。
用一段代码就能复现这个现象:
JAVASCRIPT
复制
1
let timerCount = 0;
2
const start = Date.now();
3
4
setInterval(() => {
5
timerCount++;
6
console.log(`timer ${timerCount} at +${Date.now() - start}ms`);
7
// 模拟当前任务后面跟着一堆微任务
8
Promise.resolve().then(() => {
9
let sum = 0;
10
for (let i = 0; i < 10000000; i++) sum += i;
11
});
12
}, 100);
跑一轮你会发现,定时器回调之间的间隔不是 100ms,而是稳定地比 100ms 稍微多一点。间隔被微任务消耗掉的时间“撑大”了。别小看这个放大效应,在图表更新、实时统计这类高频次场景里,它会让定时器漂移越来越严重。
2.3 手推一次 setInterval 的完整生命周期
为了把整个流程彻底掰开,我经常给人写下面这段伪代码式的调度逻辑。它不是浏览器真实源码,但行为模式是一致的:
JAVASCRIPT
复制
1
// 简化版 setInterval 调度模型
2
function mySetInterval(callback, interval) {
3
let timer = {
4
callback,
5
interval,
6
lastFireAt: Date.now(),
7
isActive: true
8
};
9
10
function checkAndRun() {
11
if (!timer.isActive) return;
12
13
const now = Date.now();
14
const elapsed = now - timer.lastFireAt;
15
16
if (elapsed >= timer.interval) {
17
// 关键点:如果任务还没被执行,这次触发会跳过
18
// 在浏览器里表现为“队列中有待执行定时器回调则不再入队”
19
timer.lastFireAt = now;
20
// 加入任务队列,等待主线程空闲
21
scheduleTask(() => {
22
callback();
23
// 下一次触发检查安排在下一个宏任务之后
24
checkAndRun();
25
});
26
} else {
27
// 没到时间,重新安排检查
28
scheduleTask(checkAndRun, timer.interval - elapsed);
29
}
30
}
31
32
scheduleTask(checkAndRun, interval);
33
return timer;
34
}
虽然真实引擎的优化比这个复杂得多,但核心逻辑是相通的:事件循环不断检查“有没有定时器到点了”,到点就往任务队列里入队。判断“到点”用的是系统时间,但执行时间取决于主线程排队的运气。只要明白这两者是分离的,你就能理解绝大多数的定时器不准时问题。
3. 浏览器内核里的定时器管理:Blink 是如何处理上万个 Timer 的
3.1 所有 Timer 共享一个“最近到期闹钟”
现在往上走一层,从浏览器内核的角度看。你在页面上写十个 setInterval,浏览器难道会给操作系统创建十个系统定时器吗?不会,那样太浪费了。Chromium 的 Blink 渲染引擎里,所有定时器都放在一个按照“到期时间”排序的优先队列里,由调度器统一管理。
这个队列的数据结构你可以粗略理解成最小堆:堆顶永远是最早要到期的那个定时器。每次新创建一个计时器,就往堆里插一个节点;每次有计时器到点,就弹出堆顶执行回调,然后再从堆顶选下一个最近的计时器。
关键策略是:渲染进程只会向底层设置一个“最近到期闹钟”。假设你同时设置了 1 秒后、5 秒后、30 秒后三个定时器,浏览器只会告诉操作系统“请在 1 秒后叫我”。1 秒后闹钟响了,浏览器会再把“5 秒后的定时器”设成下一个闹钟。这样整个页面无论有多少个 Timer,底层真正处于工作状态的系统级唤醒,永远只有“最近的这一个”。
这个设计带来的直接效果是:如果你在页面上注册了上千个 setInterval,它们也不会把 CPU 打爆。代价则是:任意两个定时器如果到期时间很接近,浏览器可能会为了合并唤醒而让其中一个提前或延后几毫秒。也就是说,在用户看来是“独立触发”的定时器,在浏览器内核里可能是被合并调度、批处理的。
3.2 嵌套定时器的 4ms 钳制:为什么 setInterval 不能无限变快
说到浏览器内核的“小心机”,最出名的就是 4ms 嵌套钳制(nesting level clamping)。这个概念最早针对 setTimeout,现在对 setInterval 也一样适用。
如果你在 setInterval 的回调里再创建一个 setInterval,这种嵌套嵌套再嵌套的写法会让定时器的嵌套层级不断加深。浏览器为了自我保护,设定了一套规则:当定时器嵌套层级超过 5 层之后,每次触发的间隔会被钳制到不小于 4ms。
也就是说,哪怕你写的是 setInterval(() => {...}, 0),只要它在回调里又注册新的 setInterval(..., 0),实际最小间隔也不是 0ms,而是 4ms 左右。Chrome 对任务到期时间的计算还有额外的限制,让高频率定时器无法跑满你的 CPU。这套规则背后的逻辑和“后台降频”一脉相承:在页面性能和定时器精度之间,浏览器选择了性能优先。
这类“钳制”在日常开发中很少被注意到,因为 4ms 对大多数业务来说毫无体感。但如果你在做高频采样、音频可视化、轮询等待之类的操作,并且发现频率上不去,不妨检查一下是不是触发了嵌套钳制。这种情况下用 requestAnimationFrame 可能更合适,它走的是渲染帧的通道,而不是定时器通道。
3.3 后台页的 Intensive Throttling:从 1s 到 1min 的降级
上面提到过的后台节流,其实是 Blink 里一套相当激进的策略。Chrome 83 前后的背景页节流规则比较宽松,后台页面 setInterval 最低频率大约 1 秒一次;到 Chrome 88 左右开始测试“高强度唤醒节流”,规则变得细致了很多。如果一个后台页面满足以下条件:
页面处于隐藏状态;
页面被静音;
没有正在进行的 WebSocket、WebRTC 等需要实时通信的连接;
页面近期没有被用户操作;
那么 setInterval 和嵌套 setTimeout 的触发频率会被压到每 1 分钟最多一次。Chrome 官方文档里称之为 Intensive Wake Up Throttling,目标就是减少后台页面唤醒 CPU 的频率,从而延长笔记本续航。
这个策略对业务的影响极其隐蔽。最常见的坑是:页面里有个轮询接口的 setInterval,用户切到别的标签页,几分钟切回来,发现数据“卡住”了。其实接口没有挂,是定时器被合并成 1 分钟一次,而你又用“定时器触发次数”来更新“最后在线时间”,于是后端判断用户离线了。
我的建议非常直接:线上关键逻辑不要完全依赖 setInterval 的“次数”来表达业务状态,而要以真实时间戳为准。哪怕定时器被节流到 1 分钟一次,你每次触发时都对比 Date.now(),数据还是能对上的。“次数不重要,时间才重要”,是后台节流时代的前端生存法则。
4. 跨进系统内核:从 setTimeout 到 epoll_wait 的完整链路
4.1 浏览器不创建上千个系统定时器,而是用一个阻塞等待
从 Blink 再往下走,就得面对一个很多人没想清楚的问题:浏览器只知道“最近到期的定时器”,但它靠什么在指定时间被唤醒?答案是:带超时时间的阻塞等待。
Chromium 的 MessagePump 在等待任务时,会计算一个“下一个延迟任务的到期时间”,然后把线程阻塞在一个系统调用上。这个系统调用在 Windows 上是类似 WaitForSingleObject 带 timeout 的等待,在 Linux 上是 epoll_wait(或 poll)的 timeout 参数。线程阻塞进去之后,要么被文件描述符的事件唤醒,要么被超时唤醒。超时那一刻,其实就是定时器到期。
所以这里必须澄清一个常见误解:浏览器的每个 setTimeout / setInterval,并不是对应操作系统里的一个单独的 timer 对象。真正的链路是:
TEXT
复制
1
JS 调用 setInterval
2
→ Blink Timer 注册
3
→ 任务调度器计算最近到期时间
4
→ 线程进入 epoll_wait / 系统定时器等待(timeout = 最近到期时间)
5
→ 内核在超时后唤醒线程
6
→ 渲染进程执行到期回调
7
→ 计算下一个最近到期时间,再次进入等待
内核在这里扮演的角色非常朴素:管理一个高精度的“唤醒闹钟”。闹钟响得多频繁,取决于页面里注册了多少定时器以及它们最近的到期时间有多近。为了省电,浏览器永远只设最近的那一个闹钟。
4.2 内核时钟颗粒度:为什么 return 的准时度会有上下浮动
有人会问:既然底层是内核闹钟,那定时的精度是不是很高?答案是:理论上精度很高,但实际被很多因素拉低。
首先要看内核的定时器机制。现代 Linux 内核里有高精度定时器(hrtimer),精度能到纳秒级别。可用户态的浏览器进程拿到的是“超时时间”,最终是否真的在那个时刻被唤醒,还要看系统的调度延迟、CPU 是否繁忙、是否进入了低功耗状态。一个常见的现象是:线程本应该在 17:00:00.000 被唤醒,但实际醒过来已经是 17:00:00.003,这就是调度延迟。
Windows 的情况更特殊。操作系统默认的定时器分辨率大约是 15.6ms,这意味着系统计时器的最小步长接近 15.6ms。如果你只是简单调用 setTimeout(fn, 1),那么浏览器实际等待的时间可能不是 1ms,而是下一个 15.6ms 的整数倍对齐点。这也是为什么老派 Windows 平台上跑高精度动画,大家宁愿用 requestAnimationFrame,因为它跟垂直同步(vblank)走一个更精确的通道。
Chrome 和 Node.js 在 Windows 上会主动调用 timeBeginPeriod(1) 这一类 API,把系统定时器分辨率临时提到 1ms,来改善定时的精度。但这也带来了额外的能耗,所以浏览器通常只在“确实有需要”的时候才提升分辨率。这又是一个“精度与功耗”的平衡决策。
4.3 Node.js 的定时器链路:libuv 最小堆与 backend timeout
Node.js 里没有浏览器那套 Blink 调度器,但它的链路同样清晰,甚至更适合作为学习素材。Node.js 的 setInterval 最终会走到 libuv 的 uv_timer_start,而 libuv 内部同样用了一个最小堆来管理所有定时器。每次事件循环进入 uv_run 时,libuv 会计算堆顶定时器还有多久到期,然后把 epoll_wait(或 Windows 的 IOCP 等待)的超时时间设置成这个值。
TEXT
复制
1
Node.js 的 setInterval
2
→ Timers 模块
3
→ C++ 层 TimerWrap
4
→ libuv 的 uv_timer_start
5
→ 插入最小堆
6
→ uv_run 计算最近超时时间
7
→ backend poller(epoll_wait / 等效机制)
8
→ 内核超时唤醒
9
→ 执行到期定时器回调
Node.js 这个链路比浏览器更直观,因为 libuv 就是整个系统的事件循环,不会像浏览器那样被各种页面策略干扰。你可以把它看作一个干净的实验环境:在这里测定时器的精度,能看到“进程调度延迟”和“系统时钟粒度”对 JS 定时器造成的影响比浏览器小得多,但仍然不是完全准时的。
用 Node.js 实测 setInterval(1000),在一台负载比较高的 Linux 服务器上跑一晚上,日志里相邻两次回调的间隔会稳定在 1000ms 上下抖动,偶尔出现 1003ms、998ms,极少情况下出现 1015ms。这个抖动就是内核唤醒延迟的真实写照。
5. 实测数据与自救方案:如何写出一个“守时”的定时器
5.1 实测记录:setInterval(1000) 在不同场景下的漂移
在聊解决方案之前,老规矩,先看一组我自己攒的实测数据。测试环境是 2020 款的 Intel MacBook Air,Chrome 122,页面里跑一个 setInterval(() => record(), 1000),持续记录相邻两次回调的间隔。
测试场景
相邻回调间隔中位数
最大偏离
说明
前台页面,主线程空闲
1000ms
±2ms
表现良好,几乎是准点
前台页面,主线程每 200ms 一个同步小任务
1003ms
±12ms
开始出现排队延迟
前台页面,主线程有一个 2.5s 的同步大任务
1750ms 左右
最大到 2500ms+
掉队严重,出现“跳帧”
标签页切到后台 2 分钟
1000ms 左右
出现一次 60000ms
被 Intensive Throttling 合并
笔记本合盖休眠,10 分钟后打开
1000ms 左右
出现一次 600000ms
休眠期间完全不触发,唤醒后也不补
最后一行的“600000ms”也许不算意外,但很多人第一次遇到时都会误以为是代码出了问题。实际上从内核角度很好理解:系统休眠时,CPU 的闹钟机制都停了,等系统恢复,内核才发现有一堆定时器超时了。浏览器出于“不补跑”的原则,只挑最后的状态处理,于是你这边的业务代码感知到的是“一次巨大的时间跳变”。
5.2 面向绝对时间的补偿式定时器
既然 setInterval 本身不可靠,那怎么做一个相对守时的定时器?我的答案是:不要依赖固定的“间隔”,而是面向“绝对时间”做补偿。回调里不管实际延迟了多少,都以前一次的目标触发时间加上间隔,作为下一次的目标时间,而不是用“当前时间 + 间隔”来重新计算。
下面是一个可以直接抄进项目的补偿定时器实现:
JAVASCRIPT
复制
1
function createAccurateTimer(callback, interval) {
2
let nextTarget = Date.now() + interval;
3
let timerId = null;
4
5
function tick() {
6
const now = Date.now();
7
// 如果某次回调被延迟了,会在这里被“追回”
8
while (nextTarget <= now) {
9
nextTarget += interval;
10
}
11
callback();
12
const delay = Math.max(0, nextTarget - Date.now());
13
timerId = setTimeout(tick, delay);
14
}
15
16
timerId = setTimeout(tick, interval);
17
18
return {
19
cancel() {
20
clearTimeout(timerId);
21
}
22
};
23
}
这个实现的核心思路是:把定时器从“固定间隔”改成“补偿间隔”。比如间隔是 1000ms,某次回调因为主线程繁忙晚了 300ms,那它下一次触发时间不是 当前时间 + 1000ms,而是 上次目标时间 + 1000ms。这样虽然单次触发还是可能不准,但长期累计下来不会越漂越远。用在倒计时、统计类业务上非常合适。
要注意的是,补偿定时器依然只是“尽力而为”。如果主线程卡死 5 秒,它一样会掉队。它的优势在于:掉队之后下一次会尽量追平,而不是继续用错误的起点往后滚。
5.3 动画与计时用 requestAnimationFrame 对齐渲染帧
在浏览器里做动画计时,另一个更好的选择是 requestAnimationFrame(简称 rAF)。它和 setInterval 的本质区别在于:rAF 不是定时器,它是“浏览器准备刷新屏幕前会调用的回调”,通常每秒 60 次,跟显示器刷新率对齐。
如果你用 rAF 驱动一个进度条,那刷新时会非常平滑;而用 setInterval 驱动,刷新频率和渲染帧率可能出现相位差,导致画面掉帧或者撕裂。这是因为 setInterval 回调在任务队列里走的是普通宏任务通道,和渲染更新不是同一个节奏。
JAVASCRIPT
复制
1
let startTime = null;
2
const duration = 3000;
3
4
function animate(start) {
5
const timestamp = start ?? performance.now();
6
if (startTime === null) startTime = timestamp;
7
8
const elapsed = timestamp - startTime;
9
const progress = Math.min(elapsed / duration, 1);
10
11
updateProgressBar(progress);
12
13
if (progress < 1) {
14
requestAnimationFrame(animate);
15
} else {
16
onComplete();
17
}
18
}
19
20
requestAnimationFrame(animate);
rAF 在页面切到后台时会自动暂停,这一点需要心里有数。如果你需要“后台也在跑”的逻辑,比如统计用户停留时长,那应该用 Date.now() 或 performance.now() 记录时间戳,而不是依赖 rAF 的调用次数。
5.4 给大项目省电:把几十个 setInterval 收缩成一个
最后聊一个容易被忽视的实践:页面上的 setInterval 越多,浏览器内核里那个“最近到期闹钟”的排序、合并、唤醒压力就越大。虽然现代浏览器撑得住几十个 timer,但长时间跑下来,这种零散的定时器调度仍然会带来不必要的能耗。
我自己在维护一个数据大屏项目时,做过一次“定时器收敛”:把页面里十几个各自为政的 setInterval(轮询接口的、刷新时钟的、检查状态的、动画驱动的),统一收敛为一个“主循环”定时器,靠一个 Map 管理所有回调:
JAVASCRIPT
复制
1
const timers = new Map();
2
let masterTimer = null;
3
4
function registerTimer(id, callback, interval) {
5
timers.set(id, { callback, interval, lastFireAt: Date.now() });
6
ensureMasterTimer();
7
}
8
9
function ensureMasterTimer() {
10
if (masterTimer) return;
11
masterTimer = setInterval(() => {
12
const now = Date.now();
13
for (const [id, timer] of timers) {
14
if (now - timer.lastFireAt >= timer.interval) {
15
timer.lastFireAt = now;
16
timer.callback();
17
}
18
}
19
}, 250); // 主循环自己跑 250ms 一次
20
}
21
22
function unregisterTimer(id) {
23
timers.delete(id);
24
if (timers.size === 0 && masterTimer) {
25
clearInterval(masterTimer);
26
masterTimer = null;
27
}
28
}
主循环每 250ms 检查一次所有注册的回调,只要发现某个回调的“距离上次执行时间”达到了它的间隔,就执行一次。这个方案的优点是很直观:整个页面只需要一个系统级定时器在运行,所有业务回调都在内存里做时间判断,既减少了内核唤醒次数,也让定时器的生命周期管理变得集中。代价是间隔的精度受主循环检查频率影响,所以主循环的频率要比最大的业务间隔高一个量级,实际跑下来数据大屏里的轮询、时钟刷新都挺稳的。
这套“一个主循环 + 时间戳判断”的模式,在很多长连接场景和流式刷新场景里都是成熟做法。它比堆一堆 setInterval 要省电得多,排查问题时也只需要看一个入口,非常推荐给页面里定时器种类繁多的项目。
写到这里,这条链路其实还没到最底层。如果继续往下钻,还能看到 Linux 内核里 hrtimer 的到期处理、时钟源的切换、tickless 调度是怎么让 CPU 在空闲时“睡”得更深的。更进一步,还能对比嵌入式世界里 STM32 的硬件定时器和 FreeRTOS 的软件定时器——硬件定时器靠外设时钟计数,软件定时器靠系统节拍 tick,两者和今天聊的“浏览器定时器”在思路上完全同构,只是精度和资源消耗的坐标系不同。我在实际调代码时的体会是:理解定时器的关键,不是背 API,而是搞清楚“谁在计时、谁在排队、谁在唤醒”这三件事。搞明白了这三件事,无论是排查前端倒计时跑偏,还是给 Node 服务写定时任务,都不会再被表面现象带偏方向。