
1. 计时事件是什么先搞清楚它解决的三个问题做前端这些年我几乎每个项目都会碰到“隔一段时间自动做点什么”的需求轮播图自动切换、倒计时抢购、弹窗延迟出现、表单防抖校验……这些事的底层都是一个东西JavaScript 的计时事件。计时事件在 JS 里其实就两个核心 APIsetTimeout和setInterval外加两个对应的清除方法clearTimeout和clearInterval。前者是“延迟执行一次”后者是“每隔一段时间重复执行”。很多人刚开始写 JS 时觉得这俩方法太简单了不就是“延迟”和“循环”吗但真正用起来坑一个接一个this指向不对了、循环变量全变成最后一个值、计时器清除不掉、页面越跑越卡……这篇文章我把这些年踩过的坑和排过的错一次性整理出来配合可运行的代码示例看完你基本就能把这些 API 用得明明白白。适合谁看刚入门 JavaScript 的初学者以及已经写了几个月但遇到计时器相关 bug 总是查半天的人。如果你是用原生 JS 做网页游戏、写营销活动页、做后台管理系统的前端这篇文章尤其值得花几分钟读完。2. 计时器的底层机制单线程下的“假并行”2.1 JavaScript 是单线程的先理解一个前提JavaScript 是单线程语言同一时刻只能执行一段代码。但浏览器里不仅有 JS 引擎线程还有事件触发线程、定时器触发线程、网络请求线程等。setTimeout并不是 JS 引擎自己计时而是告诉浏览器“请你在 xx 毫秒后把这段代码放回 JS 执行队列里。”打个比方你去餐厅点菜厨师只有一个灶台单线程但前台收银员可以同时接十几个订单定时器线程。你点了“15 分钟后上一道甜点”收银员记录下这个时间点到了时间她把甜点订单塞给厨师。厨师手头若正在炒菜就得等炒完这道菜再去做甜点。这个类比基本还原了setTimeout的真实行为它保证的是“最短等待时间”而不是“准时执行”。这一点非常重要后面讲“计时不准”时会详细展开。2.2 事件循环与任务队列JS 引擎里有一个叫“事件循环”的机制。调用栈Call Stack里放着当前正在执行的函数当调用栈空了事件循环就会从任务队列里取出下一个任务执行。setTimeout的回调函数在时间到达后会进入任务队列而不是直接执行。所以setTimeout(callback, 0)并不是“马上执行”它的意思是“尽可能快地把 callback 排进队列至少等当前调用栈清空”。看这段代码console.log(A); setTimeout(() console.log(B), 0); console.log(C); // 输出顺序A C B很多人以为会输出 A B C实际输出是 A C B。因为setTimeout的回调被放进了队列尾部必须先执行完当前同步代码A 和 C再轮到 B。这个知识点经常出现在面试题里也解释了为什么有些“立即执行”的需求用setTimeout反而会被延迟。2.3 计时器为什么不“准”setTimeout(fn, 1000)就是 1 秒后执行吗不一定。如果当前调用栈里有耗时任务比如一个循环计算耗时 2 秒那么即使定时器已到时间回调也得等这 2 秒的同步任务跑完才能执行。更隐蔽的是浏览器对定时器嵌套的钳制当嵌套超过 5 层后Chrome 会把最小延迟时间从 0 提升到 4ms。也就是说setTimeout(fn, 0)连续嵌套调用时实际间隔可能是 4ms 甚至更长。后台标签页中的定时器还会被进一步降频或挂起这也会让倒计时出现明显偏差。所以做倒计时时不要用“每次减 1 秒”的方式累加而应该用时间戳差值计算。这个我在第 4 节的场景案例里会给出完整写法。3. 核心 API 详解setTimeout 与 setInterval3.1 setTimeout 的参数与返回值setTimeout的标准签名是setTimeout(fn, delay, param1, param2, ...)fn延迟后执行的函数delay延迟毫秒数默认 0param1, param2, ...传给fn的额外参数。看一个通过参数传递值的写法function showName(name) { console.log(你好 name); } setTimeout(showName, 1000, 张三);很多写惯 ES5 的开发者会写成setTimeout(() showName(张三), 1000)这当然没问题但如果showName内部依赖this箭头函数和普通调用会带来不同的this指向差异后面会专门说。setTimeout的返回值是一个正整数代表这个定时器的 ID。我们要在它触发前取消就用这个 IDconst timerId setTimeout(() console.log(不会输出), 5000); clearTimeout(timerId);注意定时器一旦触发并执行完毕ID 就失效了再调用clearTimeout不会有任何副作用也不会报错所以你可以放心重复调用清理函数。3.2 setInterval 与 clearIntervalsetInterval(fn, interval)按固定间隔反复执行fn。理解它也有一个关键它并不保证每次执行间隔完全一致。如果fn执行本身耗时超过 interval浏览器会等fn执行完后再安排下一次而且不会补偿之前落下的次数。let count 0; const intervalId setInterval(() { count; console.log(第 count 次执行); if (count 5) { clearInterval(intervalId); } }, 1000);上面的代码会让定时器每 1 秒跑一次计数到 5 后清除。注意clearInterval必须在回调内部访问到intervalId所以一般把intervalId声明在回调外面或者让回调引用外部变量。实际项目里最常见的错误是把setInterval的返回值忘了保存结果后面想停止时发现拿不到 ID。建议一开始就养成“创建定时器后立即保存 ID”的习惯const timer setInterval(tick, 1000); // 后续通过 timer 来清理3.3 参数传递与字符串执行的老写法早期教程里会看到setTimeout(alert(hi), 1000)这种传字符串的写法这是把字符串当作代码执行和eval一样有安全隐患而且性能差、作用域混乱。现代浏览器虽然还支持但强烈不建议使用一律传函数引用或箭头函数。还需要注意一个细节把函数名直接传给setTimeout时不能加括号setTimeout(showName, 1000); // 正确把函数引用传进去 setTimeout(showName(), 1000); // 错误立即执行 showName把返回值传给 setTimeout第二个写法非常隐蔽。如果showName()返回undefined那么这个setTimeout静默不执行任何东西也不会报错。排查时很容易让人抓狂。3.4 this 指向的经典陷阱先看这段代码const user { name: Alice, greet: function() { setTimeout(function() { console.log(Hello, this.name); }, 500); } }; user.greet(); // 输出Hello, undefined原因setTimeout回调里的this默认指向全局对象浏览器里是window而不是外层user对象。要解决这个问题有三种常见方式箭头函数setTimeout(() console.log(Hello, this.name), 500);箭头函数没有自己的this会继承外层词法作用域的this也就是user。bindsetTimeout(function() { console.log(Hello, this.name); }.bind(this), 500);缓存thisconst self this; setTimeout(function() { console.log(Hello, self.name); }, 500);第一种最简洁后两种兼容老浏览器时更有用。现在项目中基本可以用 ES6 语法建议优先箭头函数。4. 从 API 到应用计时事件的实际落地场景4.1 网页游戏倒计时别用减秒用时间戳用 HTML5 JavaScript 写网页小游戏时倒计时几乎必不可少。很多人一开始会这样写let remain 60; const timer setInterval(() { remain--; if (remain 0) { clearInterval(timer); } console.log(remain); }, 1000);这段逻辑看似没问题但有个隐患如果某个瞬间主线程被其他任务阻塞比如页面渲染、网络请求回调setInterval回调就会滞后。理论上应该跑 60 次实际可能只跑了 58 次倒计时整体被拉长。更稳的做法是记录“目标时间点”然后根据当前时间和目标时间的时间戳差值来刷新显示const totalSeconds 60; const endTime Date.now() totalSeconds * 1000; const timer setInterval(() { const remain Math.max(0, Math.round((endTime - Date.now()) / 1000)); console.log(剩余 remain 秒); if (remain 0) { clearInterval(timer); } }, 200);这里把setInterval的间隔缩短到 200ms让页面能更快响应时间变化同时用时间戳计算保证整体时长准确。即使某次回调因为主线程繁忙延迟了计算出的 remain 依然基于真实时间不会累积误差。游戏里需要“每帧更新”的逻辑比如角色移动用setInterval并不合适。requestAnimationFrame是更贴合屏幕刷新率的方案let lastTime 0; function animate(time) { const delta time - lastTime; lastTime time; // 根据 delta 更新游戏逻辑 requestAnimationFrame(animate); } requestAnimationFrame(animate);它的执行频率由浏览器根据屏幕刷新率决定通常是 60fps而且页面在后台时会自动暂停节省资源。需要注意requestAnimationFrame的回调参数time是调用时刻的毫秒时间戳用它与上一次的差值来驱动动画既不依赖固定间隔也不会因帧率波动而错乱。4.2 轮询请求与心跳检测后台管理系统经常需要定期刷新数据比如“每 30 秒拉取一次订单状态”这就是典型的轮询。最直接的实现setInterval(async () { const res await fetch(/api/order/status); const data await res.json(); render(data); }, 30000);但这里有个问题如果接口响应时间不稳定超过了 30 秒上一次请求还没返回下一次请求又发出了容易造成请求堆积和页面数据错乱。更稳妥的写法是“在上一次请求完全结束后再安排下一次轮询”async function poll() { try { const res await fetch(/api/order/status); const data await res.json(); render(data); } finally { setTimeout(poll, 30000); } } poll();上面用递归setTimeout替代setInterval确保每次请求完成后才开始计时下一次轮询。这也是推荐的做法类似于防抖中“等待结束后重新计时”的思路。心跳检测也常用类似逻辑客户端每隔一段时间向服务器发送一个心跳包判断连接是否存活。前端里最常见的场景是 WebSocket 断线重连let heartbeatTimer null; function startHeartbeat() { stopHeartbeat(); heartbeatTimer setInterval(() { ws.send(JSON.stringify({ type: ping })); }, 15000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }这里多了一个细节startHeartbeat先调用stopHeartbeat避免重复开启多个定时器。这种“先清理再创建”的写法在切换用户、重新登录、组件重挂载等场景里非常重要。4.3 防抖与节流计时事件的经典变体防抖debounce和节流throttle是搜索框、滚动事件、窗口 resize 里最常用的性能优化手段两者本质都是基于setTimeout。防抖在事件触发后等待一段时间比如 300ms如果等待期间又触发了就重新计时只有“停止触发”一段时间后才真正执行函数。function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); }, delay); }; }节流固定时间间隔内只执行一次比如“1 秒内最多触发一次”。function throttle(fn, interval 1000) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }这两个函数在日常开发中出现频率极高。注意debounce里的fn.apply(this, args)这是为了让原函数在被调用时保留正确的this指向。如果不这样写像fn(...args)当一个对象方法经过debounce包装后this就会指向错误的对象。还有一种带“立即执行”选项的防抖版本适合“第一次点击立即执行后续点击防抖”的场景function debounceImmediate(fn, delay 300, immediate false) { let timer null; return function(...args) { if (immediate !timer) { fn.apply(this, args); } if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; }这种写法在提交按钮防重复点击时很有用第一次点击马上提交之后 300ms 内的重复点击都被忽略。4.4 原型工具里嵌入脚本Axure 与定时器一些产品经理或交互设计师会在 Axure 里嵌入 JavaScript 脚本让原型更接近真实产品的交互体验。Axure 支持在“交互事件”里添加“自定义脚本”或使用javascript:伪协议这里也会用到计时事件。比如要在 Axure 中实现“页面加载 2 秒后弹窗提示”可以在页面的OnPageLoad事件里写setTimeout(function() { alert(欢迎体验本原型); }, 2000);如果要做轮播图自动切换也可以借助setInterval定时触发“下一张”的交互。不过我建议在原型里尽量少用复杂计时逻辑因为 Axure 原型的作用是验证交互流程不是真实产品计时器多了之后原型的维护成本反而上升。真需要高保真验证直接用 HTML/CSS/JavaScript 写一个独立页面更可控。4.5 Electron 主进程报错里的计时器热搜词里有一条 “a javascript error occurred in the main process”这是 Electron 应用常见的报错。Electron 有主进程和渲染进程主进程里写setInterval或setTimeout时偶尔会因为回调里访问了不存在的对象、或调用了被销毁的窗口对象而报错。比如这样的代码// 主进程代码 setInterval(() { mainWindow.webContents.send(refresh); }, 10000);如果这时mainWindow已经被关闭或销毁回调执行时就会报错。稳妥方式是每次回调里加个判断setInterval(() { if (mainWindow !mainWindow.isDestroyed()) { mainWindow.webContents.send(refresh); } }, 10000);这个坑不算高频但一旦踩到报错信息比较抽象排起来费时间。记住凡是定时器回调里引用了外部可变对象都要考虑对象可能已经不存在的场景。5. 常见报错与排查经验这些坑我全踩过5.1 javascript:void(0) 相关的疑惑热搜词里出现了javascript:void(0)它本身不是计时事件的 API但在很多老代码里会看到它尤其是配合onclick时。void是一个运算符作用是对表达式求值然后返回undefined。hrefjavascript:void(0)的意思是“执行一段 JavaScript 代码并让页面不跳转”。很多项目用a hrefjavascript:void(0) onclickshow()点击/a这种写法来模拟一个“可点击但不跳转”的链接。这样做的问题在于如果 JavaScript 运行时报错点击可能完全没有反应排查时很难定位。更推荐把交互绑定到元素上并阻止默认行为a href# idshowBtn点击/adocument.getElementById(showBtn).addEventListener(click, function(e) { e.preventDefault(); show(); });这样既不会跳转又能正常执行逻辑而且错误更容易追踪。如果你维护的是旧代码看到javascript:void(0)先不要急着删要确认有没有其他脚本依赖这个行为但新代码不建议再这样写。有些场景下void 0还出现在压缩代码里——因为undefined在旧环境里可能被重写而void 0永远返回undefined。这是一个历史兼容性技巧现在基本用不到了。5.2 setTimeout 里的函数不执行“定时器不执行”是我见过最多的提问。这类问题排查思路其实很固定确认函数是否真正传进了setTimeout而不是把调用结果传了进去前面提过的setTimeout(fn(), 1000)坑确认是否在当前作用域里能访问到该函数比如函数声明在模块内部定时器却开在全局确认delay是否写成了 0 或者负数浏览器会把 0 和负数都当作最小延迟处理但不会视为错误确认是否有clearTimeout在定时器触发前清除了它很多清除操作发生在意外的代码路径中。一个典型的隐藏 bugconst timer setTimeout(fn, 3000); someOtherFunction(); clearTimeout(timer); // 3秒后 fn 没有执行因为定时器被提前清掉了这种事在代码重构后特别容易发生原先清除逻辑是写在某个条件分支里重构后变成了无条件清理。5.3 循环里创建定时器的闭包陷阱这是个经典且高频的 bug。看下面的代码很多人会以为会依次输出 0、1、2、3、4for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 1000); }实际输出是 5 次 5。原因是var声明的i是函数作用域共享的循环结束后i已经变成 5所有定时器回调读到的都是同一个i。解决方案有三种改用letfor (let i 0; i 5; i) { setTimeout(() console.log(i), 1000); }用立即执行函数创建闭包for (var i 0; i 5; i) { (function(j) { setTimeout(() console.log(j), 1000); })(i); }用bind传参function logNum(j) { console.log(j); } for (var i 0; i 5; i) { setTimeout(logNum.bind(null, i), 1000); }现在项目基本都用 ES6let是最省心的方式。但如果你在维护老代码或写兼容旧浏览器的脚本闭包写法还是要会的。5.4 定时器累积导致页面卡顿一个页面开了多个定时器或者同一个定时器被反复创建页面会越来越卡。这种情况常见于“某个操作被用户多次触发但定时器没有被提前清除”。比如滚动加载更多window.addEventListener(scroll, () { setInterval(() { // 加载更多 }, 1000); });每次滚动都会创建一个新的setInterval结果出现多个定时器同时执行请求频繁发出页面自然卡顿。这类问题的修复思路有两种一是“先清理再创建”保证任何时候只存在一个定时器二是用“防抖/节流”控制触发频率。我在团队 code review 时反复强调只要出现 setInterval就要先问一句它在什么情况下会被清除答不上这个问题的代码多半有隐患。还有一种是定时器没有被清除后浏览器无法释放闭包中引用的 DOM 对象造成内存泄漏。尤其是单页应用里组件销毁了但定时器还在跑回调里又引用了已经卸载的 DOM 元素就会一直占用内存。现代框架React/Vue都在生命周期销毁阶段提供钩子务必在componentWillUnmount或onUnmounted里清除所有游走的定时器。5.5 报错信息排查速查表现象常见原因排查思路setTimeout回调不执行传入了函数调用结果或定时器被清除检查传参是否为函数引用搜索clearTimeout调用点循环输出全是最后一个值var闭包陷阱改用let或闭包传参setInterval只执行一次回调内抛异常导致停止或误用setTimeout打开控制台看报错检查代码逻辑页面越跑越卡定时器未清除多处累积用Performance面板查看定时器数量尽量用递归setTimeout并确保清除this在回调里是window普通函数丢失上下文改用箭头函数或bind后台标签页倒计时不准浏览器节流后台定时器用时间戳差值计算剩余时间Electron 主进程报错回调中访问了已销毁对象先判断对象是否存在再调用6. 计时事件的实际项目小技巧6.1 为什么建议用递归 setTimeout 替代 setIntervalsetInterval的一个固有问题是如果回调执行时间超过间隔时间就会发生“执行重叠”。比如回调耗时 2 秒而间隔只有 1 秒下一次回调不会等上一次完成而是会在上一次仍在运行时就开始执行这可能引发不可预期的联动问题。setInterval还有一个问题是如果页面在后台运行浏览器会降低定时器频率当页面重新回到前台时累积的回调可能一次性连续执行多次导致状态跳变。递归setTimeout能天然避免这两个问题function repeat(interval) { setTimeout(() { // 任务逻辑 repeat(interval); // 任务完成后再安排下一次 }, interval); }我个人的习惯是除非业务场景特别简单且回调执行时间可以忽略否则优先用递归 setTimeout。尤其是请求轮询、数据刷新、动画循环这类场景递归setTimeout更可控。6.2 让 setTimeout 支持 async/awaitsetTimeout本身不支持 Promise想用await等待一个延时很简单封装一个 Promise 版本function sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } async function example() { console.log(开始); await sleep(2000); console.log(2秒后执行); }这个sleep函数在工作里非常好用不管是模拟接口延迟、控制并发节奏还是写测试用例都能让代码更简洁。注意clearTimeout在这里没有什么作用因为resolve只需要执行一次Promise 本身只认第一次调用。6.3 清理定时器的统一模式大型项目里定时器的创建和清理往往散落在不同函数中容易漏掉。我推荐一个小的封装思路用一个对象统一管理页面上所有定时器。const timers { pool: new Map(), set(key, fn, delay) { this.clear(key); const id setTimeout(fn, delay); this.pool.set(key, id); }, clear(key) { if (this.pool.has(key)) { clearTimeout(this.pool.get(key)); this.pool.delete(key); } }, clearAll() { this.pool.forEach(id clearTimeout(id)); this.pool.clear(); } };页面离开或组件销毁时调用timers.clearAll()一把清干净不会再有“某处定时器漏清”的问题。7. 写在最后计时事件的核心思维回过头看JavaScript 计时事件本身不难难的是它背后那一整套“什么时候执行、卡不卡、会不会被清掉”的调度逻辑。我做了这么久前端最大的体会是写计时器代码之前先想清楚它的生命周期——什么时候开始、什么时候结束、中间可能发生什么意外状况。想清楚了setTimeout和setInterval用起来非常简单想不清楚就会出现各种偶现 bug还特别难复现。如果你刚开始学建议把文中的每个例子都亲自跑一遍尤其是在控制台里对比一下setTimeout(fn, 0)和同步代码的输出顺序再把递归setTimeout封装成自己的工具函数。等这些基本操作都形成肌肉记忆再遇到和分析“计时不准”“组件销毁后还在回调”这类问题时就游刃有余了。