我最早被 Page Visibility API 逼着去仔细看文档,是因为一个特别尴尬的线上事故。当时我在做一个给运营看的大屏页面,页面上有一组实时告警列表,前端每 15 秒拉一次后端接口。本来功能一切正常,直到某天产品跑过来说:后台打开这个标签页放着不管,再回来后,页面积压了几百条假告警,接口一恢复就像洪水一样往外喷。我一开始猜测是定时器被浏览器节流了,后来实测发现根本不是节流那么简单,而是页面在后台时,请求虽然发出去了,界面却完全没机会渲染,等回到前台又重新补跑了一堆不必要的任务。那次之后我把跟页面可见性相关的机制系统过了一遍,才发现 Page Visibility API 和 visibilitychange 事件虽然看着简单,但实际用起来,从事件的触发边界到不同浏览器的兼容细节,处处都有坑。
这篇文章就把我完整的理解写出来,适合正在做视频播放器、直播、数据大屏、消息通知页、H5 埋点上报这类项目的同学。凡是页面切走之后还想控制脚本是否继续工作的,都绕不开这几个 API。
1. 先说一个教训:为什么我只监听 blur 事件会翻车
1.1 我最初写的那版“土办法”
第一次修复线上问题时,我下意识想到的是监听 window.blur。因为直觉告诉我,用户把页面切走了,浏览器窗口应该失焦了,那我就暂停告警弹出提示,这个逻辑不是顺理成章吗?
当时的代码大概是这样的:
javascript复制window.addEventListener('blur', () => {
// 停止告警音频
// 停止轮询
stopAlarmPolling();
});
window.addEventListener('focus', () => {
// 恢复告警音频
// 重新拉取数据
startAlarmPolling();
});
从表面行为看,这确实能覆盖很多常见操作。比如用户在 Windows 上按 Alt + Tab 切换窗口,或者在 macOS 上 Cmd + Tab 切换应用,blur 基本都会触发。但真正线上出问题的场景是另一种:用户只是在浏览器里点了另一个标签页,比如从大屏标签页切到同事发的 IM 网页版,这时候当前页面所在的窗口仍然是聚焦的,blur 压根不会触发。
于是大屏页面认为自己还活在前台,定时器不断发起请求、告警音效不断尝试播放,把整条业务链路拖得很累,用户几乎无法第一时间收到真正重要的告警。
1.2 “页面不可见”和“窗口失焦”根本不是一回事
这个例子特别典型地反映了浏览器里好几组“看似能替代”的概念之间的差别:
window.blur表达的是“这个窗口不在焦点上”,不代表整个页面被另一个标签页遮住了。document.hidden表达的是“这个页面目前是否被用户实际看到”,也就是标签页是否真的被切换或者最小化了。- 更准确的状态描述是
document.visibilityState,它的值是'visible'还是'hidden',决定了你页面逻辑该不该继续跑前台任务。
如果只看 blur,你可能把“用户切换到别的应用窗口,但页面仍占据着当前标签页且被完整渲染”的情况误判为隐藏,导致音频被错误停掉,或者动画被错误暂停。相反,如果只看 visibilityState,那你可能也会漏掉一些用户注意力已经转移、但浏览器仍认为页面可见的细节。
所以,理解了这套可见性语义之后,再去看官方文档里的一句话就非常清楚了:Page Visibility API 是浏览器提供给页面的一个公共信号,用来告诉你“这个页面有没有被用户真实看到”。它不是简单的焦点问题,而是由标签页切换、窗口最小化、系统锁屏等多种因素共同决定的综合状态。
1.3 为什么轮询任务应该在 hidden 时不只停止,还要“冻结时间”
除了判断逻辑要对,另一个容易出错的是恢复逻辑。
用户切回页面时,你需要判断“离开期间漏掉了哪些消息”,而不是立刻把积压的请求全部打出去。我后来在代码里加了一个 lastVisibleTimestamp,用 visibilitychange 记录进入 hidden 的时间点,回到 visible 时先只请求增量数据,避免了回来时接口被瞬间刷爆的情况。这一点可能才是实际项目里真正要命的坑:事件本身不复杂,但事件引发的业务状态恢复策略,需要提前设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. visibilityState、hidden、visibilitychange:先搞清三个基础概念的分工
2.1 visibilityState 是标准的“状态结论”
现代浏览器里,建议你优先读 document.visibilityState,它直接给出一个枚举值,让人一眼就知道页面到底处于什么状态。根据规范,这个枚举值分为四种:
| 状态 | 含义 | 常见触发场景 |
|---|---|---|
visible |
页面至少部分可见 | 当前标签页处于前台且窗口未被最小化 |
hidden |
页面完全不可见 | 切换到其他标签页、最小化窗口、系统锁屏、移动端切到其他应用 |
prerender |
页面正在预渲染,但还没展示给用户 | 浏览器预加载某些页面链接时可能出现 |
unloaded |
文档正在被卸载 | 兼容性较差,实际使用极少,不建议依赖 |
prerender 这个状态很有迷惑性。在 Chrome 上如果你用预渲染机制,页面可能在用户点开之前就已经加载并执行了一段脚本,此时 document.visibilityState === 'prerender'。如果你在脚本里一进来就疯狂上报、播放声音、拉数据,预渲染阶段的行为就会变得不可控。正确姿势是遇到 prerender 时尽量保持“静默”,等它真正变成 visible 后再开始工作。
2.2 hidden 是“历史遗留但依然好用”的布尔值
document.hidden 其实是一个更老的 API。在老项目中,很多程序员会写:
javascript复制if (document.hidden) {
// do something
}
它和 document.visibilityState === 'hidden' 的含义高度一致,存在是为了兼容旧代码,也稍微省一点代码量。但我个人不推荐新项目继续把它作为主判断条件,一方面它的布尔语义太粗糙,没办法区分 prerender 这种中间状态,另一方面很多代码规范里隐藏了一个倾向:直接读布尔值容易让后人不知道这个 hidden 到底是“标签页隐藏了”还是“元素隐藏了”,不如 visibilityState 的语义一目了然。
所以,判断页面是否可见,现代写法我推荐这样:
javascript复制function isPageVisible() {
return document.visibilityState === 'visible';
}
兼容老浏览器的项目再叠加一层 document.hidden 的判断。
2.3 visibilitychange 事件挂在 document 上,不在 window 上
这个细节是我见过新人最容易写错的:
javascript复制// 错误示范
window.addEventListener('visibilitychange', handler);
// 正确写法
document.addEventListener('visibilitychange', handler);
虽然早期某些浏览器版本对 window 上绑定这个事件也网开一面,但标准规范从来没有把 visibilitychange 定义成 window 上的事件。事件目标是 document。你如果挂在 window 上,Chrome、Firefox 等主流浏览器通常也能捕获到,因为事件冒泡到了 window,可这并不保险,某些环境可能行为不一致。
我的习惯是直接挂在 document 上,然后在事件回调里读 document.visibilityState,不去依赖事件对象本身携带的数据。从规范角度讲,visibilitychange 也不是 Event 里带了某个特别的 state 字段,所以想通过 event.visibilityState 去拿值是完全不靠谱的。
3. 实践中的标准接入方式和四段可抄代码
3.1 事件回调里的状态判断顺序
先给出一段最基础的监听代码:
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// 页面回到前台
handlePageVisible();
} else if (document.visibilityState === 'hidden') {
// 页面切到后台
handlePageHidden();
}
});
这段代码看起来很简单,但有个细节值得注意:事件触发时,document.visibilityState 已经被更新为最新的值了,你读取时看到的是变化之后的目标状态,而不是“旧的可见状态”。比如从可见切换到隐藏,触发回调时你再读 document.hidden,结果是 true。
所以如果你需要记录“切换之前的状态”,一定要在被切换前用别的变量缓存。例如:
javascript复制let previousVisibility = document.visibilityState;
document.addEventListener('visibilitychange', () => {
const currentVisibility = document.visibilityState;
console.log(`从 ${previousVisibility} 切换到了 ${currentVisibility}`);
previousVisibility = currentVisibility;
});
3.2 视频播放器:离开页面立即暂停,回来不自动续播
视频类网站是 Page Visibility API 最经典的战场。如果用户在播放视频时切到其他标签页,浏览器默认不会继续播放声音,但视频的播放状态本身还停留在“播放中”,一些播放器甚至会因为继续解码丢帧,导致回来后出现音画不同步或缓冲。
通用处理逻辑很简单:
javascript复制const video = document.getElementById('player');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 记录是否因为切走而暂停,方便后续策略
wasPlayingBeforeHidden = !video.paused && !video.ended;
if (wasPlayingBeforeHidden) {
video.pause();
}
} else if (document.visibilityState === 'visible') {
// 不要无条件续播,尊重用户预期,等待显式播放命令
// 如果产品期望“自动续播”,可在这里 video.play()
}
});
“回来不自动续播”这一条,是我踩过坑后总结的体验原则。很多产品经理希望一切无缝,但现实是用户可能刻意切走暂停音频,比如接电话、开会。如果页面一回到前台就自动把声音放出来,社交场合会非常尴尬。
在移动端,Safari 对自动播放限制更严格,即使你在 visibilitychange 回调里调用 video.play(),也可能因为用户手势要求被浏览器拒绝,出现一个未处理的 Promise rejection。如果确实要续播,最好放在按钮点击事件或页面可见后第一次触摸时再触发。
3.3 轮询类任务:不只是停止定时器,还要处理“过期数据”标记
处理轮询时,很多人都知道要 clearInterval,但忽略了一个更重要的逻辑:网络请求是异步的,你在 hidden 时已经发出去的请求,在回来时才返回怎么办?
我通常会在页面切到后台之前做一个自增的“代际编号”,每个请求发起时带着当前代际编号,回来之后检查回调结果是不是最新代际的,如果不是就直接丢弃,避免旧请求覆盖新页面状态。
javascript复制let pollTimer = null;
let pollGeneration = 0;
function startPolling() {
stopPolling(); // 避免重复启动
pollTimer = setInterval(() => {
pollGeneration += 1;
const currentGeneration = pollGeneration;
fetch('/api/current-status')
.then((response) => response.json())
.then((data) => {
if (currentGeneration !== pollGeneration) {
// 请求发起后有新的代际产生,说明结果已过期
return;
}
renderData(data);
});
}, 15000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
stopPolling();
pollGeneration += 1; // 使在途请求彻底失效
} else if (document.visibilityState === 'visible') {
startPolling();
}
});
这种“代际编号”思想不只在 Page Visibility 场景有用,凡是异步请求和界面状态强相关的场景,都可以用它来防止“过期响应覆盖新内容”。
3.4 埋点上报:页面隐藏时的最后机会
埋点领域对 visibilitychange 的使用是最早也是最广泛的。常见做法是在业务埋点里收集了一系列用户行为,希望等到页面卸载时一次性上报。传统依赖 beforeunload 或 unload 事件上报,但这两个事件在移动端尤其不可靠,浏览器随时可能杀掉后台页面,根本没有机会执行回调。
有一种性能策略叫“页面隐藏时统一上报”,因为页面进入 hidden 状态后,脚本还有几百毫秒到几秒的执行时间,此时可以用 sendBeacon 把数据发出去。
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const payload = collectPendingEvents();
if (payload && payload.length > 0) {
navigator.sendBeacon('/api/collect', JSON.stringify(payload));
}
}
});
sendBeacon 解决了传统 AJAX 请求在页面卸载时容易被取消的问题。不过要注意,发送的数据不能太大,一般需要限制在几十 KB 以内,而且服务端要做好重复数据的去重。因为 visibilitychange 在有的环境下会触发多次,或者与其他上报通道形成竞争,导致同一批数据被重复发送。
4. 触发边界:哪些操作真的会触发,哪些不会触发
4.1 桌面端常见操作触发情况对照
我梳理了一份桌面端浏览器实测后的触发对照,能帮你快速判断当前代码是否需要覆盖:
| 操作 | visibilityState 变化 | visibilitychange 是否触发 |
|---|---|---|
| 切换到浏览器内另一个标签页 | hidden | 触发 |
| 当前窗口点击最小化 | hidden | 触发 |
| 在 Windows 上用 Alt + Tab 切换离开浏览器 | hidden | 触发 |
| 在 macOS 上用 Cmd + Tab 切换应用 | hidden | 常见触发,但部分版本可能延迟 |
| 系统锁屏 | hidden | 触发 |
| 同一浏览器里,当前页面被另一个窗口完全遮挡 | 常常仍为 visible,不完全保证 | 通常不触发 |
| 点击同一个标签页内部的按钮,不切走 | visible | 不触发 |
| 打开开发者工具,把页面缩小到只剩一半 | visible | 不触发 |
注意倒数第三行,很多人误解页面被其他窗口盖住就会触发 hidden。实际情况是,浏览器窗口本身还在前台,当前标签页也还处于激活状态,只是系统层面的窗口层级被挡住了。浏览器并不认为页面“不可见”,所以不会触发 visibilitychange。
4.2 iframe 里能不能用?能,但可见性由宿主页面决定
在 iframe 里,document.visibilityState 不完全等同于 iframe 是否显示。它继承并受宿主页面的可见性状态影响。更直白一点说:如果顶级页面被切到了后台,那里面所有 iframe 的 document.hidden 通常也是 true。
但反过来不成立:顶级页面是可见的,可你把 iframe 用 display: none 或者藏到屏幕外,iframe 内部的 document.visibilityState 依然可能是 visible,因为浏览器只关注“iframe 所在页面是否可见”,不关心 iframe 元素本身在页面里的几何可见性。这一点做广告富媒体、跨域嵌入组件时要特别小心,别指望靠它来做“元素曝光检测”。
另外,跨域 iframe 的事件和属性还能不能读到?同样是可以的,因为 document.visibilityState 不是敏感信息,不会因为跨域就受到限制。
4.3 移动端 Safari 的特殊表现
移动端本身屏幕小,用户切 App 的频率高。iOS Safari 在很多版本里有一个让人又爱又恨的行为:用户从底部往上滑调出控制中心,或者下拉通知栏,并没有真正把页面切到后台,但页面有一段时间可能不被绘制。可 Safari 未必会立刻把 visibilityState 切成 hidden。如果我们的业务逻辑是“隐藏就暂停播放”,需要额外处理这些系统 UI 遮挡场景。
另一点是 iOS Safari 的标签页实现跟桌面不太一样。双击 Home 键切换到 App 列表后,页面会进入“挂起”状态,这时候 visibilitychange 最终会变成 hidden,但时间点可能延迟到系统真的决定冻结页面时才触发。开发者不能指望它像桌面端那样及时。
Android Chrome 大体上表现良好,但部分定制 ROM 的后台管理策略激进,会在页面进入 hidden 后直接冻结 JS,sendBeacon 也不一定能及时发出去。埋点类实现最好做多层兜底,比如配合后端心跳来判断用户活跃状态。
4.4 页面还在加载阶段,不会因为刚开始的 prerender 乱发请求
前面提到 prerender 状态。在 Chrome 里,一些高概率会被点击的链接,浏览器会在后台静默预渲染整个页面,此时 document.visibilityState 会被设置成 prerender。如果你的代码没判断就执行大数据上报、视频加载、WebSocket 连接,可能白白浪费用户流量。
稳妥的初始化逻辑是:
javascript复制function initApp() {
if (document.visibilityState === 'prerender') {
// 等页面真正可见后再初始化
document.addEventListener('visibilitychange', function onVis() {
if (document.visibilityState === 'visible') {
document.removeEventListener('visibilitychange', onVis);
initApp();
}
});
return;
}
// 正常初始化流程
doInit();
}
这套机制这几年越来越重要,因为不少浏览器在加强预加载,不只是移动端,桌面端也会在鼠标悬停到链接上时做预渲染。
5. 我复现过的几类问题:现象、误判原因、最终处理方法
5.1 同一逻辑注册了多次监听,导致恢复时任务被重复启动
用 React 开发时,很多人习惯在 useEffect 里注册:
javascript复制useEffect(() => {
const handler = () => { ... };
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, []);
这段代码本身没毛病,问题往往出在依赖数组写错了,或者组件被多次挂载而清理函数没执行。结果就是页面上绑了四五份相同的 handler,每次从后台切回来,轮询定时器被创建好几套,接口请求瞬间成倍增长。
我处理这个问题的方法是加一层“幂等启动”函数:每次 startPolling 时先调用 stopPolling,保证只存在一个定时器。同时在 handler 外层做防重入判断。
javascript复制let taskStarted = false;
function handleVisibility() {
if (document.visibilityState === 'visible') {
if (!taskStarted) {
taskStarted = true;
startPolling();
}
} else {
taskStarted = false;
stopPolling();
}
}
5.2 依赖 blur 和 visibilitychange 的触发顺序,结果不同浏览器优先级不同
我曾经想在用户切走页面时先记录一个时间戳,然后再弹一个“离开提示”气泡。当时想当然地认为 blur 会先触发,visibilitychange 稍后触发。但实测结果各浏览器不一致:Chrome 里通常是先 visibilitychange 后 blur,而 Safari 里可能反过来。如果业务逻辑依赖事件顺序,跨浏览器很容易出现状态不一致。
后来我彻底放弃了在事件里排顺序,把所有状态收敛到 visibilitychange 的 handler 里单独判断。窗口失焦和页面隐藏本来就是不同维度的事件,最简单的做法就是各自处理各自的职责,不要在它们之间建立时序依赖。
5.3 页面由 hidden 切回 visible 后,动画并没有立即恢复
原来我写过这样的代码:
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
startAnimation();
}
});
看似没问题。但某些情况下,用户从手机桌面切换回浏览器,页面虽然显示 visible,浏览器还需要一点时间完成首次绘制和合成。此时直接启动 rAF 动画,可能前面几帧被跳过,导致动画卡顿。
解决方案是不要在 visible 时立刻启动高强度的动画循环,而是稍微延迟一帧开始渲染,或者干脆只在 requestAnimationFrame 回调里启动。除此之外,动画类型如果是 CSS transition,也要注意浏览器可能暂停了合成器,恢复时需要强制触发一次重排。
5.4 页面切走前还在播放的 Web Audio,回来的状态已经断掉
Web Audio 的 AudioContext 在页面进入后台后可能被系统挂起。如果页面 hidden 前你忘了 suspend,等回到 visible 后再执行 resume(),可能会出现一个异常状态,因为系统的 audio context 已经被自动 suspended,或者时间线发生了跳变。
我在写一个 Web 端提示音工具时遇到过:用户把页面切走再切回来,提示音的计时逻辑全乱了。后来我统一在 hidden 时 audioContext.suspend(),回到 visible 时再调用 resume(),并且把计时基准改为从系统时钟读取,而不是依赖音频上下文内部的播放时间。
这些细节不属于 Page Visibility API 的规范范围,却是在真实业务里绕不开的联动逻辑。原理解释起来很直接:浏览器为了省电和资源,会把后台页面里音频、视频、定时器、动画等一并调速或挂起,我们开发时要主动配合调度,而不是指望它自动恢复得丝滑无感。
6. 进阶:从 visibilitychange 延伸到完整的页面生命周期
6.1 页面生命周期里不止有可见性,还有冻结和丢弃
以前处理页面切走,只需要关心 visible/hidden 就够了。但现在的浏览器为了省资源,在页面进入后台后还可能做两件更狠的事:冻结页面(freeze),甚至直接丢弃页面(discard)。
Page Lifecycle API 里定义的重要状态包括 active、passive、hidden、frozen、terminated、discarded。visibilitychange 只能帮你识别 active/passive/hidden 之间的切换,识别不了 frozen 和 discarded。如果一个标签页在后台待得太久,浏览器可能冻结它,让所有 JS 计时器和回调都停摆。这时即使你监听 visibilitychange,也可能发现事件不触发了,因为页面被冻住了,根本没机会执行 JS。
因此,如果产品需求是“后台一段时间后彻底切断网络连接”,单靠 visibilitychange 还不够,最好监听 freeze 与 resume 事件,并且用 pageshow、pagehide 辅助处理页面从 bfcache(后退/前进缓存)恢复的场景。
我从一个真实案例里感受到这个区别:用户把页面切到后台很久,系统回收页面后被重新唤醒,从 bfcache 恢复时,页面代码里的某些全局状态可能还停留在冻结前。如果没有监听 pageshow,定时器可能不会自动恢复,界面看起来是静止的。
常见的生命周期监听骨架如下:
javascript复制document.addEventListener('visibilitychange', () => {
// 状态切换
});
window.addEventListener('pagehide', () => {
// 页面即将被卸载或进入 bfcache
});
window.addEventListener('pageshow', () => {
// 页面从缓存恢复,可能是普通加载,也可能是 bfcache 恢复
});
document.addEventListener('freeze', () => {
// 页面被冻结,尽量释放资源
});
document.addEventListener('resume', () => {
// 页面从冻结中恢复
});
这套组合拳比单独用 visibilitychange 稳妥很多。
6.2 把“回到前台”当作一次新的可见环境来设计,而不是旧状态的续接
很多逻辑出错,本质上是开发者潜意识里认为“页面切走再切回来,什么都不应该变”。但实际上,后台期间系统可能已经产生了很多变化,比如网络切换、用户退出登录、消息中心积攒了几十条提醒。
我现在的设计原则是:每次页面从 hidden 恢复到 visible,统一执行一次“轻度刷新”——不重新加载整页,但重新同步用户状态、消息未读数、服务端时间。这样哪怕浏览器对页面做了冻结或资源回收,回来后也能快速对齐最新状态,而不是依赖一份可能已经过期的本地状态。
这一步不算 Page Visibility API 的强制用法,但恰恰是项目稳定性的加分项。线上环境里有太多杀进程、清缓存、网络切换的意外,在可见性恢复的节点做状态对账,等于给页面套了一层保险。
一些最后想分享的操作体会
这套 API 我从最初只会在 blur 上糊一版,到后来把 visibilitychange、pagehide、freeze、resume 全部纳入监控体系,中间踩过不少和浏览器底层调度机制有关的坑。现在已经养成了几个固定习惯:监听器统一挂 document;回调里永远先读 document.visibilityState,不用 hidden 做业务主判断;每次 hidden 不只停任务,还做状态标记,防止后台在途异步回调污染前台数据;回到 visible 后做轻量对账,不盲目续跑之前所有事情。
如果你也刚开始用,建议先从一个最小场景入手,比如给当前页面加上日志打印,然后手动切换标签页、最小化窗口、锁屏,把每次触发时的 visibilityState 变化记录下来,跑个半天你就能对这套机制有非常直观的感受。后面再逐步把视频暂停、轮询收敛、数据上报这几个常见的业务场景接进去,就能尽量避免被它坑到。
