Page Visibility API 深度解析:visibilitychange 的用法与坑

4 阅读10分钟

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: truehidden: 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 压栈(仍在内存中)
离开时触发onUnmountedvisibilitychange(hidden: true)
返回时触发onMountedvisibilitychange(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 只在两种情况下触发

  1. 页面正常加载完成(紧跟 load 事件),event.persisted = false
  2. 页面从 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,两者感知的是不同层面的事:

pageshowvisibilitychange
感知的是页面加载/恢复事件页面可见性变化
触发前提页面经历了卸载或 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 被调用两次(visibilitychangepageshow 都触发),需要加防抖或状态锁。


八、关于监听器的生命周期管理

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 的触发还受以下因素影响:

  1. 小程序导航方式

    • navigateTo(页面压栈)→ 当前 WebView 隐藏 → visibilitychange 触发 ✅
    • redirectTo(页面替换)→ 当前 WebView 被销毁 → visibilitychange 可能不触发
  2. 小程序自身生命周期:小程序整体切后台时,WebView 也会隐藏,visibilitychange 会触发,可能误触发刷新逻辑

  3. 跨小程序跳转(openEmbeddedMiniProgram):行为取决于宿主小程序实现


十、最终决策框架

需要"用户离开再回来后刷新数据"?
    │
    ├── 主要运行在 H5 纯浏览器(window.location.href 导航)
    │       └── 用 onMounted(必须)+ pageshow persisted(可选增强)
    │
    ├── 主要运行在小程序/App WebView(页面栈导航)
    │       └── 用 visibilitychange(必须)
    │
    └── 两种环境都要兼容
            └── onMounted + visibilitychange 同时用
                注意:可能触发两次,加防抖或 loading 状态锁保护

十一、参考资料