面试官:设计一个程序,模拟红绿灯切换

1,436 阅读5分钟

前言

这是一道经常考的面试题。在实际业务开发中,也时常遇见类似的场景,至此做一个总结

应用场景

  • 轮询,站内消息
  • 时间线任务,时钟,每日任务线
  • 前端监控数据上报
  • 扫码登录
  • 倒计时

实现方式

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • 时间线,diffTime

我们这里主要讲解使用时间线,diffTime的方式

为什么不用setTimeout和setInterval?

因为setTimeout和setInterval不准时,无法精确计算

1、通过setTimeout实现

const serial = ['Red', 'Yellow', 'Green']
const times = [3, 10, 6]

function delay(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true)
    }, time);
  })
}
async function run() {
  this.count = 0
  while (1) {
    const index = this.count % serial.length
    const time = times[index]
    const single = serial[index]
    await delay(time * 1000)
    console.log('当前信号为:', single)
    this.count++
  }
}
run()

通过延时函数,while循环+ 异步函数同步调用实现,加入count计数

效果为:

Image.png

2、通过setInterval实现

const serial = ['Red', 'Yellow', 'Green']
const times = [3, 10, 6]

class TimerClass {
  count = 0
  singleTime = times[0]
  single = serial[0]
  timer = null
  singleRemainder = 0

  constructor() {
    this.setTImer()
  }

  // 切换信号
  changeSingle() {
    this.timer && clearInterval(this.timer)
    this.timer = null
    const index = this.count % serial.length
    const time = times[index]
    const single = serial[index]
    this.count++
    this.single = single
    this.singleRemainder = time
    return { single, time }
  }

  // 设置定时器
  setTImer() {
    const that = this
    if (!this.timer) {
      that.timer = setInterval(() => {
        console.log('剩余时间', that.singleRemainder)
        if (that.singleRemainder <= 0) {
          const { single, time } = that.changeSingle()
          console.log('切换信号====>', single, time)
          that.setTImer()
        }
        that.singleRemainder--
      }, 1000);
    } else {
      that.changeSingle()
    }
  }
}

const timer = new TimerClass()

效果为:

image.png

1、setTimeout 和 serInterval 的区别
  • serInterval :  定时器指定的时间间隔,表示的是何时将定时器的代码添加到消息队列,而不是何时执行代码。所以真正何时执行代码的时间是不能保证的,取决于何时被主线程的事件循环取到,并执行。如果执行代码执行时长超出下一个setInterval时间间隔,那么下一个setInterval 将会被忽略

setInterval有两个缺点:

  1. 使用setInterval时,某些间隔会被跳过;
  2. 可能多个定时器会连续执行;
  • setTimeout:每个setTimeout产生的任务会直接push到任务队列中

1、当页面长时间休眠或浏览器处于非活动状态时,‌setTimeout的定时器会暂停执行,‌这是为了减少浏览器的缓存占用以及电脑的内存占用。‌当页面被重新激活后,‌定时器会被同步激活,‌从而继续执行。‌但这种执行是基于定时器休眠之前的参数继续执行,‌而不是以最新的环境参数执行,‌这可能会导致一些问题1。‌此外,‌在未激活的页面中,‌浏览器通常会对定时器的执行间隔进行调整,‌以降低对系统资源的占用。‌在大多数浏览器中,‌未激活的页面中的setTimeout最小间隔通常被调整为1000毫秒(‌1秒)‌,‌这意味着如果你在未激活的页面中使用setTimeout,‌设置的时间间隔小于1000毫秒时,‌浏览器可能会延长执行时间间隔,

2、最小延迟时间:‌在浏览器中,‌setTimeout()/setInterval()的每调用一次定时器的最小间隔是4ms,‌这通常是由于函数嵌套导致或已经执行的setInterval的回调函数阻塞导致的。‌

3、系统负载:‌当系统负载较重时,‌事件循环可能会出现延迟,‌导致setTimeout的回调函数执行的时间比预期的要晚。‌

4、睡眠模式:‌在某些设备上,‌当设备进入睡眠模式时,‌定时器可能会暂停,‌直到设备被唤醒。‌这会导致setTimeout的回调函数执行时间延迟。‌

2、解决计算时间不精确的方案
  • 使用Web Workers:‌将计时任务移至Web Workers中执行,‌避免与主线程的其他代码竞争,‌提高定时器的准确性。‌
  • 使用时间戳:‌使用时间戳来进行倒计时和计算,‌可以更精确地控制时间。‌
  • 手动调整:‌在每次定时器触发后,‌通过记录实际执行时间并与预期执行时间进行比较,‌计算误差并进行手动调整。‌
  • 使用精确计时库,‌如requestAnimationFrame来实现准确的定时任务。‌

使用时间线计时

// 红黄绿灯

