Lolipop's Studio.

定时器 SetTimeout 在后台失效?试试 Web Worker 吧

字数统计: 3.1k阅读时长: 12 min
2022/09/22

业务上有这样一个需求:「若用户不活跃超过 12 个小时,自动退出当前页面,并切换路由到首页」。

想都没想,直接在 useEffect() 里用 setTimeout() 定个时,12 个小时后触发相应跳转事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useEffect } from "react";

const LEAVE_PAGE_COUNTDOWN = 12 * 60 * 60 * 1000; // 12h

/** 离开页面的方法 */
const leavePage = () => {
// ...离开当前页面的业务代码
};

export default () => {
useEffect(() => {
// 初始化时设置定时器
const timer = setTimeout(() => {
leavePage();
}, LEAVE_PAGE_COUNTDOWN);

return () => {
// 页面卸载时清除定时器
if (timer) clearTimeout(timer);
};
}, []);
};

没想到,今天上班来,切换到没有关闭的标签页,发现还在当前页面,掐指一算怎么也有 12 个小时了,这是怎么一回事儿……?

昨天晚上走的时候还在和前辈探讨页面卸载(unload)事件与浏览器后台优化的坑,于是首先就想到了可能是浏览器优化的缘故,导致定时器没有正常执行。以「setTimeout」和「后台失效」为搜索关键词,很快找到了原因和优化解决方案。

失效原因

系现代浏览器为了节能与性能优化做的处理。

若页面处于非激活的状态,那么此页面中通过 setTimeout()setInterval() 创建的定时器可能会停止工作以较慢的速度工作。页面的非激活状态包括不限于:切换到其它标签页、最小化窗口和息屏等。在移动端,这样的性能优化尤为常见。

因此会发生另外一种常见的现象:如果浏览器页面里有一个基于 setInterval() 实现的计时器,当用户切换页面或回到桌面后,计时器将停止计时或计时频率减慢,导致计时功能异常。

基于 SetTimeout / SetInterval 的解决方案

定时器失效带来的最直接影响是:JavaScript 代码不再能够正确获取定时器计划执行的时间已经执行的次数

一个很容易想到的解决方案是,当页面切回前台时,重新校准 SetTimeout 定时器时间。

使用 SetTimeout + 监听 visibilitychange 事件

通过监听窗口的 visibilitychange 事件(兼容性见于此),可以判断页面是否切换到前台:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
window.addEventListener("visibilitychange", () => {
switch (document.visibilityState) {
case "visible":
// 当前页面被切换到前台(可见或部分可见)
break;
case "hidden":
// 当前页面被切换到后台(不可见)
break;
case "prerender":
// 当前页面被预渲染,且用户不可见
break;
case "unloaded":
// 当前页面被卸载
break;
}
});

对于这次业务上遇到的 12 小时自动切换路由这一需求,对即时性和定时器的精度要求并不高,且重新校准的逻辑容易编写,可以码出 React 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import React, { useEffect } from "react";

const LEAVE_PAGE_TIMESTAMP = "__leave_page_timestamp";
const LEAVE_PAGE_COUNTDOWN = 12 * 60 * 60 * 1000;

/** 离开页面的方法 */
const leavePage = () => {
// ...离开当前页面的业务代码
};

/** 获得倒计时时间 */
const getLeavePageCountdown = (): number => {
const timestamp = sessionStorage.getItem(LEAVE_PAGE_TIMESTAMP);
const countdown = timestamp
? Number(timestamp) - new Date().getTime()
: LEAVE_PAGE_COUNTDOWN;
return countdown > 0 ? countdown : 0;
};

/** 获得离开页面定时器 */
const getLeavePageTimeout = (): NodeJS.Timeout => {
return setTimeout(() => leavePage(), getLeavePageCountdown());
};

export default () => {
useEffect(() => {
// 初始化时设置离开页面的时间
sessionStorage.setItem(
LEAVE_PAGE_TIMESTAMP,
String(new Date().getTime() + LEAVE_PAGE_COUNTDOWN)
);

return () => {
// 页面卸载时清除 SessionStorage
sessionStorage.removeItem(LEAVE_PAGE_TIMESTAMP);
};
}, []);

useEffect(() => {
// 初始化时设置定时器
let timer = getLeavePageTimeout();

const onWindowVisibilityChange = () => {
// 重新校准定时器
if (document.visibilityState === "visible") {
// 清除已有定时器
if (timer) clearTimeout(timer);
// 设置新的定时器
timer = getLeavePageTimeout();
}
};
// 添加页面可见性变化监听器
window.addEventListener("visibilitychange", onWindowVisibilityChange);

return () => {
// 页面卸载时清除定时器和监听器
if (timer) clearTimeout(timer);
window.removeEventListener("visibilitychange", onWindowVisibilityChange);
};
}, []);
};

