封面

定时器不知道你切走了标签页?用 Page Visibility API 解决

原文:https://dev.to/parsajiravand/your-timer-doesnt-know-you-tabbed-away-3cjp(作者 @parsajiravand)

想象一个仪表板上的磁贴每两秒向端点轮询一次,以刷新数字。你切到 Slack 去回复一条消息。四分钟后你还在 Slack 里,磁贴仍在轮询——每两秒一个请求,发往一个你离开后没人再看过的标签页。

再乘以你的用户会在后台开着多少标签页,这就不是一个可忽略的小误差了。它是在为毫无价值的活儿消耗服务器负载和电池电量,因为唯一能阻止它的那条信息——是否真的有人在看——从未被检查过。

看起来很自然的修复方案

直觉做法是在 blur 时暂停,在 focus 时恢复。它看上去很干净:

window.addEventListener("blur", () => clearInterval(pollId));
window.addEventListener("focus", () => { pollId = startPolling(); });


如果照这样发布,迟早会有人报一个奇怪的 bug:一个本应在后台继续播放的视频,在用户点开 DevTools 时暂停了。或者一个实时行情条,在用户打开浏览器扩展弹窗的瞬间停止了更新。或者还有一种——这种很容易被忽略,因为它只在多显示器环境下发生——一个仪表板在副显示器上明明完全可见,却进入了“空闲状态”,因为用户正在主显示器上的另一个应用里打字。

这些标签页都没有被隐藏。内容就明明白白地显示在屏幕上。blur 仍然触发了,因为 blurfocus 所回答的问题,比你真正想问的要窄得多。

你真正想问的问题

blur/focus 告诉你的是窗口是否拥有键盘焦点。对于像“这个输入框是否应该显示焦点环”这类问题,它确实有用——但这不等于“现在是否有真人能看到这个内容”。一个窗口可以在完全渲染且可见的情况下失去焦点;这正是上面副显示器的场景,也是 MDN 官方文档在解释 Page Visibility API 为何存在、而不是直接复用 focus 事件时特别指出的。

回答真正问题的 API 非常简单:

  • document.visibilityState —— 取值为 "visible""hidden"
  • document.hidden —— 同一个值的旧布尔简写。
  • visibilitychange 事件,在状态切换时于 document 上触发。

当标签页被切走、窗口被最小化,或者(在大多数平台上)屏幕锁定时,状态会变成 "hidden"——在这些条件下,就浏览器所能判断的而言,内容肯定不在任何人的视网膜上。而在上面的副显示器场景中,它保持 "visible",因为那确实是可见的。十多年来,这一直是各种现代浏览器中无需前缀的标准 API——不需要再做特性检测。

<!-- playground:start -->

自己试试看

[打开交互式演示 →](https://bestpractic.org/blog/page-visibility-api-tab-aware-timers/playground)

_直接在浏览器中运行——动手戳一戳,看看这个概念如何实时响应。_

<!-- playground:end -->

正确接入

换成正确的监听事件,而不只是换一套说法:

let pollId = null;

function startPolling() {
  pollId = setInterval(fetchLatest, 2000);
}

function stopPolling() {
  clearInterval(pollId);
  pollId = null;
}

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    stopPolling();
  } else if (!pollId) {
    startPolling();
  }
});

if (!document.hidden) startPolling();


这就是全部改动。但即便你已经选对了事件,还有一个坑在等着:不要依赖 tick 计数来判断标签页离开了多久。

浏览器会限制在无人可见的标签页中运行的定时器——这是一种刻意的省电策略,意味着你在标签页隐藏前启动的 setInterval,在标签页进入后台后不一定会按计划触发。如果你的“离开时长”逻辑依赖数 tick(ticksElapsed * intervalMs),那么一段长时间的后台停留会被少算,因为其中一些 tick 根本没有触发。

解决办法是停止计数 tick,改为读取时钟。在离开时和回来时分别盖一个 Date.now() 时间戳:

let hiddenAt = null;

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    hiddenAt = Date.now();
  } else if (hiddenAt) {
    const hiddenMs = Date.now() - hiddenAt;
    reconcile(hiddenMs); // 用实际经过的时间同步状态
    hiddenAt = null;
  }
});


visibilitychange 提供精确的时刻;真实时间计算提供这两个时刻之间的精确时长。两者都不依赖浏览器在期间实际决定执行多少次定时器回调。

<!-- quiz:start -->

测一测自己

已经掌握了吗?[来做这 7 道题的测验 →](https://bestpractic.org/blog/page-visibility-api-tab-aware-timers/quiz)

_即时反馈,每题有提示,每个答案都有讲解——无论对错。_

<!-- quiz:end -->

Focus 与 visibility 是不同的问题——按需选用

经验法则:focus/blur 用于「该元素或窗口当前是否拥有键盘输入」——表单字段、快捷键,以及一切与 输入 相关的东西。visibilitychange 用于「人类当前是否看得见它」——视频和动画播放、轮询与刷新间隔、分析停留时长、自动保存节奏。比起 beforeunloadvisibilitychange 也是发送最后一刻分析信标的更可靠时机,因为 hidden 在标签页关闭时同样会触发,而且在移动浏览器上表现稳定得多。

混淆这两者不会导致语法错误,但会造成背景视频因错误的原因而停止、轮询却因正确的原因而永不停止。

你现在某个后台标签页里,有没有什么正在默默运行,却完全不知道根本没人看着它?

原文:https://dev.to/parsajiravand/your-timer-doesnt-know-you-tabbed-away-3cjp(作者 @parsajiravand)

发布评论
全部评论(0)