JS定时器遇浏览器调度踩坑日记

3,123 阅读5分钟

业务场景

在线状态管理

业务描述

前端:处于 在线,忙碌 状态的时候需要定时调用后台的心跳接口,和后台设定的说10秒一次的间隔,这个接口定义为工作人员是否在线的心跳。这个接口后台返回了 计费,使用权限,以及当前是否在线标识。因不可抗力原因未能使用上socket。

后台:每隔一分钟扫描一次心跳表,获取当前工作人员的在线状态,得知在线状态来做对应的在线分配接入等业务。

代码实现

实现方式上,考虑过:setTimeout,setInterval,requestAnimationFrame,咱货比三家

setTimeout:去消息队列排队的时候只需排一次队。

setTimeout(()=>{
    console.log(1)
}, 500)
setTimeout(()=>{
    console.log(2)
}, 1000)
setTimeout(()=>{
    console.log(3)
}, 1500)
alert(4)

setInterval:需要按照预设的间隔时间,每到时间点都去排一下;setInterval去排队时,如果发现自己还在队列中未执行,则不会再去队列排队。也就是说,同一个inerval,在队列里只会有一个。

当然,这个地方执行三次 setInterval 也会有三次执行,不过就有了三个定时器了。
setInterval(()=>{
    console.log('执行')
}, 500)
alert(1)

requestAnimationFrame:可以通过模拟实现 setTimeout,setInterval

最终的代码实现通过还是通过 setTimeout 来实现的。

    let time
    heartbeatTimer () {
        time = setTimeout(() => {
        clearTimeout(time)
        // 开启下一次心跳
        heartbeatTimer()
        // 心跳接口请求
        heartbeatTimerApi()
      }, 10000)
    }

出现的问题

通过 setTimeout 后发自行测试的时候是实现了间隔10秒一次的心跳接口请求,但是测试人员发现,在线状态会在自己未做状态更改的时候被后台返回的 online 值给离线了。

解决问题

起初是从 Event Loop 角度出发,觉得可能是主应用可能是执行比较吃性能的任务,或者一直在页面上进行操作,导致事件循环中一直有同步代码压入调用栈,从而一直无法让消息队列的任务进入到调用栈执行。

后续发现页面未有任何操作的情况下也被后端下线了, 原因还是因为心跳保持59秒都没有发起请求的情况导致了后台认为当前工作人员以离线。

和测试的沟通描述到一个场景就是出于在线的工作人员状态的时候, 有时候会将浏览器先收起来最小化,或者切换到别的页签的场景。

配合浏览器任务管理器 + 描述的场景 = 结论

设定了定时器的页面被后台的时候(浏览器最小化, 页签不被激活),随着浏览器的本身的线程调度策略降低,导致页面上的定时器执行情况也被降低了,导致心跳接口直接变成近分钟才会执行一次,后台刚好50秒间隔扫描一次,_(:з」∠)_ 这个运气也是没谁了。

let time = Date.now()
setInterval(() => {
    console.log('距离上一次执行相差', Date.now() - time)
    time = Date.now()
}, 500)

let time = Date.now()
function test() {
    timer = setTimeout(() => {
        test()
        const tempTime = Date.now()
        console.log('距离上一次执行的时间差:', tempTime - time)
        time = tempTime
    }, 500)
}
test()

诶, 要是我们通过 requestAnimationFrame 模拟 setTimeout 和 setInterval 呢 ?_(:з」∠)_

class RAF {
  constructor () {
    this.init()
  }
  init () {
    this._timerIdMap = {
      timeout: {},
      interval: {}
    }
  }
  run (type = 'interval', cb, interval = 16.7) {
    const now = Date.now
    let stime = now()
    let etime = stime
    //创建Symbol类型作为key值,保证返回值的唯一性,用于清除定时器使用
    const timerSymbol = Symbol()
    const loop = () => {
      this.setIdMap(timerSymbol, type, loop)
      etime = now()
      if (etime - stime >= interval) {
        if (type === 'interval') {
          stime = now()
          etime = stime
        }
        cb()
        type === 'timeout' && this.clearTimeout(timerSymbol)
      }
    }
    this.setIdMap(timerSymbol, type, loop)
    return timerSymbol // 返回Symbol保证每次调用setTimeout/setInterval返回值的唯一性
  }
  setIdMap (timerSymbol, type, loop) {
    const id = requestAnimationFrame(loop)
    this._timerIdMap[type][timerSymbol]= id
  }
  setTimeout (cb, interval) {  // 实现setTimeout 功能
    return this.run('timeout', cb, interval)
  }
  clearTimeout (timer) {
    cancelAnimationFrame(this._timerIdMap.timeout[timer])
  }
  setInterval (cb, interval) { // 实现setInterval功能
    return this.run('interval', cb, interval)
  }
  clearInterval (timer) {
    cancelAnimationFrame(this._timerIdMap.interval[timer])
  }
}




var raf = new RAF()
var timer1 = raf.setInterval(() =>{
  console.log(1000)
}, 1000)

var timer2 = raf.setInterval(() =>{
  console.log(1500)
}, 1500)

raf.setTimeout(() => {
  raf.clearInterval(timer1)
  raf.clearInterval(timer2)
}, 6000)

引用的原址: https://juejin.cn/post/6999444668089892901#heading-11

然后出现了更离谱的事,通过模拟的定时器,在页面切后台等操作的时候直接不会执行 233,

为什么? 因为切换到其他页面的时候,requestAnimationFrame 获取不到浏览器更新的频率,回调函数也就不执行了。

解决办法

通过 web Worker 来新开辟一条线程来执行定时器任务,这个时候选择 setTimeout,setInterval 就都是可以的了

不过 webWorker 必须与其创建者同源

之前是尝试过 worker.js 文件放置于项目的public文件夹打包的时候以静态方式来去读取,不过在线上环境,public文件下的文件都被上传到oss服务器去了,存在跨域,与非同源了都一个问题。放目录下又被webpack给编译掉了文件名称。

为了解决此问题,通过 Blob 将代码段生成临时的JS文件来进行访问,就避免了上述的问题。

function createWorker (f) {
  var blob = new Blob(['(' + f.toString() + ')()'])
  var url = window.URL.createObjectURL(blob)
  var worker = new Worker(url)
  return worker
}

createWorker(()=>{
    let time = Date.now()
    setInterval(() => {
        const tempTime = Date.now()
        console.log('差值:', tempTime - time)
        time = tempTime
    }, 500)
})

因此,以后大家在使用到前端需要轮训到定时任务到时候,推荐使用 worker 来实现,不用担心 Event Loop 的队列执行问题,也不用担心浏览器线程调度的问题。

小知识 _(:з」∠)_

教大家怎么在控制台辨别当前网页是否开启了子线程

如果启动的子线程有请求的话,在 Network 面板下 请求也会携带一个线程图标在前面