1. 从一次定时器“失灵”说起setTimeout到底在什么时候执行很多人第一次用setTimeout都踩过同一个坑明明写了setTimeout(fn, 0)结果代码并没有立刻执行反而排在了后面。更让人困惑的是当需要往回调函数里传参数时有人写setTimeout(fn(a, b), 1000)浏览器直接报错或者行为完全不对。这两个问题几乎覆盖了setTimeout日常使用中八成的疑惑。先把结论摆出来setTimeout是 JavaScript 里用来做延时调度的 API它属于宿主环境浏览器或 Node.js提供的定时器能力不是 JavaScript 语言本身的一部分。它的核心作用是把一段函数推迟到指定毫秒数之后交给事件循环去执行。而“带参数”这件事标准写法是把参数放在延时时间之后依次传入而不是在函数名后面加括号。这篇文章面向的是已经会写基本 JavaScript、但在实际项目里对定时器行为拿不准的开发者。我会从执行时机、参数传递、this 指向、清除定时器、常见误用这几个角度把setTimeout讲透并且给出可以直接复制运行的代码。读完之后你应该能判断一段定时器代码到底会怎么跑也能避开那些看起来没问题、实际会出错的写法。提示本文所有示例都可以直接在浏览器控制台或 Node.js 环境里运行建议边看边敲一遍效果比只读强很多。2. setTimeout 的执行时机为什么延时 0 也不是立刻执行2.1 事件循环与任务队列的基本模型要理解setTimeout的行为绕不开事件循环Event Loop。JavaScript 是单线程执行的同一时刻只能跑一段代码。那异步任务怎么办靠任务队列排队。整个流程可以粗略理解成主线程执行同步代码遇到setTimeout就把回调注册进去等延时到了宿主环境把回调放进任务队列主线程空闲时再从队列里取出来执行。这里的关键是“主线程空闲”——如果当前同步代码还没跑完哪怕延时是 0回调也得等着。console.log(A); setTimeout(() { console.log(B); }, 0); console.log(C); // 输出顺序A C B这段代码的输出顺序是 A、C、B而不是 A、B、C。原因就是setTimeout的回调被放进了任务队列必须等同步代码console.log(C)执行完主线程才会去处理队列里的回调。2.2 延时时间不是精确值而是最小等待时间很多人以为setTimeout(fn, 100)就是精确的 100 毫秒后执行实际上它表示“至少 100 毫秒后才有资格被执行”。真正执行的时间取决于主线程什么时候空闲。如果主线程被一段耗时 500 毫秒的循环占住那么即使延时设成 100 毫秒回调也得等这 500 毫秒跑完才能执行。这一点在动画、轮询、倒计时场景里特别重要因为时间会“漂移”。const start Date.now(); setTimeout(() { console.log(实际延时, Date.now() - start); }, 100); // 模拟主线程阻塞 while (Date.now() - start 300) {} // 实际输出会接近 300 毫秒而不是 100 毫秒2.3 嵌套 setTimeout 的最小延时限制浏览器对嵌套超过 5 层的setTimeout有一个最小延时限制通常是 4 毫秒。也就是说如果你用setTimeout递归调用自己前几层可能按你写的延时走但嵌套深了之后即使写 0实际也会被拉到 4 毫秒左右。这个机制是为了防止开发者写出过于密集的定时器循环把主线程拖垮。实际项目里如果要做高频调度更推荐用requestAnimationFrame或者基于时间戳的补偿逻辑而不是无脑递归setTimeout。场景写法实际行为普通延时setTimeout(fn, 0)排入队列等同步代码跑完主线程阻塞setTimeout(fn, 100) 长循环延时会明显大于 100ms深层嵌套递归 setTimeout(fn, 0)超过 5 层后最小约 4ms3. 带参数传递标准写法与那些容易写错的姿势3.1 正确姿势参数放在延时时间之后setTimeout的完整签名是setTimeout(callback, delay, arg1, arg2, ...)。从第三个参数开始都会原样传给回调函数。这是最干净、最标准的带参数写法。function greet(name, age) { console.log(我是 ${name}今年 ${age} 岁); } setTimeout(greet, 1000, 小明, 18); // 1 秒后输出我是 小明今年 18 岁这种写法的好处是不需要额外创建闭包参数直接由定时器 API 传递代码可读性也高。需要注意的是这种传参方式在很老的浏览器比如 IE9 及以下里不支持但放到今天已经完全没有兼容性顾虑。3.2 错误写法setTimeout(fn(a, b), 1000) 到底错在哪这是新手最容易犯的错// 错误写法 setTimeout(greet(小明, 18), 1000);这行代码的问题在于greet(小明, 18)后面带了括号表示立即调用这个函数。于是greet会马上执行打印出内容然后把它的返回值这里是undefined传给setTimeout。结果就是函数立刻执行了定时器却什么都没调度。正确的做法是传函数引用而不是函数调用结果// 正确写法一直接传函数引用 参数 setTimeout(greet, 1000, 小明, 18); // 正确写法二用箭头函数包一层 setTimeout(() greet(小明, 18), 1000);3.3 闭包传参箭头函数与 bind 的取舍除了标准传参用闭包包裹也是很常见的做法。箭头函数写法简洁能直接访问外层变量适合参数来自当前作用域的场景。const user { name: 小红, age: 20 }; setTimeout(() { console.log(user.name, user.age); }, 500);另一种是bindfunction greet() { console.log(我是 ${this.name}今年 ${this.age} 岁); } const user { name: 小红, age: 20 }; setTimeout(greet.bind(user), 500);bind的好处是同时解决了参数传递和this指向两个问题。如果回调里需要用到thisbind往往比箭头函数更直观因为箭头函数不绑定自己的this会直接沿用外层作用域的this。传参方式写法适用场景标准传参setTimeout(fn, delay, a, b)参数简单、无需绑定 this箭头函数setTimeout(() fn(a, b), delay)需要访问外层变量bindsetTimeout(fn.bind(obj, a), delay)需要固定 this 指向3.4 传对象参数时的引用陷阱当传的参数是对象时要注意传进去的是引用不是副本。如果回调执行前对象被修改了回调里拿到的也是修改后的值。const config { count: 1 }; setTimeout((cfg) { console.log(cfg.count); // 输出 2不是 1 }, 100, config); config.count 2;如果希望回调拿到的是当时的值需要在传参前做一次浅拷贝比如{ ...config }。这个小细节在配置对象、状态快照场景里很容易被忽略排查起来还挺费劲。4. this 指向问题回调里的 this 为什么不是你以为的那个4.1 普通函数回调中的 this 丢失先看一段代码const counter { count: 0, start() { setTimeout(function () { this.count; console.log(this.count); }, 100); } }; counter.start(); // 输出 NaN这里输出NaN因为普通函数作为回调执行时this不再指向counter而是指向全局对象浏览器里是window严格模式下是undefined。window.count是undefinedundefined得到NaN。4.2 三种修复 this 指向的方案第一种是用箭头函数箭头函数不绑定自己的this会捕获定义时外层的thisconst counter { count: 0, start() { setTimeout(() { this.count; console.log(this.count); // 输出 1 }, 100); } };第二种是用bind显式绑定setTimeout(function () { this.count; console.log(this.count); }.bind(this), 100);第三种是提前把this存到变量里这是 ES6 之前最常见的写法const self this; setTimeout(function () { self.count; console.log(self.count); }, 100);三种方案都能解决问题实际项目里我更推荐箭头函数代码最短语义也最清晰。只有在需要动态绑定不同对象时才考虑bind。4.3 类方法里使用定时器的注意事项在类的方法里用setTimeout时同样要注意this。如果回调里要调用类的其他方法用箭头函数最省心。class Poller { constructor() { this.timer null; this.count 0; } start() { this.timer setTimeout(() { this.count; console.log(轮询次数, this.count); this.start(); // 递归继续 }, 1000); } stop() { clearTimeout(this.timer); } }这里把定时器 id 存到实例属性上方便后续清除。如果不用箭头函数this.start()就会报错因为this已经不是实例了。5. 清除定时器与内存管理别让回调悄悄泄漏5.1 clearTimeout 的正确使用方式setTimeout会返回一个定时器 id把这个 id 传给clearTimeout就能取消尚未执行的回调。const timerId setTimeout(() { console.log(这行不会执行); }, 1000); clearTimeout(timerId);需要注意的是如果回调已经执行了再调用clearTimeout不会有任何效果也不会报错。所以清除操作要放在回调执行之前。5.2 组件卸载时忘记清除的后果在前端框架里组件卸载后如果定时器还在跑回调里访问已经销毁的 DOM 或状态轻则报错重则造成内存泄漏。这是一个非常典型的隐患。// 以某个组件生命周期为例 let timer null; function mount() { timer setTimeout(() { // 访问 DOM }, 5000); } function unmount() { clearTimeout(timer); timer null; }养成习惯只要创建了定时器就在对应的销毁逻辑里清除它。把定时器 id 统一管理比散落在各处要可靠得多。5.3 定时器 id 在浏览器与 Node.js 中的差异浏览器里setTimeout返回的是数字 idNode.js 里返回的是一个Timeout对象。这意味着同一段代码在两个环境里对返回值的处理方式可能不同。// 浏览器数字 const id setTimeout(() {}, 100); console.log(typeof id); // number // Node.js对象 const id setTimeout(() {}, 100); console.log(typeof id); // object如果代码需要跨环境运行不要对返回值做类型假设直接把它存起来传给clearTimeout就行。另外 Node.js 的Timeout对象还有unref()方法可以让定时器不阻止进程退出这在写命令行工具时很有用。6. 实战中那些容易翻车的写法与替代思路6.1 用 setTimeout 做倒计时的精度问题用setTimeout做倒计时如果每次都固定减 1 秒时间一长就会累积误差。因为每次回调的实际执行时间都会略大于 1000 毫秒。更稳的做法是基于时间戳计算剩余时间const endTime Date.now() 60000; // 60 秒后结束 function tick() { const remain endTime - Date.now(); if (remain 0) { console.log(倒计时结束); return; } console.log(剩余, Math.ceil(remain / 1000), 秒); setTimeout(tick, 1000); } tick();这样即使某次回调延迟了下一次也会自动校正不会越走越偏。6.2 循环里使用 setTimeout 的经典陷阱看这段代码for (var i 0; i 3; i) { setTimeout(() { console.log(i); // 输出 3 3 3 }, 100); }输出是三个 3因为var声明的i是函数作用域等回调执行时循环早就结束了i已经是 3。解决办法有两个把var换成let或者用闭包把当前值包起来。// 方案一let for (let i 0; i 3; i) { setTimeout(() console.log(i), 100); // 0 1 2 } // 方案二闭包 for (var i 0; i 3; i) { (function (j) { setTimeout(() console.log(j), 100); })(i); }6.3 高频调度场景下 setTimeout 的替代方案如果要做动画或高频更新setTimeout并不是好选择。它的执行时机受主线程影响大容易出现卡顿。浏览器提供了requestAnimationFrame它会在每次重绘前调用节奏和屏幕刷新率对齐动画更顺滑。function animate() { // 更新动画状态 requestAnimationFrame(animate); } requestAnimationFrame(animate);对于需要按固定间隔重复执行的任务setInterval看起来更合适但它同样有累积误差问题而且如果回调执行时间超过间隔任务会堆积。实际项目里更推荐用递归setTimeout配合时间戳校正控制力更强。方案适用场景注意点setTimeout一次性延时任务延时是最小等待时间setInterval固定间隔重复任务回调耗时长会堆积requestAnimationFrame动画、高频渲染与屏幕刷新率对齐递归 setTimeout需要动态调整间隔配合时间戳避免漂移6.4 一个容易忽略的细节延时参数为负数或非数字如果传给setTimeout的延时是负数浏览器会把它当成 0 处理。如果传的是非数字比如字符串会先尝试转成数字转不了就当 0。setTimeout(() console.log(a), -100); // 相当于 0 setTimeout(() console.log(b), abc); // 相当于 0这个行为在不同环境里基本一致但依赖它并不好。写代码时还是老老实实传一个合理的非负数字避免给自己挖坑。7. 我个人的几条实操建议用setTimeout这么多年我总结下来最值得记住的其实就几条。第一永远记住延时是“最小等待时间”不是精确时间凡是和时间精度强相关的逻辑都要用时间戳做校正。第二带参数就用标准写法setTimeout(fn, delay, ...args)别在函数名后面加括号这个错误太常见了。第三只要创建了定时器就一定要有对应的清除逻辑尤其是在组件化开发里忘记清除是内存泄漏的高发区。第四涉及this的回调优先用箭头函数能省掉很多不必要的绑定代码。还有一个小技巧调试定时器相关问题时可以在回调里打印Date.now()和预期时间的差值一眼就能看出是延时不准还是逻辑有问题。这个习惯帮我定位过不少“看起来是定时器坏了、其实是主线程被阻塞”的案例。定时器本身很简单难的是理解它背后的执行模型把事件循环这一层想清楚了绝大多数疑惑都会迎刃而解。