# JS 定时器时长控制细节

# 背景

JS 最常使用 setTimeoutsetInterval 来延迟或定时循环执行函数,通常会传递第二个参数来控制延迟或间隔执行的时间。

但开发者必须意识到函数执行时间并非精确地符合预期,在以下场景中它会超出你的预期

  1. CPU 繁忙(主线程被长时间占用),JS 无法按开发者设定的预期时间延迟函数
  2. 定时器过于频繁地执行(第二个参数 < 4),达到一定条件后浏览器(或说 JS 执行引擎)会限制定时器执行频率
  3. 页面处于后台,浏览器为了降低 CPU 资源占用、电池消耗,会主动限制定时器的执行频率
    • 后文会介绍如何在必要的时候绕过这个限制

总结以上的特征,定时器在你进行简单主动地检测时它往往符合预期,但经常会悄悄地偏离你的预期。
因为简单检测时,页面肯定处于前台,而且不会达到限制条件;你可以复制后文中设计好的代码来检测定时器的延迟特性。

# CPU 繁忙

const t = performance.now()
setTimeout(() => {
  // 期望 100ms 后执行,实际因为 while 占用主线程,将在 1s 后执行
  console.log(performance.now() - t)
}, 100)
while(performance.now() - t < 1000){}

# 最小延迟时长 >= 4ms

在浏览器中,由于函数嵌套(嵌套层级达到一定深度),或者是由于已经执行的 setInterval 的回调函数阻塞导致 setTimeout()/setInterval() 的每调用一次定时器的最小间隔是 4ms。

尝试在不同浏览器分别执行以下两段代码,观察打印的间隔值。
前几次(不同浏览器有差异)间隔差不多1ms,后续间隔时长都大于4ms。

let t = performance.now()
setInterval(() => {
  console.log(performance.now() - t)
  t = performance.now()
}, 1)
// setTimeout 构建循环
let t = performance.now()
function loop() {
  console.log(performance.now() - t)
  t = performance.now()

  setTimeout(loop, 1)
}
loop()

也就是说:小于 4ms 的异步循环任务 期望的延迟时间是不靠谱的。
如果有小于 4ms 的异步循环任务,得小心了,这个限制无法绕过

# 后台页面 最小延迟 >= 1000ms

为了优化后台 tab 的加载损耗(以及降低耗电量),在未被激活的 tab 中定时器的最小延时限制为 1000ms。

这经常导致一些看似正常工作的程序,后台运行一段时间后就出现 bug 了。
甚至导致需要常驻后台的主要功能无法正常工作。

解决方法:使用 WebWorker 中的 setInterval 向主线程发送消息(postmessage),主线程会立即执行。
理解浏览器限制的原因,如非必要不要随意使用该方法

# 基于 WebWorker 的后台定时器

有了解决思路,但每次碰到需要后台的定时器,就创建一个WebWorker也不合适。

一个极简、可以在后台页面定时运行任务的工具函数实现。
提供简单的API,支持在有任务时自动启动定时器,无任务自动终止。

展开看源码
const setup = (): void => {
  let timerId: number

  let interval: number = 16.6

  self.onmessage = (e) => {
    if (e.data.event === 'start') {
      self.clearInterval(timerId)
      timerId = self.setInterval(() => {
        self.postMessage({})
      }, interval)
    }

    if (e.data.event === 'stop') {
      self.clearInterval(timerId)
    }
  }
}

const createWorker = (): Worker => {
  const blob = new Blob([`(${setup.toString()})()`])
  const url = URL.createObjectURL(blob)
  return new Worker(url)
}

const handlerMap = new Map<number, Set<() => void>>()
let runCount = 1

const worker = createWorker()
worker.onmessage = () => {
  runCount += 1
  for (const [k, v] of handlerMap.entries()) {
    if (runCount % k === 0) {
      v.forEach(fn => fn())
    }
  }
}
/**
 * 16.6ms 执行一次回调
 * 解决页面后台时,定时器不(或延迟)执行的问题
 */
export const timer16ByWorker = (handler: () => void, time = 1): () => void => {
  const fns = handlerMap.get(time) ?? new Set()
  fns.add(handler)
  handlerMap.set(time, fns)

  if (handlerMap.size === 1 && fns.size === 1) {
    worker.postMessage({ event: 'start' })
  }

  return () => {
    fns.delete(handler)
    if (fns.size === 0) handlerMap.delete(time)
    if (handlerMap.size === 0) {
      runCount = 0
      worker.postMessage({ event: 'stop' })
    }
  }
}

