这个问题在开发者社区里经常被问到,但很多人写了两三年 JavaScript 也不一定认真想过:setTimeout(fn, 1000) 里的 1000 到底是怎么被计算机计时的?它是看系统时间走到哪个点了,还是自己在后台默默数秒?如果我现在把系统时间往前调 20 分钟,正在排队等待的定时器会提前触发吗?如果电脑休眠 10 分钟再唤醒,定时器是立刻执行,还是把剩下的时间补完再执行?
我的经验答案是:setTimeout 作为定时器,它的底层机制通常不是去反复读取系统的墙上时钟(比如 Date.now()),而是使用系统提供的一个相对、单调递增的时间基准来估算“还要等多久”。所以,系统时钟被人为修改时,已经排队的 setTimeout 一般不会跟着“提前触发”或“无限延期”。但这不代表 setTimeout 就是严格的“经过时间”——因为事件循环阻塞、后台标签页节能策略、系统休眠、定时器嵌套节流等因素,都会让真实触发时间远远大于你设定的那个数。
这篇文章会把这层东西彻底拆开:什么是系统时钟和单调时钟、时间循环里 setTimeout 怎么被调度、系统休眠/改动时间后具体会发生什么,以及怎样设计代码才不会被这些时间陷阱坑到。不管你是写业务页面还是做基建,只要用过 setTimeout、setInterval、倒计时、动画节流,都值得花几分钟弄明白这些底层逻辑。
1. 秒表不是墙上时钟:setTimeout 的时间基准到底是什么
1.1 计算机里有两套完全不同的“时间”
要搞清楚这个问题,先要把两样东西分开:墙上时钟(wall clock)和单调时钟(monotonic clock)。
墙上时钟就是我们平时看的时间,从 1970 年 1 月 1 日 0 点 0 分 0 秒(UTC)开始算到现在经过了多少毫秒。在 JavaScript 里,最典型的就是 Date.now() 和 new Date()。这种时间的特征是“可以被用户、NTP 校时、时区设置随意改变”。比如你把系统时间从 12 点改成 1 点,Date.now() 的返回值会瞬间跳变 3600 万毫秒。
单调时钟就不一样了,它不关心现在到底是几点几分,它只记录“从某个基准时刻开始,系统持续运行了多久”。比如电脑开机后到现在运行了 5000 秒,那单调时钟就是 5000 秒。它不会因为你把系统时间从 12 点改成 1 点就跟着跳动,它就是一把只往前走、不回拨的尺子。C/C++ 里常见的 clock_gettime(CLOCK_MONOTONIC)、Go 里的 time.Now().Sub(start)、Node.js 里的 process.hrtime() 底层大多基于这种时间基准。
JavaScript 业务代码里能直接感知单调时钟的 API 是 performance.now()。它返回“从页面打开/导航开始时到当前时刻经过了多少毫秒”,而不是“当前是几点几分”。你随便什么时候调用它,它都只跟页面生命周期挂钩,不会受系统时间修改影响。
这两套时间系统在日常开发里常常混在一起,容易栽跟头。比如你要计算一段代码跑了多久,如果用 Date.now() 相减,万一执行中间有人把系统时间往前调了 5 分钟,计算结果直接变成负数。正确做法是用 performance.now(),因为它背后就是单调时钟。
1.2 setTimeout 用的是哪一套
setTimeout 内部关心的是“延迟等待多久”,而不是“现在几点”。所以它在设计上更接近单调时钟,而不是墙上时钟。
你可以在浏览器里做一个实验:先设置一个 5 秒后的 setTimeout,然后在系统设置里把时间往后调 1 小时,再等 5 秒。实验结果通常是,回调依然会在原来的 5 秒左右触发,不会因为你把系统时间调快了 1 小时就提前 1 小时触发。反过来,把系统时间往前调 1 小时,已经排队的定时器也不会因此推迟 1 小时。
用一个生活中的比喻来说:setTimeout(1000) 就像一个厨房定时器,你拧到 1 分钟的位置,它会根据内部的发条或电子计时电路倒计时 60 秒。它不是每次看一眼墙上时钟“哦,现在 12:01,所以我应该 12:02 响”。如果厨房定时器是依赖墙上时钟来判断的,那你半夜把钟从 1 点拨到 2 点,它可能立刻响了。真实的厨房定时器不会这样,setTimeout 在运行中的主流环境里也不会这样。
但这句话需要再严谨一点:setTimeout 不是直接暴露给 JS 引擎的 API。浏览器的定时器由渲染进程里的定时器管理模块负责,Node.js 的定时器则由 libuv 的事件循环来管理。各自的底层实现会用系统提供的相对时间或平台相关的高精度计时接口。虽然不同运行环境在细节上有差异,但它们都不是通过“等待时反复调用 Date.now() 判断到没到时间”这种方式实现的,因为那太脆弱了,系统时间一跳,所有定时器都会乱套。
1.3 为什么要刻意避开系统时钟
从工程角度看,定时器如果依赖墙上时钟,会遇到两个很大的问题。
第一个问题是系统时间可被任意修改。普通用户为了调时区、校准时间、甚至单纯想玩游戏,都可能动系统时间。服务器上还有 NTP 自动校时,随时有可能把时间往外拨或往前拨几百毫秒。如果定时器的到期时间全用 Date.now() + 1000 去算,系统时间一旦发生跳变,所有排队的定时器的触发顺序和触发时间都会被破坏。这在一个基础计时器组件里是无法容忍的。
第二个问题是墙上时钟不是单调的。世界上很多地区有夏令时,虽然中国已经取消很久了,但海外用户仍然会遇到一年两次“凭空多一小时/少一小时”的情况。时区切换、手动改时间,都会让墙上时钟出现重复或跳跃。而实现一个任务调度器时,最理想的情况是“给每个定时器记录一个绝对单调的到期时间,然后不停检查到期时间是否小于当前单调时间”。这样,不管墙上时钟怎么跳,调度器内部的排序仍然稳定。
所以,浏览器实现和 Node.js 实现都会优先选择一种“相对时间/单调时间”的机制。可以说,setTimeout 不是为了回答“现在几点”而存在的,它是为了回答“现在距离开始时刻过了多久”而存在的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事件循环里的 setTimeout:“至少 1000ms”是怎么被击穿的
既然知道了基准是相对时间,很多人会以为 setTimeout(fn, 1000) 就代表“1000ms 后绝对执行”。这句话只对了一半,准确说法是“最早 1000ms 后执行”。把后面那层“最早”忽略掉,是很多线上定时器 bug 的来源。
2.1 一个简单的阻塞实验
你可以把下面这段代码丢到浏览器控制台里跑一下:
javascript复制const st
