setTimeout/setInterval 计时器页面失活状态下放慢工作

1,211 阅读5分钟

问题背景

在我们实现页面交互时,时常需要通过 「延迟执行」 来实现一些特定的功能。

比如使用 setTimeout 延迟执行渲染,来实现 「逐字输出」 效果。

如下代码示例:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
  </head>
  <body>
    <div id="text"></div>

    <script>
      const textElement = document.getElementById("text");
      const text =
        "春天,我从来没有强烈的感觉到春天在去年。有人说春天应该是一个快乐的季节。但我从来没有感觉到。我一直喜欢秋天,因为我认为秋天是一个浪漫的季节。我很喜欢夏天,因为我很年轻,我喜欢我的裙子和花边,现在,我还喜欢秋天和夏天,而我喜欢春天和冬天。以前我不喜欢各种颜色的花朵,我认为他们是不负责任的和肤浅的。我认为只有蓝色的海洋是深的,金色的秋天是优雅的。然而,现在我有一个不同的想法,我觉得春天美好。我喜欢在现场和在山的花。从他们身上我有生命的精神。";
      let index = 0;

      let now = Date.now();
      function typeWriter() {
        if (index < text.length) {
          textElement.textContent += text.charAt(index);
          index++;
          console.log(index, Date.now() - now); // 统计计时器执行时间
          now = Date.now();
          setTimeout(typeWriter, 100);
        }
      }
      typeWriter();
    </script>
  </body>
</html>

这段 setTimeout 计时器代码,在你正常浏览「当前页面」时,运行上不会有问题。

一旦你切换到其他 Tab 页,或是其他类似操作致使「当前页面」不在电脑屏幕的可视区域时,

你会发现:刚才页面进行中的 setTimeout 停止了工作(休眠),准确来说是放慢了工作,当你设定计时器延迟时间为 100ms 时,它会放慢到 1000ms 来工作。只有在你重新切回页面后,计时器延迟时间才会恢复正常。

image.png

这其实与浏览器的优化机制(省电策略)有关:setTimeout()setInterval() 以及 requestAnimationFrame() 在浏览器窗口 「非激活」 的状态下会停止工作或者以极慢的速度工作

有一个 requestIdleCallback(),在浏览器窗口 「非激活」 的状态下不会停止工作,但是它没办法像 setTimeout 那样能够手动控制延迟时间。

但有时候这个优化并不是我们想要的,它会限制我们程序代码的正常执行。

解决方案

为了让浏览器窗口在非激活状态(或者最小化)下 计时器 有效不休眠,可以用 HTML5 的新特性:Web Workers 来解决。

Web Workers 是 HTML5 提供的一个 JavaScript 多线程解决方案,可以将一些大计算量的代码交由 Web Workers 运行而不冻结、阻塞用户界面。

基于 Web Workers 的特性,我们将 计时器 的执行放入 worker 子线程中,主线程只用不断接收,子线程在延迟时间过期后推送的通知就好了。

下面我们一起来看看具体的实现。

1、定义 Worker.js

首先,我们定义一个 delayWorker.js 文件,作为开启子线程要执行的文件。计时器在这里注册,当延迟时间过期后,会向外推送消息,来执行对应的 callback 延迟函数。

同时,也支持计时器清除的实现。

// delayWorker.js

// 存储 setTimeout 返回值 timeoutID 的集合,用于计时器清除
const setTimeoutIdsMap = new Map();

onmessage = function (evt) {
  console.log("delayWorker.js 接收到用户传递的 data: ", evt);
  const { action, id, timeout } = evt.data;

  // 创建计时器
  if (action === "setTimeout") {
    // 在 worker 中开启计时器代码
    const timeoutID = setTimeout(() => {
      postMessage(id);
      setTimeoutIdsMap.delete(id);
    }, timeout);
    setTimeoutIdsMap.set(id, timeoutID);
  }
  
  // 清除计时器
  else if (action === "clearTimeout") {
    const timeoutID = setTimeoutIdsMap.get(id);
    if (timeoutID !== undefined) {
      clearTimeout(timeoutID);
      setTimeoutIdsMap.delete(id);
    }
  }
};

2、实现计时器方法

接着,我们封装两个方法:

  • setTimeoutWork() 代替 setTimeout,它的用法与 setTimeout 一致;
  • clearTimeoutWork() 代替 clearTimeout,它的用法与 clearTimeout 一致。
// setTimeoutWork.js

// 引入 work.js
const worker = new Worker("delayWorker.js");
// 收集计时器回调
const worksMap = new Map();
// 模拟 setTimeout 的返回值 timeoutID,从 1 开始,可用于取消该定时器。‌
let timeoutID = 1;

worker.onmessage = function (evt) {
  console.log("delayWorker.js 推送过来的 data: ", evt);
  const id = evt.data;
  if (worksMap.has(id)) {
    const { callback } = worksMap.get(id);
    callback(); // 执行回调
    worksMap.delete(id);
  }
};

function setTimeoutWork(callback, timeout) {
  // 为 work 提供一个唯一 id
  const id = timeoutID++;
  // 保存 id 与 callback 的关系
  worksMap.set(id, { callback, timeout });
  worker.postMessage({ action: "setTimeout", id, timeout }); // 向 worker 发送数据
  return id; // 返回 id,可用于清除计时器
}

function clearTimeoutWork(id) {
  if (worksMap.has(id)) {
    worksMap.delete(id);
    worker.postMessage({ action: "clearTimeout", id });
  }
}

setTimeoutWork 的执行流程分析:

  1. 在使用 setTimeoutWork 时传入 callback 延迟回调函数 和 timeout 延迟时间;
  2. setTimeoutWork 为延迟任务生成一个唯一 id,并将 callback 存放到 Map 集合中;
  3. 接着向 Web Worker 子线程推送消息,在子线程中开启计时器代码的执行;
  4. 子线程会在计时器延迟时间达到以后,推送通知,主线程 work.onmessage 会接收到消息;
  5. 最后,通过唯一 idMap 集合中找到 callback 执行回调函数。

3、使用示例

最后,上面的 「逐字输出」 示例可以改成 setTimeoutWork 去执行:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
  </head>
  <body>
    <div id="text"></div>

    <script src="./setTimeoutWork.js"></script>
    <script>
      const textElement = document.getElementById("text");
      const text =
        "春天,我从来没有强烈的感觉到春天在去年。有人说春天应该是一个快乐的季节。但我从来没有感觉到。我一直喜欢秋天,因为我认为秋天是一个浪漫的季节。我很喜欢夏天,因为我很年轻,我喜欢我的裙子和花边,现在,我还喜欢秋天和夏天,而我喜欢春天和冬天。以前我不喜欢各种颜色的花朵,我认为他们是不负责任的和肤浅的。我认为只有蓝色的海洋是深的,金色的秋天是优雅的。然而,现在我有一个不同的想法,我觉得春天美好。我喜欢在现场和在山的花。从他们身上我有生命的精神。";
      let index = 0;
      
      function typeWriter() {
        if (index < text.length) {
          textElement.textContent += text.charAt(index);
          index++;
          setTimeoutWork(typeWriter, 100);
        }
      }
      typeWriter();
    </script>
  </body>
</html>