从一次移动端定时器卡死分析JS定时器机制

657 阅读3分钟

一、问题背景

某 H5 页面倒计时(最初使用 setInterval 实现)在安卓端(尤其是内存有限的机型上)出现了卡顿的现象:

  • 页面滚动到一定位置后,定时器执行时机变得不稳定(不再是每秒一次)
  • 一段时间不操作(页面没有切到后台),定时器停止(触碰页面任意位置,定时器恢复)
  • 连带导致在 App 中打开一段时间会造成 App 崩溃

二、原因分析

经过搜索比对,和我们遇到的情况最相似的情形是页面切到后台后定时器停止,但没有找到完全一致的情况,即页面在前台,无操作的情况下定时器卡死。推测可能的原因有两个,但无法完全确认:

  1. 浏览器将未操作的页面判定为切到后台,因此定时器停止执行
  2. setInterval 长时间执行后内存占用过高(CPU 占用率超过 1%

三、解决方案:requestAnimationFrame

基本实现

window.requestAnimationFrame(简称 rAF)是浏览器执行动画的 API,它接收一个回调函数,并将在浏览器下次重绘之前调用回调函数更新动画。

执行时机示意图

回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配

网上普遍的使用 requestAnimationFrame 实现的倒计时代码如下:

setMyInterval(options) {
    const requestAnimationFrame = window.requestAnimationFrame ||
      window.mozRequestAnimationFrame ||
      window.webkitRequestAnimationFrame ||
      window.msRequestAnimationFrame
    let i = 1
    let count = 1
    options.timer = options.timer || null
    const loop = () => {
        options.timer = requestAnimationFrame(loop)
        if(i % 60 === 0){
            // 此处 60 代表屏幕刷新率为60HZ,即代表此处每隔一秒执行一次
            let timeout = options.timeout || 1000
            if(count % parseInt(timeout / 1000) === 0){
                // 每隔多少秒调用一次回调函数,此处timeout应为1000的倍数
                options.callback && options.callback()
            }
            count++
        }
        i++
    }
    options.timer = requestAnimationFrame(loop)
}

但这里有几个问题:

  1. 计时与屏幕刷新率挂钩,在刷新率不为 60hz 的屏幕上表现不佳
  2. 传参格式和 setInterval 不同
  3. 借用外部变量保存 requestAnimationFrame 的返回值,不便于取消定时器

封装优化

改进后代码如下,使用闭包保存控制执行的变量,并返回清理执行的函数:

function $animationInterval(callback, delay) {
  let timer
  let $start = Date.now()
  let flag = true
  function fn() {
    const loop = () => {
      if (flag) {
        const now = Date.now()
        if (now - $start > delay) {
          callback && callback()
          $start = now
        }
        timer = requestAnimationFrame(loop)
      }
    }
    timer = requestAnimationFrame(loop)
  }
  fn()
  return () => {
    flag = false
    cancelAnimationFrame(timer)
  }
}

使用如下:

mounted() {
  this.clearCountdown = $animationInterval(() => {
    const diffTime = dayjs.duration(dayjs().endOf('day') - dayjs())
    const hours = diffTime.hours()
    const minutes = diffTime.minutes()
    const seconds = diffTime.seconds()
    this.countdown = [first, second, third]
  }, 1000)
},

beforeDestroy() {
  this.clearCountdown()
}

四、扩展

1. setTimeout 和 setInterval 的区别

要实现固定间隔执行一次回调可以使用 setInterval 也可以使用递归的 setTimeout,但 setTimeout 可以更加准确的控制两次执行之间的间隔。 因为 setInterval 的任务执行本身会花费一段时间,而这段时间是计入两次任务之间的间隔的。因此实际的间隔将比传入的时间要短。当任务耗时过大时,可能会出现连续执行的情况

定时器的 delay,指的是何时将任务推入任务队列,而不是何时进行执行。因此延时并不代表实际执行时间。

使用 setTimeout 会在时间到来时直接将任务推入任务队列,而 setInterval 会检查上一个任务是否还在任务队列中,如果在则不会推入。因此可能会出现某次任务被跳过的情况

如我们模拟一个耗时任务的场景:

let flag = 0
function bigTask() {
  flag++
  console.log(
    `${new Date().getMinutes()}:${new Date().getSeconds()}执行第${flag}次`
  )
  for (let i = 0; i < 10000000000; i++) {}
}
setInterval(bigTask, 1000)

执行结果如下:

34:40执行第1次
34:49执行第2次
34:58执行第3次

2. Chrome 对定时器的节流

chainCount:setTimeout 递归的次数或 setInterval 执行的次数

最小化的节流

如果页面满足以下两个条件:

  1. 页面可见
  2. 页面在过去 30s 内产生过声音(静音的音频不算)

页面只会在同时满足以下两种情况被节流:

  1. 定时器间隔小于 4ms
  2. chainCount > 100

普通节流

  1. chainCount < 100
  2. 页面被隐藏,但不超过 5min
  3. 在使用 WebRTC

浏览器将会每秒检查一次这部分的定时器,并统一执行

密集节流

Chrome88 推出

  1. 页面被隐藏超过 5min
  2. chainCount > 100
  3. 过去 30s 没有发出过声音
  4. 没有使用 WebRTC

浏览器将会每分钟检查一次这部分的定时器,并统一执行

参考: