Page Visibility API 深度解析:visibilitychange 的用法与坑
起因:在做 H5 活动页时,需要在用户从订购页返回后自动刷新数据。用了
visibilitychange却发现手机和电脑行为完全不同——电脑不触发,手机触发。这篇文章彻底梳理这个 API 的行为边界。
一、基础概念
Page Visibility API
浏览器提供的用于感知"页面当前是否对用户可见"的 API,核心属性和事件:
// 当前页面是否处于隐藏状态
document.hidden // boolean,true = 不可见,false = 可见
// 当前可见性状态(更细粒度)
document.visibilityState
// 'visible' — 页面完全可见
// 'hidden' — 页面不可见(切后台、切标签、最小化等)
// 'prerender' — 页面预渲染中,用户尚未看到
// 可见性发生变化时触发
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('页面变为不可见')
} else {
console.log('页面变为可见')
}
})
二、visibilitychange 真正会触发的场景
| 场景 | hidden: true | hidden: false |
|---|---|---|
| 切换到其他浏览器标签页 | ✅ | ✅(切回来时) |
| Alt+Tab 切换到其他应用 | ✅ | ✅(切回来时) |
| 手机按 Home 键回桌面 | ✅ | ✅(重新打开浏览器时) |
| 手机锁屏 | ✅ | ✅(解锁时) |
| 小程序内 WebView 被压栈(新页面压在上面) | ✅ | ✅(用户返回时) |
| App 内嵌 WebView 跳转到新的 WebView | ✅ | ✅(返回时,视 App 实现) |
window.location.href 跳转到新页面 | ❌(页面直接卸载) | ❌(返回时是重新加载) |
三、核心坑:「页面导航」vs「可见性变化」是两件事
这是最容易踩的坑,也是本次遇到问题的根本原因。
坑 1:window.location.href 跳转后返回,不会触发 visibilitychange
用户点击"去订购"
↓
window.location.href = 'https://order.xxx.com'
↓
当前页面被【卸载】(不是"隐藏")
onUnmounted 触发 ✅
↓
浏览器加载新页面
↓
用户点击返回
↓
浏览器重新加载原来的页面(或从 bfcache 恢复)
onMounted 触发 ✅
visibilitychange(hidden: false) 触发? ❌(普通重载)/ ✅(bfcache 恢复,不可靠)
关键区别:window.location.href 是页面导航,当前页面生命周期结束。页面被"销毁"再"重生",不是"隐藏"再"显示"。visibilitychange 感知的是可见性,感知不到生命周期。
电脑浏览器(如 Chrome)的表现:window.location.href 跳转时会完全卸载当前页面,按返回键走重新加载,触发 onMounted,不触发 visibilitychange。
手机浏览器(如夸克、UC、QQ浏览器等)的表现:国产手机浏览器普遍做了更激进的页面保活优化——window.location.href 跳转时并不销毁当前页面,而是将其压入后台保留在内存中(类似 App 的页面栈)。因此跳转时 visibilitychange(hidden: true) 触发,返回时 visibilitychange(hidden: false) 触发,而 onUnmounted/onMounted 均不触发。这就是为什么同一段代码在电脑 Chrome 和手机夸克上行为完全相反。
坑 2:小程序/App WebView 中"导航"其实是"可见性变化"
用户点击"去订购"(小程序环境)
↓
Utils.miniProgram.uniNavigateTo('/pages/order/index')
↓
小程序把新页面压栈,当前 WebView 被放到栈底(进入后台)
当前 WebView:document.hidden = true
visibilitychange(hidden: true) 触发 ✅
onUnmounted 不触发 ✅(页面还活着,只是不可见)
↓
用户点击返回
↓
小程序弹栈,当前 WebView 回到前台
当前 WebView:document.hidden = false
visibilitychange(hidden: false) 触发 ✅
onMounted 不触发 ✅(页面没有被销毁过)
关键区别:小程序/App 的页面栈导航,WebView 并没有被销毁,只是被"压到后台"。这才是真正的"可见性变化",visibilitychange 完全适用。
四、两种环境行为对比总结
| 电脑浏览器(H5 直接打开) | 手机小程序/App 内嵌 WebView | |
|---|---|---|
| "跳转到新页面"的本质 | 页面导航(卸载重载) | WebView 压栈(仍在内存中) |
| 离开时触发 | onUnmounted | visibilitychange(hidden: true) |
| 返回时触发 | onMounted | visibilitychange(hidden: false) |
| 数据刷新方案 | 在 onMounted 里刷新 ✅ | 在 visibilitychange 里刷新 ✅ |
| 对方的方案 | visibilitychange ❌ 不触发 | onMounted ❌ 不触发 |
这就是为什么同一套代码在两个环境里行为截然不同。
五、bfcache(往返缓存)
浏览器的往返缓存(Back-Forward Cache)是一种优化:用户按返回键时,浏览器不重新加载页面,而是直接从内存快照还原,此时 visibilitychange(hidden: false) 会触发。
// 检测是否从 bfcache 恢复
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
// 从 bfcache 恢复,等同于"返回"
console.log('从 bfcache 恢复,刷新数据')
refreshData()
}
})
bfcache 的坑:
- Chrome 96+ 对 H5 支持较好,但不保证
- Safari 支持较早,但策略不同
- 以下情况会导致页面不进入 bfcache:
- 页面里有
unload事件监听器(哪怕空的) - 页面里有
Cache-Control: no-store - 页面使用了某些不兼容 API(IndexedDB 事务、WebRTC 连接等)
- 小程序/App 内嵌 WebView 通常有自己的缓存策略,行为不可预测
- 页面里有
所以 bfcache 是个"有则用,无则算"的彩蛋,不能依赖它做数据刷新。
六、能不能用 pageshow 替代 visibilitychange?
既然 visibilitychange 在电脑浏览器上不触发,那直接用 pageshow 能不能一招通吃?
pageshow 的触发条件
pageshow 只在两种情况下触发:
- 页面正常加载完成(紧跟
load事件),event.persisted = false - 页面从 bfcache 恢复,
event.persisted = true
window.addEventListener('pageshow', (e) => {
console.log('pageshow 触发,persisted:', e.persisted)
if (e.persisted) {
// 从 bfcache 恢复
}
})
关键点:pageshow 本质上是一个加载事件("页面被展示了"),而不是可见性事件("页面从不可见变为可见")。
三种方案在不同环境下的表现
| 方案 | 电脑 Chrome(卸载重载) | 手机夸克(后台保活) | 小程序 WebView(页面栈) |
|---|---|---|---|
onMounted | ✅ 重新加载触发 | ❌ 页面没死过 | ❌ 页面没死过 |
visibilitychange | ❌ 页面是新造的 | ✅ 后台恢复触发 | ✅ 压栈恢复触发 |
pageshow | ✅ 重新加载触发 | ❌ 页面没重新加载 | ❌ 页面没重新加载 |
可以看到:pageshow 能覆盖的场景和 onMounted 完全重合,在手机浏览器/小程序"后台保活"的场景下同样无效。
为什么 pageshow 在手机夸克上不触发?
手机夸克:window.location.href 跳转
↓
夸克把当前页面压入后台(页面还活着,JS 上下文完整保留)
↓
用户点返回
↓
夸克把页面从后台恢复到前台
↓
页面从未经历过 "卸载 → 加载" 的过程
↓
pageshow ❌ 不触发(没有 "show" 可言,它一直都在)
visibilitychange ✅ 触发(从"不可见"变为"可见")
夸克的"页面保活"不等于 bfcache。bfcache 是在页面卸载后将快照保存、返回时恢复并触发 pageshow(persisted: true)。而夸克是根本没有卸载页面,只是把它"藏起来"了,所以只有 visibilitychange 能感知到。
结论
pageshow 无法替代 visibilitychange,两者感知的是不同层面的事:
| pageshow | visibilitychange | |
|---|---|---|
| 感知的是 | 页面加载/恢复事件 | 页面可见性变化 |
| 触发前提 | 页面经历了卸载或 bfcache 恢复 | 页面从可见变不可见,或反过来 |
| 适用场景 | 传统页面导航(卸载→加载) | Tab 切换、App 切后台、WebView 压栈 |
要覆盖所有环境,仍然需要组合使用。
七、各场景的正确数据刷新方案
场景 1:H5 纯浏览器,window.location.href 跳转后返回
// ✅ 正确:在 onMounted 刷新(每次重新加载都会触发)
onMounted(async () => {
await fetchData()
})
// ✅ 可选补充:pageshow 处理 bfcache 场景
window.addEventListener('pageshow', (e) => {
if (e.persisted) fetchData()
})
// ❌ 错误:依赖 visibilitychange(不会触发)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) fetchData() // 返回后不会走到这里
})
场景 2:小程序/App WebView,页面栈导航后返回
// ✅ 正确:visibilitychange(WebView 压栈后返回会触发)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) fetchData()
})
// ❌ 错误:依赖 onMounted(WebView 没被销毁,不会重新挂载)
onMounted(async () => {
await fetchData() // 返回后不会走到这里
})
场景 3:两种环境都要兼容(本项目的现实情况)
onMounted(async () => {
await fetchData() // 覆盖 H5 浏览器返回重载的场景
// 覆盖小程序/App WebView 压栈后返回的场景
document.addEventListener('visibilitychange', () => {
if (!document.hidden) fetchData()
})
// 可选:覆盖 bfcache 场景(H5 浏览器支持时的额外加持)
window.addEventListener('pageshow', (e) => {
if (e.persisted) fetchData()
})
})
⚠️ 注意:两种监听同时存在时,在 bfcache 场景下可能造成
fetchData被调用两次(visibilitychange和pageshow都触发),需要加防抖或状态锁。
八、关于监听器的生命周期管理
document.addEventListener 注册的监听器绑定在 document 上,不会随 Vue 组件的卸载自动移除。如果组件会被多次挂载(路由切换),不移除会导致监听器叠加。
教科书写法(常规 SPA 场景)
// ❌ 危险写法:每次组件挂载都注册,卸载时不移除
onMounted(() => {
document.addEventListener('visibilitychange', handler)
// 组件销毁后 handler 仍然存活
})
// ✅ 正确写法:挂载时注册,卸载时移除
const handler = () => { if (!document.hidden) fetchData() }
onMounted(() => document.addEventListener('visibilitychange', handler))
onUnmounted(() => document.removeEventListener('visibilitychange', handler))
// ✅ 也可以封装为 composable
export function usePageVisibility(onVisible) {
const handler = () => { if (!document.hidden) onVisible() }
onMounted(() => document.addEventListener('visibilitychange', handler))
onUnmounted(() => document.removeEventListener('visibilitychange', handler))
}
坑:在"页面保活"浏览器中,onUnmounted 会导致监听器丢失
实测:在夸克浏览器中使用上述 composable 封装后,
visibilitychange监听失效了。
原因分析:
window.location.href = '订购页'
↓
浏览器开始导航流程,触发 pagehide 等事件
↓
Vue 检测到页面即将卸载 → 执行 onUnmounted → 监听器被移除 ❌
↓
但夸克并没有真正销毁页面!而是把它"保活"压到后台
↓
用户点返回 → 页面恢复前台
↓
visibilitychange 本该触发,但监听器已被 onUnmounted 移除 → 失效 💀
onMounted 也不会触发(页面没被重建)→ 监听器无法重新注册 → 彻底丢失
核心矛盾:Vue 的 onUnmounted 在导航过程中"提前"执行了清理,但浏览器又"反悔"了——没有真正销毁页面,只是把它藏起来了。监听器被移除了,页面却还活着,两头落空。
正确做法:区分场景选择是否移除
// 场景 A:组件会被 Vue 路由反复挂载/卸载(如 SPA 内的路由页面)
// → 必须在 onUnmounted 中移除,否则监听器累积
const handler = () => { if (!document.hidden) fetchData() }
onMounted(() => document.addEventListener('visibilitychange', handler))
onUnmounted(() => document.removeEventListener('visibilitychange', handler))
// 场景 B:页面需要经历 window.location.href 外跳再返回(如跳转订购页)
// → 不能在 onUnmounted 中移除!否则在保活浏览器中监听器会丢失
onMounted(() => {
document.addEventListener('visibilitychange', () => {
if (!document.hidden) fetchData()
})
})
// 不注册 onUnmounted 移除
// 如果浏览器真的销毁了页面,监听器会随页面一起被回收,不会泄漏
// 如果浏览器保活了页面,监听器继续存活,返回时正常触发
为什么不移除也不会泄漏?
- 浏览器真正销毁页面时(电脑 Chrome):整个 JS 上下文被回收,
document对象连同所有监听器一起被 GC,不存在泄漏 - 浏览器保活页面时(手机夸克):页面仍在内存中,监听器也在,但这正是我们需要的——返回时要靠它刷新数据
- 唯一会泄漏的情况:同一个 Vue 组件在不卸载页面的前提下被反复挂载(如 Vue 路由切换),每次
onMounted都注册新监听器却不移除旧的。但如果组件只挂载一次(如本项目的 Floor 组件),不存在此问题
最终建议
| 场景 | 是否在 onUnmounted 中移除 |
|---|---|
| 纯 SPA 内路由切换,组件反复挂载/卸载 | ✅ 必须移除,防止累积 |
会通过 window.location.href 外跳再返回 | ❌ 不移除,防止保活浏览器中监听器丢失 |
| 组件只挂载一次、永远不卸载 | 都可以,不移除也无泄漏风险 |
九、小程序环境的额外注意事项
小程序 WebView 中 visibilitychange 的触发还受以下因素影响:
-
小程序导航方式:
navigateTo(页面压栈)→ 当前 WebView 隐藏 →visibilitychange触发 ✅redirectTo(页面替换)→ 当前 WebView 被销毁 →visibilitychange可能不触发
-
小程序自身生命周期:小程序整体切后台时,WebView 也会隐藏,
visibilitychange会触发,可能误触发刷新逻辑 -
跨小程序跳转(openEmbeddedMiniProgram):行为取决于宿主小程序实现
十、最终决策框架
需要"用户离开再回来后刷新数据"?
│
├── 主要运行在 H5 纯浏览器(window.location.href 导航)
│ └── 用 onMounted(必须)+ pageshow persisted(可选增强)
│
├── 主要运行在小程序/App WebView(页面栈导航)
│ └── 用 visibilitychange(必须)
│
└── 两种环境都要兼容
└── onMounted + visibilitychange 同时用
注意:可能触发两次,加防抖或 loading 状态锁保护