const serial = ['Red', 'Yellow', 'Green']

function delay(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true)
    }, time);
  })
}

class Single {
  constructor(options) {
    this.sig = options.init
    this.times = options.times

    this.setTime()
    this.exchange()
  }

  // 时间计数, 时间线
  setTime() {
    this.start = Date.now()
    const time = this.times[serial.indexOf(this.sig)]
    this.end = this.start + time * 1000
  }

  // 下一个信号
  get next() {
    return serial[(serial.indexOf(this.sig) + 1) % serial.length]
  }

  // 剩余时间
  get remain() {
    let diff = this.end - Date.now()
    if (diff <= 0) {
      diff = 0
    }
    return diff / 1000
  }

  // 切换红绿灯
  async exchange() {
    if (this.remain > 0) {
      // 存在剩余时间不切换
      console.log(`当期信号灯【${this.sig}】,剩余时间【${Math.round(this.remain)}】`)
      // 我们希望每一秒钟提示一次
      await delay(1 * 1000)
    } else {
      this.sig = this.next
      this.setTime()
      console.log(`切换信号灯【${this.sig}】`)
    }
    this.exchange()
  }
}


const s = new Single({
  init: 'Green',
  times: [10, 3, 5]
})

我们可以实时获取,当前信号以及剩余时间

效果为:

Image.png

加入发布订阅模式/停止/重启

// 红黄绿灯
// 红黄绿灯
const serial = ['Red', 'Yellow', 'Green']

function delay(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true)
    }, time);
  })
}

class Single {
  // 是否立即停止
  isStop = false
  constructor(options) {
    this.sig = options.init
    this.times = options.times

    // 发布订阅模式
    this.eventMap = new Map()
    this.eventMap.set('tick', new Set([]))
    this.eventMap.set('change', new Set([]))

    this.setTime()
    this.exchange()
  }

  on(event, handler) {
    this.eventMap.get(event).add(handler)
  }

  off(event, handler) {
    this.eventMap.get(event).delete(handler)
  }

  emit(event) {
    this.eventMap.get(event).forEach(h => {
      h.call(this, this)
    });
  }

  // 时间计数, 时间线
  setTime() {
    this.start = Date.now()
    const time = this.times[serial.indexOf(this.sig)]
    this.end = this.start + time * 1000
  }

  // 下一个信号
  get next() {
    return serial[(serial.indexOf(this.sig) + 1) % serial.length]
  }

  // 剩余时间
  get remain() {
    let diff = this.end - Date.now()
    if (diff <= 0) {
      diff = 0
    }
    return diff / 1000
  }

  // 切换红绿灯
  async exchange() {
    if (!this.isStop) {
      if (this.remain > 0) {
        // 存在剩余时间不切换
        // console.log(`当期信号灯【${this.sig}】,剩余时间【${Math.round(this.remain)}】`)
        this.emit('tick')
        // 我们希望每一秒钟提示一次
        await delay(1 * 1000)
      } else {
        this.sig = this.next
        this.setTime()
        console.log(`切换信号灯【${this.sig}】`)
        this.emit('change')
      }
      this.exchange()
    }
  }

  // 停止
  stop() {
    console.log('停止循环=====>')
    this.isStop = true
  }

  // 重启
  reStart() {
    console.log('重启=====>')
    this.isStop = false
    this.exchange()
  }
}


const s = new Single({
  init: 'Green',
  times: [10, 3, 5]
})

const tickHandler = (e) => {
  console.log('tickHandler=====>', e.sig, e.next, e.remain)
}

const changeHandler = (e) => {
  console.log('changeHandler=====>', e.sig, e.next, e.remain)
}

s.on('change', changeHandler)
s.on('tick', tickHandler)

setTimeout(() => {
  s.stop()
}, 15000);

setTimeout(() => {
  s.reStart()
}, 20000);

效果为:

Image.png

【注意】当在使用的时候,stop方法停止循环可以在界面退出,息屏,app退出时使用;进入界面时,可以使用reStart方法重启,进入时重新计算时差

setTimeout/setInterval、requestAnimationFrame 三者的区别:

  • 引擎层面: setTimeout/setInterval 属于 JS引擎,requestAnimationFrame 属于 GUI引擎;JS引擎与GUI引擎是互斥的,也就是说 GUI 引擎在渲染时会阻塞 JS 引擎的计算
  • 时间是否准确:requestAnimationFrame 刷新频率是固定且准确的,但 setTimeout/setInterval 是宏任务,根据事件轮询机制,其他任务会阻塞或延迟js任务的执行,会出现定时器不准的情况
  • 当页面被隐藏或最小化时,setTimeout/setInterval 定时器仍会在后台执行动画任务,而使用 requestAnimationFrame 当页面处于未激活的状态下,屏幕刷新任务会被系统暂停

参考文档

完结撒花...