上面的代码做了这些事情:

  1. 当用户进入到页面时,在 SessionStorage 存储了应当执行业务需求的时间戳。
  2. 启动一个定时器,在指定时间以后执行业务需求。
  3. 启动一个监听器,当页面可见性发生改变,变为「可见」时,校准定时器:清除已有的定时器,然后启动一个新的定时器,在新的指定时间以后执行业务需求。其中,新的指定时间由存储的时间戳和当前的时间计算得来。

使用 SetInterval 轮训

哇噻,有够麻烦。换一种思路,使用轮训的实现方式,基于 SetInterval 不断比较当前的时间戳和应当离开页面的时间戳,若当前的时间戳大于应当离开页面的时间戳,执行离开页面的业务方法就好了。这种实现方式无需费力地重新校准时间,是一个讨巧的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { useEffect } from "react";

const LEAVE_PAGE_COUNTDOWN = 12 * 60 * 60 * 1000;

/** 离开页面的方法 */
const leavePage = () => {
// ...离开当前页面的业务代码
};

export default () => {
useEffect(() => {
// 获取执行业务需求的时间戳
const timestamp = new Date().getTime() + LEAVE_PAGE_COUNTDOWN;

// 初始化时设置轮询器
const timer = setInterval(() => {
const now = new Date().getTime();
if (now >= timestamp) {
leavePage();
}
}, 1000);

return () => {
// 页面卸载时清除轮询器
if (timer) clearInterval(timer);
};
}, []);
};

似乎都不太优雅

但是,以上的实现都会导致一些体验上的问题:用户从后台切换到该页面时,若超过了 12 个小时,定时器或轮询器一运行,唰的一下子路由发生改变,用户会感到非常奇怪。

虽然加上一些 Notification 告知刚刚发生了啥会减少用户的不适,但终究我们还是会希望浏览器能完全正常地运行定时器方法(这是我们不想要被后台优化的功能),而不需要做这些带来额外开销且不符合直觉的适配(下一任程序员看到代码就头疼)。

此外,这样的做法并不能满足对准确度要求高的定时器需求。

基于以上需求,本文的主角 Web Worker 给出了现代、普适的解决方案。

生产实践的解决方案:使用 Web Worker

Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面……一个 worker 是使用一个构造函数创建的一个对象运行一个命名的 JavaScript 文件:这个文件包含将在工作线程中运行的代码;workers 运行在另一个全局上下文中,不同于当前的 window。

-- MDN

Web Worker 能够为 JavaScript 创建多线程环境,允许将主线程中的任务分配给 Worker 线程处理,主线程和 Worker 线程之间可以进行通信。当遇到计算密集型或高延迟的任务时,常使用 Web Worker 进行性能优化:在 Worker 线程进行复杂的计算操作,进而避免主线程阻塞或卡死。

Worker 线程一旦创建成功,将始终运行,不会被主线程上的活动中断。但是,这也意味着 Worker 使用完毕后应当立即关闭,避免造成额外的系统开销。

只需要了解 Web Worker 的基本用法,就能很好地实现本次业务上的需求。将 setTimeout() 方法移动到 Worker 中去,只要浏览器不关闭,Worker 将保持运行的状态,在正确的时机向主线程返回离开页面的消息。

Webpack 的方式

首先编写一个 Web Worker 脚本文件 leavePage.worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let timer;

self.onmessage = (event) => {
// console.log("Received message from main thread", event.data);

if (timer) {
clearTimeout(timer);
}

timer = setTimeout(() => {
// 向主线程发出消息
self.postMessage(`Time to leave page ${new Date()}`);
// event.data 即离开页面的倒计时(ms)
}, event.data);
};

如果您的项目基于 Webpack 4.x,那么需要配置 worker-loaderworker-plugin 等 loader 或插件才能通过 new Worker(url) 的方式正常引入 Web Worker。

在 React 代码里读取脚本文件,即可创建 Worker 线程并监听它返回的消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React, { useEffect } from "react";

/** 离开页面的方法 */
const leavePage = () => {
// ...离开当前页面的业务代码
};

export default () => {
useEffect(() => {
// 新建 Worker 线程
// const worker = new Worker("path/to/leavePage.worker.js");

// 向 Worker 线程发出消息,设定 12h 后返回消息
worker.postMessage(12 * 60 * 60 * 1000);

// 监听 Worker 返回的消息
worker.onmessage = (event) => {
// 一旦接收到消息,执行离开页面的业务代码
console.log("Received message from worker thread", event.data);
leavePage();
// 一旦完成响应,关闭 Worker 线程
worker.terminate();
};

return () => {
// 页面卸载时关闭 Worker 线程
worker.terminate();
};
}, []);
};

