中断轮询的setTimeout无效的情况

需求中有一个弹窗的表数据需要循环调用接口来获取,关闭弹窗时需要中断循环,但是出现中断失败的情况,为此查了一下原因并做了一些处理方法

前提紧要

  • clearTimeout给你一个反悔的机会,重点是要在setTimeout事情发生之前,当setTimeout事件进行中clearTimeout无法清除定时器的。这是我的代码出现问题的重要原因。

问题出现场景

  • 需求:弹窗的表数据需要循环调用接口来获取,关闭弹窗时需要中断循环(后续的接口不继续调用了)
  • 错误代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 中断方法 关闭弹窗时调用的方法
async handleClose() {
// 重点:clearTimeout() 是取消由 setTimeout() 设置的定时操作,而不是打断!!
console.log('---关闭弹窗清除的定时器',this.timing)
if(this.timing) {
/*
如果走到一半才关闭弹窗(企图清除进行中的定时器),当存在this.timing的定时器
进行中时,clearTimeout自然无法取消进行中的事情,就会导致getProcess继续执行,
继续循环调用直至接口判断数据请求结束才停【实际上这样是无效的】
*/
clearTimeout(this.timing)
this.timing = null
}
},
// 循环调用接口的方法 弹窗初始会调用this.getProcess({ taskId: xxx })触发
async getProcess ({ taskId }) {
console.log('---执行中的定时器',this.timing)
// ...axios post方法,发出请求...
this.timing = setTimeout(() => {
// 循环调用getProcess通过接口获取弹窗内表数据
this.getProcess({ taskId });
}, 500)
},
  • 结局: timing为49的定时器执行中,如果清除,实际上并没清除49,因为进行中的定时器清除无效,所以如果走到一半才清除,那后面的会继续执行,所以会继续循环调用getProcess,中断失败
1
2
3
4
5
6
7
8
9
// 如果不加timing=null,在getProcess开头通过timing判断要不要执行就会报错如下
---执行中的定时器 null
---执行中的定时器 45
---执行中的定时器 49
---关闭弹窗清除的定时器 49 // 因为定时器49正在执行,所以定时器清除失败,后续轮询继续
---执行中的定时器 54
---执行中的定时器 55
---执行中的定时器 56
---执行中的定时器 57

最终解决

  • 下面3个方法,方法1是顺着上面的思路解决问题的方法,但实际上不顺思路更好些,目前感觉最优解是方法3
  • 方法2和方法3不需要 timing 也可以,但是方法1离不开 timing ,需要通过定时器名字来清除未执行的定时器
    • 毕竟是频繁创建定时器,执行完就通过设置 timing 为 null 来清除下定时器的引用是挺好的,明确释放对定时器的引用,以帮助垃圾回收,但如果不设置 null 也行,执行完的定时器会自动被释放

方法1

  • 弹窗关闭时清除(已设置并未执行的)定时器,那么此时就满足定时器先设置后清除,并且此时清除的是还没执行的定时器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 中断方法 关闭弹窗时调用的方法
async handleClose() {
this.dialogVisible = false
// 重点:clearTimeout() 是取消由 setTimeout() 方法设置的定时操作,而不是打断!!
},
// 循环调用接口的方法 弹窗初始会调用this.getProcess({ taskId: xxx })触发
async getProcess ({ taskId }) {
// ...axios post方法,发出请求...
let timing = setTimeout(() => {
// 循环调用getProcess通过接口获取弹窗内表数据
this.getProcess({ taskId });
}, 500)
console.log('---set',timing)
// 在弹窗关闭时 清除刚刚设置(还未执行的)定时器 停止后续调用getProcess
if(!this.dialogVisible) { // 使用element的dialog组件,dialogVisible为弹窗显示与否状态
console.log('---关闭弹窗后清除未执行的定时器', timing)
clearTimeout(timing)
timing = null
}
},
1
2
3
4
5
---set 54
---set 55
---set 59
// 执行中的定时器是55
---关闭弹窗后清除未执行的定时器 59

方法2

  • 轮询之前先判断弹窗是否显示,显示才继续请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 关闭弹窗时 只控制visible
