重置setTimeout后是否会触发两次?ResetableTimeout类风险解析
关于ResetableTimeout类触发两次"done"事件的可能性分析与解决办法
先来看你给出的代码和场景描述:
我有时会采用如下实现方案:
class ResetableTimeout extends EventEmitter { constructor() { this.id = -1; } start(delay) { clearTimeout(this.id); this.id = setTimeout(() =>{this.emit("done");}, delay); } }这类架构可用于节流操作等场景。我发现该实现可能在以下场景中触发两次:
- setTimeout启动定时器
- 调用start(delay)并处于执行过程中
- 定时器触发,回调被推入事件循环,等待start(delay)执行完毕
- 调用clearTimeout,但此时定时器已完成
- start(delay)执行结束,定时器回调执行
- 延迟指定毫秒后,定时器回调再次执行
结论:你描述的这种情况不可能发生
原因要从JavaScript的单线程事件循环机制说起:
- JavaScript是单线程的,所有同步代码都会在当前执行栈中依次执行,只有当执行栈完全清空后,引擎才会去处理任务队列(比如定时器回调、Promise回调等)。
- 当你调用
start(delay)时,clearTimeout和setTimeout都是同步操作,整个start方法的执行属于当前执行栈的一部分。在start执行完毕之前,事件循环根本不会去处理任何定时器回调——也就是说,步骤3里的“定时器触发,回调被推入事件循环”是不可能在步骤2的start执行过程中发生的。 - 退一步说,当某个定时器的回调被推入事件队列时,说明该定时器已经完成,但此时
start方法要么已经执行结束,要么还没被调用,绝对不会处于“执行过程中”的状态。
虽然当前场景不会出问题,但可以优化代码让它更严谨
虽然你的现有代码在同步调用start的情况下不会出现重复触发,但如果后续你在start中加入了异步逻辑(比如await某个异步操作),就可能出现旧定时器回调未被清理的情况。所以可以给代码加一层防护:
class ResetableTimeout extends EventEmitter { constructor() { this.id = -1; } start(delay) { clearTimeout(this.id); // 保存当前定时器的ID const currentTimerId = setTimeout(() => { // 只有当前实例的id和这个定时器的id一致时,才触发事件 // 避免旧定时器回调在新定时器创建后误触发 if (currentTimerId === this.id) { this.emit("done"); } }, delay); this.id = currentTimerId; } }
这个优化的核心是:每个定时器回调都会检查自己的ID是否和实例当前保存的id一致——如果不一致,说明后续已经调用过start创建了新的定时器,旧的回调就不应该再触发事件。
内容的提问来源于stack exchange,提问作者Tomáš Zato
相关产品推荐
相关产品推荐

