1. 计时事件不止 setTimeout:先把三兄弟摆到桌面上
刚入行做前端那几年,我对 JavaScript 计时事件的理解就是一招 setTimeout 吃遍天。后来一个页面倒计时在用户切后台再回来后差出十几秒,另一个轮询接口因为 setInterval 在慢网络下疯狂排队,我才意识到这类 API 表面上只是“延迟执行 / 每隔一段时间执行”,背后却连着事件循环调度、渲染帧和回调作用域这些老底子。这篇文章是从实际踩坑里整理出来的,覆盖 setTimeout、setInterval、requestAnimationFrame 三兄弟的差异,也会讲定时器回调里的 this、传参、异常处理,以及在 Vue/React 项目里怎么避免组件卸载后定时器继续跑的坑。适合刚把 JavaScript 基础语法过完、正开始写业务逻辑或网页小游戏的开发者;如果你已经写了两三年,也能在里面找到一些边界行为的印证。
1.1 setTimeout、setInterval、requestAnimationFrame 各自的定位
先说使用频率最高的 setTimeout。它的语义是“等一段时间后,把回调放进任务队列”,不是“等一段时间后立刻执行”。这个区别很多人一开始不重视,但在事件循环被长任务阻塞时就会翻车。setInterval 则是每隔固定时间往任务队列里添加回调,适合做轮询、心跳检测、秒杀倒计时这类“周期活儿”。不过真正做动画和网页游戏的人都知道,还有第三个更懂屏幕的计时器——requestAnimationFrame,它可以让你在浏览器渲染下一帧之前执行更新逻辑,从而实现跟显示器刷新率同步的画面。
这三个 API 表面上都是“和时间打交道”,但设计目标完全不同。setTimeout 适合一次性延时,setInterval 适合固定节奏的任务,requestAnimationFrame 则是为帧动画而生的。如果把三者混为一谈,很容易写出“看起来能用,一上线就出问题”的代码。
1.2 三兄弟差异速查表
为了让你一眼看清楚适用范围,我整理了一张简化版对照表:
| API | 触发时机 | 是否自动重复 | 与渲染帧关系 | 页面切到后台后 | 常见场景 |
|---|---|---|---|---|---|
| setTimeout | 延迟一定时间后进入任务队列 | 否,需要递归调用 | 无关,宏任务 | 可能被节流 | 防抖、延时提示、一次性任务 |
| setInterval | 每隔固定周期向队列添加任务 | 是 | 无关,宏任务 | 被节流,可能合并触发 | 轮询、倒计时、心跳 |
| requestAnimationFrame | 浏览器每次绘制前一帧 | 是,回调里继续注册 | 与刷新率严格同步 | 自动暂停 | 动画、游戏主循环、图表更新 |
从这个表能看出,requestAnimationFrame 和另外两个最大的差别是“跟不跟渲染走”。你如果只是定时请求一个接口,用 rAF 是浪费的,因为它每秒最多会被调用 60 到 120 次,远超你需要的频率。反过来,你要做 60fps 的逐帧动画,却用 setTimeout(loop, 16.7),那误差和丢帧会让你很难受。
1.3 搜索“计时事件”时,为什么总混进 javascript:void(0)
很多人在搜“JavaScript 计时事件”时,会看到一些推荐词里带出 javascript:void(0),比如 javascript:void(0) 和更奇怪的 javascript:void(document.title=document.cookie)。先说结论:void 运算符和计时事件没有关系,它是用来“对某个表达式求值,并强制返回 undefined”的运算符。
javascript:void(0) 这个写法在古早网页里很常见,主要作用是塞在 <a href> 里,让用户点击链接时不发生页面跳转。现在做项目,我不推荐继续用这种写法,因为一个链接如果没有真实地址,语义上就应该用 <button>,而不是用 <a href="javascript:void(0)">。至于后面跟了一长串 document.cookie 的那种写法,属于我在后面第 7 节要专门排雷的话题,这里先按下不表——它不是技巧,是危险信号。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事件循环视角:setTimeout 为什么经常“不准点”
2.1 你设的不是“准点闹钟”,而是“到点排队”
有一次我排查一个倒计时页面,发现计时器整整延迟了两秒才触发。我当时很困惑,明明 setTimeout(fn, 1000),怎么会慢一倍?后来查主线程才发现,页面上有一个图表库在初始化时同步跑了大量计算,把主线程堵住了一秒多。setTimeout 的回调在计时到点后,只是被放进了宏任务队列,它必须等当前执行栈清空、前面排队的任务都处理完,才有机会执行。
你可以把 JavaScript 主线程想象成银行柜台:setTimeout 相当于你预约“两分钟后我来办业务”,但到了时间点,只表示你可以在取号机上取号了,前面如果有正在办理的大爷,你还是得排队。这个模型解释了绝大多数“定时器不准”的问题。所谓不准,不是计时有 bug,而是执行时机被推迟了。
2.2 最小延迟、嵌套阈值和后台标签页节流
除了排队,还有几个内置限制会让回调“迟到”。浏览器规范要求,当 setTimeout 和 setInterval 出现嵌套调用(比如在回调里再创建定时器)超过一定层级时,最小延迟会被强制提升到 4ms。也就是说,你写 setTimeout(fn, 0) 连续嵌套很多次,实际间隔不会真的接近 0ms,而是会被限制在 4ms 左右。
另外,当页面切到后台标