使用示例

const stopTimer = timer16ByWorker(() => {
  // 如果期望 setTimeout 的效果,只要执行一次,可以在首次执行时stopTimer
  // 不执行 stopTimer 则类似 setInterval
  // stopTimer() 

  // do sth
}, 1) // 间隔 1 * 16.6ms 执行一次回调

// 终止循环任务
// stopTimer()

选择 16.6ms 作为基础间隔时长的原因

  • 大约是普通情况下(60FPS)浏览器每帧的间隔时间,方便执行跟渲染相关的任务
  • 方便计算间隔时长,比如 5s 一次的循环任务 timer16ByWorker(() => {}, 5 * 60)

有其他时间间隔的需要也可以修改 let interval = 16.6 的值。
注意参考前文,值不能小于 4ms

# 附录

💗 博主正处于裸辞待业状态,欢迎 商务合作 💗

相关文章

从 React 看前端 UI 代码范式革命

alt text 前言 本来打算写的主题是“我为什么讨厌 React Hooks API”,展开聊聊“小甜甜”是如何变成“牛夫人”的,没想到越写越严肃:) React 是两次前端范式革命的引领者,至今仍有繁荣的社区和旺盛的创造力; React 多次天才又激进的创新,一些想法被借鉴改良、一些引发广泛质疑,大部分是被认同和接受的; ...

一句话总结:TS 何时选择 interface 或 type

用 interface 描述类型的结构,用 type 描述类型关系。 有点编程基础中数据结构与算法的味道。 结构即是类型的属性集合 // 如 Point3D 的属性集合: x, y, z。 interface Poi ...

JS 多线程并发

[[toc]] 为什么需要并发 我们常听说 JS 是单线程模型,即所有代码都在主线程中执行的。 如果某些任务计算量较大,将阻塞主线程,UI 界面轻则掉帧、重则卡死。 // 提示:本文所有代理均可复制到浏览器控制台中执行,验证效果 // ...

系统化学习 TS 类型系统

目的:快速、系统性的入门 TS 类型系统 [[toc]] 前言 TS 是什么? TS 是 JS 的超集, TS = JS + 类型系统 为了描述如此复杂(由于 JS 语言的灵活性/复杂性)的类型信息,类型系统表现出非常明显的编程语言特性。 以学习编程语言的方式,来学习 TS 类型系统 关键字/符号 类型: boolean, number, stri ...

跨域(Options)请求介绍及解决方法

介绍 OPTIONS请求指method为OPTIONS的http请求。 通俗来说:它的作用是用于WEB服务器是否支持某些 header,也可以叫做预检请求(顾名思义:预先检测)。 程序员:跨域发送 http get { headers { xxx: abc } } 浏览器:等等,你这个请求有点奇怪,我去跟服务器确认下 浏览器:发送 http options ...

单测(Unit Test)技巧

前言 本文目的是提高编写单测的效率,适合于有一定单测编写经验,但被单测困扰的同学。 后文的示例都在 unit-test-examples 仓库中。 单测的意义与价值 单测本质:将测试行为及结果固化下来,自动检查被测试代码的运行结果是否符合期望。 单测是一 ...

compilerOptions字段详解

{ "compilerOptions": { /* 基本选项 */ "target": "es5", // 指定 ECMAScript 目标版本: 'ES3' (default), 'ES5', 'ES6'/'ES2015', 'ES2016', 'ES2017', or 'ESNEXT' "module": ...

JS 正则表达式基础

前言 个人经验,正则是一个前期少量投入,回报超高的技能点。 其适用范围非常广泛,如批量文本处理、源码替换、程序中逻辑判断等等。 本文只介绍常用的基础知识、技巧,让初学者快速掌握大部分日常所需的正则知识。目标是 5 分钟内可逐字读完,10 分钟内可把例子都动手实践一 ...

JS优缺点

回顾上两期 优点(简单) 对象 链(原型链 & 作用域链) 一切都是对象(包括函数),构建世界的原料,越少越简单、灵活。 jimu 观察者模式,例: class Observer { constructor() { this.subscribe ...

JS 数据处理

[[toc]] 分享目标 JS 数据处理技巧速成 让你感叹:JS 还可以这样写 基础 实践原则 数据处理时,尽量避免创建临时变量(特别是 let)、修改参数、改变外部引用、for、if 等 分离 数据处理 与 副作用(DOM 操作、存储、网络请求等)代码 数据处理指:数值计算和数据结构变换。 第一点:如何避免? **1. 熟 ...