业务上有这样一个需求:「若用户不活跃超过 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;
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.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); }; }, []); };
|
上面的代码做了这些事情:
- 当用户进入到页面时,在 SessionStorage 存储了应当执行业务需求的时间戳。
- 启动一个定时器,在指定时间以后执行业务需求。
- 启动一个监听器,当页面可见性发生改变,变为「可见」时,校准定时器:清除已有的定时器,然后启动一个新的定时器,在新的指定时间以后执行业务需求。其中,新的指定时间由存储的时间戳和当前的时间计算得来。
使用 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) => {
if (timer) { clearTimeout(timer); }
timer = setTimeout(() => { self.postMessage(`Time to leave page ${new Date()}`); }, event.data); };
|
如果您的项目基于 Webpack 4.x,那么需要配置 worker-loader
或 worker-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.postMessage(12 * 60 * 60 * 1000);
worker.onmessage = (event) => { console.log("Received message from worker thread", event.data); leavePage(); worker.terminate(); };
return () => { worker.terminate(); }; }, []); };
|
对于 Webpack 5.x 以上的项目,Webpack 已内置了对 Web Worker 的支持,可查阅文档使用。
动态加载的方式
如果不想在 Webpack 上加 loader 或插件,也可以考虑「动态」地加载脚本文件,这需要一点点小技巧。首先将 Worker 包含的具体内容以字符串的形式导出:
1 2 3 4 5 6 7 8 9
| 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
| 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)/) .use("worker-loader") .loader(require.resolve("@umijs/deps/compiled/worker-loader")) .options(config.workerLoader); }
|
可知 Umi 基于 worker-loader
,将 worker.ts
或 worker.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(() => { const worker: Worker = new LeavePageWorker();
return () => { worker.terminate(); }; }, []); };
|
Worker 的编译和运行均在后台执行,这意味着即使出现报错也不会显式提醒您。您可以随时在开发者工具里找到编译得到的 Worker 的代码:
对于 Umi 3.4.1 以前版本的项目,可以通过 chainWebpack
添加 worker-loader
或 worker-plugin
插件的支持。
Umi 4.x 内置 Webpack 5.x 作为默认 Bundler,因此查阅文档使用即可。
三方库的方式
如果不介意 Web Worker 编写是否原生(笔者从不介意!),更推荐选用封装了 Web Worker 能力的三方库,例如 alewin/useWorker
和 developit/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";
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(); }; }, []); };
|
参考文章