async handleClose() {
this.dialogVisible = false
},
// 循环调用接口的方法 弹窗初始会调用this.getProcess({ taskId: xxx })触发
async getProcess ({ taskId }) {
/*
毕竟是频繁创建定时器,执行完就清除下定时器的引用,
明确释放对定时器的引用,以帮助垃圾回收,
不设置 null 也行,执行完的定时器会自动被释放
*/
if (this.timing) {
// 用上 clearTimeout 可以确保只有一个定时器在运行,其实在这不用也行
clearTimeout(this.timing);
this.timing = null;
}

// ...axios post方法,发出请求...

// 只有弹窗显示时才轮询调用getProcess
if(this.dialogVisible) {
// 不用设置 null 也行,执行完的定时器会自动被释放
this.timing = setTimeout(() => {
this.getProcess({ taskId });
}, 500)
}
},

方法3【最优解】

  • 轮询之前先判断弹窗是否显示,不显示则直接返回,不继续请求:
  • 注意:将 this.timing 设置为定时器的引用是有效的,但需要确保在每次创建新定时器之前先清除旧的定时器,所以设为 null 需要放在 getProcess 的首部位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 关闭弹窗时 只控制visible
async handleClose() {
this.dialogVisible = false
},
// 循环调用接口的方法 弹窗初始会调用this.getProcess({ taskId: xxx })触发
async getProcess ({ taskId }) {
// 清除旧定时器
if (this.timing) {
/*
用上 clearTimeout 可以确保只有一个定时器在运行,
其实在这不用也行,毕竟await不会导致两个定时器重叠
*/
clearTimeout(this.timing);
this.timing = null;
}

// ...axios post方法,发出请求...

// 只有弹窗显示时才轮询调用getProcess
if(!this.dialogVisible) {
return
}
this.timing = setTimeout(() => {
this.getProcess({ taskId });
}, 500)
},

clearTimeout和设为null

  • 总结:搭配使用效果更好
  • 建议:在每次创建新定时器之前都清除旧的定时器,这是为了确保不会出现多个定时器同时运行的情况。如果你确定不会出现多个定时器并发运行的情况,你可以简化代码,不清除旧定时器,但在一般情况下,清除旧定时器是一个安全的做法
  • clearTimeout(this.timing)将定时器从内存中清除掉
  • this.timing = null使用原因:
    • 有时,当定时器不再需要执行时,如果不将其设置为 null,它可能会继续存在,并在不合适的时候触发回调函数。所以一般会在 clearTimeout 后将定时器设置为 null ,可以确保不再有对其的引用,从而避免悬挂定时器的问题
    • 单独使用则只是将定时器的指向改为null,并没有在内存中清除定时器,定时器还是会如期运行;如同在debounce函数中将this.timing = null并不能达到防抖的目的,因为每个定时器都只是将内存地址指向了null,而每个定时器都将会执行一遍
  • 定时器是否需要用完清除:
    • 如果是很少个数的定时器,可以不清除
    • 如果数量很多或者数量不可控,则必须要做到手动清除,否则定时器将会非常占用电脑cpu,非常影响性能
      • 对于大量轮询请求的情况,确实需要谨慎处理,以避免占用过多的 CPU 和内存资源。在这种情况下,手动清除已触发的定时器可能是一个好的做法,以确保不会积累大量未完成的定时器。
      • 一个常见的解决方案是在每次轮询之前通过 clearTimeout 和设为 null 清除旧的定时器,以确保只有一个定时器在运行 来实现。这确保了每次只有一个定时器在后台执行,从而降低 CPU 和内存的使用
    • 将定时器变量设置为 null 通常用于释放对定时器的引用,以便在后续代码中无法再次使用它。这通常用于在不再需要定时器的情况下,帮助垃圾回收。但对于已经触发的定时器,不需要担心释放对它的引用,因为它会自动释放,这是JavaScript的内置行为。但如果你希望明确释放对定时器的引用,以帮助垃圾回收,可以手动设置为 null。

