ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

setTimeout:从事件循环到工程实践,破解定时器的反转与陷阱

2026/10/7 12:05:34 拓冰建站 浏览量
setTimeout:从事件循环到工程实践,破解定时器的反转与陷阱 如果你让我在 JavaScript 里挑一个最被低估、同时也最容易被误解的 API我会毫不犹豫地选setTimeout。JS异步这套体系里setTimeout的出场率几乎和 Promise 一样高但很多人的理解止步于过多少毫秒就执行这层。真到了线上问题排查时你会发现定时器可能晚执行几百毫秒、明明设了 0 延迟却排在 Promise 后面、甚至因为页面切到后台被整体暂停。这篇文章就是想把这些反转彻底讲透。结合我这些年写前端、写 Node 服务的实操经验下面会从事件循环原理讲到可取消定时器的封装再对比浏览器和 Node.js 的行为差异最后列一堆我踩过的坑。适合刚接触异步编程的初学者也适合工作一两年后想系统梳理定时器的朋友。看完你可以直接拿走里面的封装代码和排查思路不用再绕弯路。1. 先破除最大误解setTimeout 不是延迟执行器而是排队器1.1 一次反问带给我的启发有一次面试候选人的时候我问了一个很基础的题console.log(A); setTimeout(() console.log(B), 0); console.log(C);对方很快答出 A、C、B 的顺序。我又追问如果我把 0 改成 100但是在这 100 毫秒内主线程正在跑一个死循环结果会怎样他犹豫了很久最后说可能还是等 100 毫秒后执行吧但不一定。这就是问题的核心。setTimeout的真实语义不是等这么多毫秒之后执行函数而是等这么多毫秒之后把函数挂到一个可以被事件循环取走的队列上。队列里的任务什么时候真正执行取决于主线程这一会儿忙不忙。我用一个很生活化的类比你把一份材料放到公司前台的待处理筐里前台小妹每隔一段时间才会来取一次。约定 5 分钟后来取是说至少 5 分钟之后这份材料才有机会被处理而不是 5 分钟整那一刻一定开始处理。如果小妹正忙着别的事材料放在筐里就只能干等。1.2 事件循环视角看 setTimeout 的完整旅行为了把这条链路讲清楚我把一次setTimeout(fn, 100)从注册到执行的完整过程拆成四步调用栈执行到setTimeout把fn和延迟时间交给宿主环境浏览器是 Web APINode.js 是底层定时器模块。这一步是注册回调此刻还没有进队列。计时器在宿主环境里走字。100 毫秒一到宿主环境把fn推入宏任务队列也叫 Task Queue。事件循环发现当前调用栈空了就从宏任务队列取出最早的一个任务执行。开始执行fn。期间如果fn里又注册了别的异步任务等它执行完事件循环再去处理新一轮的队列。那0 毫秒是怎么回事本质也是走这条链路只是计时器几乎立刻走完回调很快就排到队尾。但如果调用栈里还有很长一段代码在执行比如一段要算 3 秒的循环那么即使计时器早就走完队列里的回调也只能等 3 秒后出队。这也是为什么说setTimeout 的最小延迟不代表最大延迟。1.3 三层节流为什么时间从来都不是硬保证理解了排队器模型后就很容易接受另一件事实浏览器为了保证性能和电量会对定时器做各种手脚。场景实际行为嵌套定时器超过 5 层HTML 规范建议最小延迟至少 4ms现代浏览器在特定情况下可能仍用 1ms 的精度但整体是往节流走页面切到后台/标签页不可见大部分浏览器把定时器节流到 1 秒甚至更久部分移动端浏览器在锁屏后可能直接暂停电脑休眠、系统压力过大计时器重置或严重推迟恢复后可能把积压的回调批量执行我第一次踩到这个坑是在做一个抢购倒计时页面。用户把手机切到微信回个消息再切回来倒计时已经不是原来那个数了。原因是页面进入后台后定时器被节流甚至暂停等用户切回来积压的回调一口气跑完UI 瞬间跳变。后来改用到期时间戳减当前时间的方式才能在切回来时恢复出正确剩余时间这部分我放到最后一节详细讲。2. 异步节奏的核心博弈宏任务与微任务如何决定顺序2.1 让人迷惑的输出顺序如果你已经会用 Promise 和 async/await多半见过这道题console.log(start); setTimeout(() console.log(timeout), 0); Promise.resolve().then(() console.log(promise)); console.log(end);预期中的输出是start、end、promise、timeout。原因在于事件循环取任务有内部优先级。每一个宏任务执行完之后事件循环会先把微任务队列清空再处理渲染、再取下一个宏任务。setTimeout的回调是一个宏任务而Promise.then的回调是微任务。所以即使setTimeout的 0ms 计时器比 Promise 更早完成注册宏任务队列也得等微任务队列清空才轮得到它。这个机制再往深说一点微任务不只是 PromisequeueMicrotask、MutationObserver都归到微任务宏任务则包括整体脚本、setTimeout、setInterval、I/O 回调、UI 渲染前的回调等。在同一个宏任务内部会产生很多微任务的话这些微任务会把下一个宏任务活活拖到后面。极端情况下一个微任务里不断产生新微任务宏任务队列就被饿死了。2.2 setTimeout(fn, 0) 的真正用途让出主线程明白这个模型后回头看setTimeout(fn, 0)它并不是马上并发执行而是把自己排到当前这个宏任务之后再执行。这带来一个很重要的工程用法把一个很长的同步任务切成多个小片每片之间用setTimeout(0)让一次位让浏览器有机会渲染界面、响应点击。比如在页面上处理一个百万级数组const list new Array(5000000).fill(0); let index 0; const batchSize 50000; function processBatch() { const end Math.min(index batchSize, list.length); for (; index end; index) { list[index] list[index] 1; // 模拟计算 } if (index list.length) { setTimeout(processBatch, 0); } else { console.log(处理完成); } } processBatch();这段代码的本质是把 500 万次循环拆成 100 片每片结束后主动归还主线程。浏览器也许还是需要半秒一秒才能全部算完但用户至少能感觉到页面活着能滚动、能点击而不是白屏卡死。需要提醒的是这只适用于 CPU 密集但可以分片的场景。如果计算本身涉及大量共享状态、又要求高效主线程分片仍然有限更好的选择是 Web Worker 或 Node.js 的 worker_threads把计算从主线程彻底搬走。分片方案只是在不想引入多线程复杂度时的过渡手段。2.3 循环里的 var 陷阱本质也是时序问题for循环配合setTimeout输出什么的问题被问过太多次了for (var i 0; i 5; i) { setTimeout(() console.log(i), 100); }多数人都知道结果不是 0 1 2 3 4而是 5 5 5 5 5。但能不能解释清楚为什么因为var没有块级作用域循环结束后i是同一个变量值已经变成 5。而所有setTimeout的回调都是等到循环跑完之后才开始执行的它们读取i时读到的是此刻的最新值。把var换成let就能解决问题因为let每次迭代都会创建一个新的绑定每个回调捕获的是自己那一轮的i。老项目里如果必须用var还可以用 IIFE 或Function.prototype.bind把每一轮的值先固化下来for (var i 0; i 5; i) { ((captured) { setTimeout(() console.log(captured), 100); })(i); }这个例子跟宏任务微任务的模型其实是一回事所有定时器回调都在未来执行捕获的是未来那个时点的变量状态。只要理解了 JS 的执行时机这种题就不会全靠背答案。3. 工程化实践把 setTimeout 封装成真正可用的异步定时器3.1 可取消定时器不要裸用原生 API浏览器里setTimeout的返回值是一个数字 IDNode.js 里则是一个Timeout对象但两者都能传给clearTimeout取消。实际项目里更大的痛点是一个页面里可能同时存在几十个定时器组件卸载时想要全部清理总不能一行一行去清。我习惯维护一个简单的管理器集中登记所有定时器class TimerManager { constructor() { this.timers new Set(); } set(fn, delay, ...args) { const timer setTimeout(() { this.timers.delete(timer); fn(...args); }, delay); this.timers.add(timer); return timer; } clear(timer) { clearTimeout(timer); this.timers.delete(timer); } clearAll() { for (const timer of this.timers) { clearTimeout(timer); } this.timers.clear(); } } export const timers new TimerManager();这里有个细节值得注意回调执行时要把自己的 timer 从集合里删掉否则Set里会残留一堆已经执行完的 ID/对象造成内存泄漏。在 React 里我会在 useEffect 的清理函数中调用timers.clearAll()在 Vue 的onUnmounted里做同样的事。这套几十行的封装比每次手写const t setTimeout(...)然后到处clearTimeout(t)要省心得多。3.2 把 setTimeout 变成可取消的 sleepsetTimeout配合 Promise 可以封装成一个很常用的sleepconst sleep (ms) new Promise((resolve) setTimeout(resolve, ms));这样代码可以写成async function run() { console.log(开始); await sleep(1000); console.log(一秒后); }但裸sleep有个问题无法中途取消。想取消一个正在等待中的 sleep常见的做法是配合AbortController。我一般在需要取消的场景使用下面这个版本function sleep(ms, { signal } {}) { return new Promise((resolve, reject) { if (signal?.aborted) { reject(new DOMException(Aborted, AbortError)); return; } const timer setTimeout(() { cleanup(); resolve(); }, ms); const onAbort () { clearTimeout(timer); cleanup(); reject(new DOMException(Aborted, AbortError)); }; function cleanup() { signal?.removeEventListener(abort, onAbort); } signal?.addEventListener(abort, onAbort, { once: true }); }); }调用方可以这样const controller new AbortController(); const promise sleep(3000, { signal: controller.signal }); controller.abort(); // 立刻让 sleep 进入 rejectAbortController在现代浏览器和 Node.js 17 里都是原生能力。如果你在写一个请求超时、用户取消操作的公共工具这种可取消的 sleep 会非常有用。3.3 轮询任务用 setTimeout 递归不要用 setInterval做轮询接口时很多新人会顺手用setIntervalsetInterval(async () { const res await fetch(/api/status); // ... }, 5000);这个写法有个隐蔽问题如果某次请求耗时超过 5 秒下一次回调已经在队列里排好队了。上一个请求还没结束下一个又开始轻则造成接口负载叠加重则数据乱序、状态错乱。我的习惯是永远用 setTimeout 递归让下一次调用等这一次真正完成后再排async function poll(interval 5000) { try { const res await fetch(/api/status); // 处理数据 } catch (err) { // 记录错误但不要打断轮询 } finally { setTimeout(poll, interval, interval); } } poll();你不需要关心上一次调用是成功了还是失败了finally确保无论如何都会排下一次。这样即使在网络抖动时请求队列也永远是最多一个在飞。这也是请把 setTimeout 当排队器使用的又一次实践真正排队的是下一次任务的注册时间而不是固定频率。3.4 可测试性用 fake timers 终结脆弱单测定时器代码最怕测试。真实等待 2 秒单测就跑 2 秒等 30 个轮询单测就卡 30 秒。尤其 CI 机器负载高定时器还可能跑不准测试偶发失败。主流的方案是 fake timers。Vitest 里可以这样import { vi } from vitest; vi.useFakeTimers(); const mockFn vi.fn(); setTimeout(mockFn, 1000); vi.advanceTimersByTime(1000); expect(mockFn).toHaveBeenCalledTimes(1); vi.useRealTimers();Jest 的语法也几乎相同。advanceTimersByTime会模拟时间前进所有排队的定时器会按顺序出队这样测试既快又稳定。需要注意一点别把业务代码里所有异步都 mock 掉。比如某个函数内部同时用了sleep和真实网络请求mock 时间可能导致 Promise 调用的时序和线上不一致。我一般只针对纯定时器逻辑做 fake timers网络层用专门的 mock 库两者分开测。4. 浏览器与 Node.js 的 setTimeout 差异同一个 API两个世界4.1 一样的名字不一样的细节我见过不少前端转 Node.js 的同学把浏览器里的经验原封不动搬到服务端结果遇到一堆怪问题。其实setTimeout在浏览器和 Node.js 里存在不少差异。维度浏览器Node.js返回值数字 IDTimeout 对象有 unref/ref 方法回调中 this非严格模式下是 window严格模式是 undefined不推荐依赖通常是 Timeout 对象相关内容最小延迟有夹紧嵌套超过 5 层至少 4ms更接近内核定时精度受系统影响但无固定 4ms 规则后台/空闲节流页面不可见时会节流没有页面概念但进程繁忙时也会推迟对进程生命周期影响无感setTimeout 会保持事件循环不退出除非 unref()最常用到的一个能力是unref()。在 Node.js 里如果某个 setTimeout 是辅助性的不想让它阻塞进程退出可以const timer setTimeout(() { console.log(这个回调可能永远不会执行); }, 5000); timer.unref();当一个进程里只有这个定时器、没有其他待处理事件时进程会直接退出回调也不会执行。这个特性在写 CLI 工具、后台任务时有奇效当然如果你需要它必须执行就老老实实别 unref。4.2 和 setImmediate、process.nextTick 的相爱相杀Node.js 里和定时器纠缠最深的是setImmediate和process.nextTick。很多面试题会问这段输出顺序setTimeout(() console.log(timeout), 0); setImmediate(() console.log(immediate));在主模块里运行输出顺序不稳定有人会觉得奇怪。原因是 Node.js 事件循环启动到真正进入 timers 阶段之间的耗时无法精确预料。如果启动耗时超过 0ms 阈值timers 回调先执行如果恰好没超过check 阶段的setImmediate先执行。但放进 I/O 回调里顺序就稳定了const fs require(fs); fs.readFile(__filename, () { setTimeout(() console.log(timeout), 0); setImmediate(() console.log(immediate)); });I/O 回调属于 poll 阶段该阶段结束后先进入 check 阶段所以setImmediate一定先于下一个轮询里的setTimeout。这个看似冷门的知识点遇到线上日志顺序问题时非常有用。至于process.nextTick它是当前操作结束后立刻执行既不属宏任务也不完全等同 Promise 微任务优先级比 Promise 微任务还要高滥用它会造成事件循环饥饿官方也建议能用queueMicrotask或setImmediate就不要用它。4.3 性能敏感场景动画和调度不该用 setTimeout回到前端场景。setTimeout(fn, 0)是有用的但它不是万能的异步神器。如果你的目标是流畅动画请用requestAnimationFrame。浏览器一般 16.7ms 刷新一帧动画刚好卡在刷新前更新。用setTimeout调度动画时间对不上浏览器渲染节奏就会抖动。尤其在嵌套超过 5 层后浏览器把最小延迟拉到 4ms 以上动画精度更没保障。我在高德地图做过一段时间轨迹动画刚开始用setTimeout(step, 16)控制小车移动帧率看着还行但开久了偶尔会卡顿。后来改成requestAnimationFrame通过时间戳计算位移丝滑程度完全不一样。至于 Node.js 侧的高频调度如果需要稳定精确的时间戳可以用process.hrtime.bigint()或perf_hooks里的performance.now()而不是依赖 setTimeout 自己报告的触发时间。5. 实战踩坑记录this 丢失、闭包捕获、参数传递与时间精度5.1 回调里的 this 会悄悄变成别人使用对象方法作为定时器回调时this 是一个高频坑。const counter { count: 0, tick() { console.log(this.count); } }; setTimeout(counter.tick, 1000);这段代码里counter.tick被当作一个普通函数传入定时器。定时器触发时回调是在全局作用域下被调用的this不再指向counter于是this.count就变成了 undefined输出变成 NaN。如果在严格模式下this是 undefined代码直接报错。解决方式有三种各有使用场景setTimeout(() counter.tick(), 1000); // 或者 setTimeout(counter.tick.bind(counter), 1000);第一种箭头函数最直观代价是每次都会创建一个新的箭头函数第二种bind需要额外生成一个函数如果定时器要清理记得保存绑定后的引用。还有一种是在定义方法时就用箭头函数包一层但箭头函数写在对象字面量里捕获的是定义时外层作用域的 this通常不是 counter 本身容易踩新坑我一般不建议。5.2 闭包捕获的未来值我在前面已经用 for 循环讲过 var 陷阱。在实际业务里这个坑最容易出现在按索引取数据的定时操作里。比如一个列表用户点击删除第 i 项弹出一个 3 秒撤销提示期间要保住 index 的原始快照for (let i 0; i items.length; i) { setTimeout(() { doSomething(items[i]); }, 1000); }用let就能解决因为每次循环都会新建一个块级绑定回调用它时拿到的是当次的值。如果你在改造老代码只能用var那就用 IIFE 或 bind 立即固化变量。理解起来一句话定时器回调执行的时候循环早就跑完了它看到的永远是执行那一刻的变量不是你写回调那一刻的变量。5.3 额外参数和字符串一个技巧一个雷区setTimeout支持在延迟时间之后继续传参数回调执行时会把它们作为参数带进去setTimeout((a, b) console.log(a b), 0, 3, 5); // 8这个特性从 ES5 时代就有兼容性已经不错IE10。只是现在写多了箭头函数后容易忘。如果你在维护老项目可以用 bind 垫一层setTimeout((a, b) console.log(a b).bind(null, 3, 5), 0);真正的雷区是传字符串给 setTimeoutsetTimeout(console.log(hi), 1000);这种写法等同于 eval不仅性能差、调试困难还有代码注入风险。前端项目里几乎没有任何理由使用它。如果你在代码 review 时看到这种写法直接打回字符串形式的定时器回调一行都不要留。5.4 验证码倒计时与 performance.now 的救场最后说一个真实场景。短信验证码倒计时我的第一版长这样let remain 60; const timer setInterval(() { remain--; if (remain 0) clearInterval(timer); btn.textContent ${remain}s; }, 1000);看起来天衣无缝。直到用户在倒计时还剩 40 秒时把手机锁屏或者把页面切到后台 10 分钟回来一看按钮还亮着 40s。原因就是我在第一节讲的后台定时器被节流甚至暂停。正确的做法是记录一个绝对截止时间每次更新 UI 时都用当前时间和截止时间做差const deadline Date.now() 60 * 1000; function tick() { const remain deadline - Date.now(); if (remain 0) { btn.disabled false; btn.textContent 重新获取; return; } btn.textContent ${Math.ceil(remain / 1000)}s; setTimeout(tick, 1000); } tick();这样即便页面被切到后台、定时器被节流等用户回来Date.now()会给出真实时间差UI 一次性恢复到正确值。计时任务要的是墙上时间而非调用次数这件事请务必记住。另外如果你依赖Date.now()做耗时统计机器时间被手动改动时会失真应该用performance.now()。它的基准是页面启动时刻或进程启动时刻不受系统时间调整影响测量函数执行时间、动画插值这类场景它才是正确工具。踩过几次坑之后我现在的习惯已经变成写任何一个 setTimeout 之前先问自己三个问题——需不需要取消需不需要在组件卸载时清理需不需要精确的时间基准只要有一个答案是需要我就不会直接裸用原生 setTimeout而是走 TimerManager、可取消 sleep 或截止时间倒计时这些封装。不是原生 API 不好是原生 API 把太多边界情况留给了调用方工程代码需要的是确定性。