对于 Webpack 5.x 以上的项目,Webpack 已内置了对 Web Worker 的支持,可查阅文档使用。

动态加载的方式

如果不想在 Webpack 上加 loader 或插件,也可以考虑「动态」地加载脚本文件,这需要一点点小技巧。首先将 Worker 包含的具体内容以字符串的形式导出:

1
2
3
4
5
6
7
8
9
// leavePage.worker.js
const leavePageWorker = `
var timer;
self.onmessage = function (event) {
// ...
};
`;

export default leavePageWorker;

在主线程的代码里导入字符串并创建真正的 Worker 线程:

1
2
3
4
5
6
7
8
import LeavePageWorker from "path/to/leavePage.worker.js";

const loadWebWorker = (code: string): Worker => {
const blob = new Blob(["(" + code + ")()"]);
return new Worker(URL.createObjectURL(blob));
};

const leavePageWorker = loadWebWorker(LeavePageWorker);

需注意的是,使用动态加载的方式意味着 Worker 的代码将不经 Webpack 而直接调用,所以应当使用兼容性更好的「古早 JavaScript 语法」,例如 var function(){} 等。

由于浏览器的 Content Security Policy (CSP) 策略,通过此方法创建 Worker 可能会失败,可以参考此介绍进行解决。

Umi 项目的方式

根据 Umi 文档,对于 Umi 3.4.1+ 的项目,可以进行如下配置启用对 Web Worker 的支持:

1
2
3
4
// config.ts
export default defineConfig({
workerLoader: {},
});

然而 Umi 文档并没有提 Web Worker 的引入方式,不过查阅 Umi 源码发现:

1
2
3
4
5
6
7
8
if (config.workerLoader) {
webpackConfig.module
.rule("worker")
.test(/.*worker.(ts|js)/) // Web Worker 文件命名规则
.use("worker-loader")
.loader(require.resolve("@umijs/deps/compiled/worker-loader"))
.options(config.workerLoader);
}

可知 Umi 基于 worker-loader,将 worker.tsworker.js 结尾的文件当作 Web Worker 处理。那么可以这样编写主线程的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useEffect } from "react";
import LeavePageWorker from "path/to/leavePage.worker.js";

export default () => {
useEffect(() => {
// 新建 Worker 线程
const worker: Worker = new LeavePageWorker();

// 像之前一样监听 worker 事件即可
// ...

return () => {
// 别忘了使用完后关闭 Worker 线程
worker.terminate();
};
}, []);
};

Worker 的编译和运行均在后台执行,这意味着即使出现报错也不会显式提醒您。您可以随时在开发者工具里找到编译得到的 Worker 的代码:

在开发者工具中查看 Worker 源码

对于 Umi 3.4.1 以前版本的项目,可以通过 chainWebpack 添加 worker-loaderworker-plugin 插件的支持。

Umi 4.x 内置 Webpack 5.x 作为默认 Bundler,因此查阅文档使用即可。

三方库的方式

如果不介意 Web Worker 编写是否原生(笔者从不介意!),更推荐选用封装了 Web Worker 能力的三方库,例如 alewin/useWorkerdevelopit/greenlet 等。

它们降低了使用 Web Worker 的心智成本,使得调用 Web Worker 就像编写普通的 async 异步函数一样;重要的是,不必再担心引入 Web Worker 时带来的各种各样的奇怪问题(CDN 部署时,可能发生同源问题)。

alewin/useWorker 为例,可以这样改进前面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { useEffect } from "react";
import { useWorker } from "@koale/useworker";

/**
* 休眠 @timeout 毫秒
*/
const setTimeoutAsync = (timeout: number) => {
return new Promise<void>((resolve) =>
setTimeout(() => {
resolve();
}, timeout)
);
};

export default () => {
const [setTimeoutWorker, { kill: killSetTimeoutWorker }] =
useWorker(setTimeoutAsync);

useEffect(() => {
const runLeavePageWorker = async () => {
await setTimeoutWorker(12 * 60 * 60 * 1000);
// ... 在此处执行离开页面的业务代码
};

runLeavePageWorker();

return () => {
killSetTimeoutWorker();
};
}, []);
};

参考文章

CATALOG
  1. 1. 失效原因
  2. 2. 基于 SetTimeout / SetInterval 的解决方案
    1. 2.1. 使用 SetTimeout + 监听 visibilitychange 事件
    2. 2.2. 使用 SetInterval 轮训
    3. 2.3. 似乎都不太优雅
  3. 3. 生产实践的解决方案:使用 Web Worker
    1. 3.1. Webpack 的方式
    2. 3.2. 动态加载的方式
    3. 3.3. Umi 项目的方式
    4. 3.4. 三方库的方式
  4. 4. 参考文章