变量设为null的作用

  1. 释放内存:将变量设为 null 可以告诉 JavaScript 引擎不再需要该变量,从而触发垃圾回收机制。这有助于释放变量所占用的内存,特别是在变量引用的对象或数据结构较大时。这可以提高应用程序的性能,尤其是在长时间运行的应用程序中。

    1
    2
    3
    let data = fetchLargeData(); // 获取大量数据
    processData(data);
    data = null; // 释放内存
  2. 取消引用:将变量设为 null 可以取消对对象的引用。这对于确保对象不再被访问或修改非常有用。如果对象不再被引用,它将成为垃圾并最终被垃圾回收。

    1
    2
    3
    let obj = { name: 'John' };
    obj = null; // 取消对对象的引用
    // 现在,对象将成为垃圾
  3. 初始化:有时,将变量设置为 null 可以用作初始化值,表示该变量当前没有有效值。这在某些算法和逻辑中很有用,可以明确表示变量的状态。

    1
    2
    3
    4
    let result = null; // 初始化为 null
    if (condition) {
    result = performOperation();
    }
  • 注意:
    • 将变量设置为 null 并不总是必需的,而且在某些情况下可能会导致不必要的复杂性
    • 通常情况下,JS 引擎会自动处理内存管理,不必手动将变量设置为 null。只有在确切需要释放内存或取消引用时,才需要这样做
    • 不过,在某些情况下,明确设置变量为 null 可以提高代码的可读性和可维护性

设置定时器前需要清除定时器的情况

  • 参考文章
  • 如果是setTimeout这种定时器,不清理就会在线程空闲后立即执行一次。
  • 如果是setInterval这种,不清理,就一直按照间隔不断的执行下去。
  • 设置定时器前需要清除定时器的:
    • 情况1:防抖之类的用法,对于频繁调用的方法,去掉中间多次无用的重复调用,只保留最后一个调用。典型应用于根据Input的keydown或者change来数据搜索查询。
    • 情况2:防止定时器效果叠加之外,使用前清除定时器不是必须的。
      • 例子:比如写个hover延时触发的特效,如果不先清除定时器,鼠标多移入移出几次,可能会造成多个定时器的叠加,动画效果会一直在执行,体验不好
  • 补充:
    • 当前产品的这个需求要求轮询请求后端异步查询的数据结果的场景不需要在设置定时器前清除定时器,因为已执行的定时器在执行后会自动从计划任务中删除,它们会被 JS 引擎的垃圾回收机制自动处理(JS 引擎会定期检查不再被引用的对象,包括已完成的定时器),这意味着它们不会持续占用 CPU 或内存资源
    • 所以只需要在满足条件(关闭弹窗)时将未执行的定时器清除即可

不使用 setInterval 实现轮询的原因

  • setTimeout 与 setInterval 执行方式的区别及图示可参考segmentfault
  • 参考segmentfault
  • 参考知乎
  • 参考掘金
  • setInterval 执行不一定准时:

    只能确保回调函数不会在指定的事件间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的状态而定。(比如回调函数执行时长>间隔时间的情况)——《你不知道的 JavaScript》 p143

    • “丢帧”现象(setInterval()仅当队列中没有该定时器的任何其他代码实例在(等待)时,才将定时器代码添加到队列中)【例子可见这篇segmentfault,图解文字都非常详细】
    • 不同定时器的代码的执行间隔可能 < 预期设定的时间间隔
    • 根据事件循环的执行机制,就会知道 setInterval 是等待 call stack 为空的时候,才会执行回调,如果前面的定时器回调函数执行太久了,超出了给 setInterval 设定的时间间隔,此时回调函数已经进入队列,等 stack 终于为空的时候就会立即执行队列中的回调函数,这时候 web APIs 中的 setInterval 计时也已经在计时了一段时间了,很快又会把新一轮回调函数放入队列,就会发现怎么上一次执行完后,没到以为的事件间隔又执行了······· 另外,如果 setInterval 本身给定的回调函数执行的时间比设定的时间间隔长,也会带来这样的问题,失去了设置时间间隔的意义。
  • 相比之下使用setTimeout进行轮询的好处:
    • 在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔(即“丢帧”现象)。
    • 而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。
    • 注意:轮询例子中第二个setTimeout()调用使用了 arguments.callee 来获取对当前执行的函数的引用,并为其设置另外一个定时器【查查 arguments.callee!!!!!!!】
  • 不要盲目使用 setTimeout 代替 setInterval
    • 首先这两者都无法保证时间上的精确性。但在实际项目中,通常需要长时间执行的任务都会被优化掉,所以最终它们的回调执行时间并不会与期望时间点偏差太多。
    • 另外,「定时器」通常有两种,一种是固定地每间隔一定时间触发一下,就像钟表那样;另一种是在前一次触发之后,间隔一段时间再触发下一次,通常这种在触发时都会需要执行一个比较耗时的异步任务。
    • 对于上面所说的第一种,使用 setInterval 即可,而第二种才应当使用 setTimeout

websocket

  • 实际上应该用websocket来实现效果会更好,但是后端那边要求沿用轮询就先这样吧
, ,