本文目录
店铺拉下卷帘门,不代表广播、空调、配送单和后厨炉火都自动关闭。页面离开也一样:用户看不到页面了,但定时器、事件监听、网络请求、Worker 和流读取可能仍有自己的生命周期。
页面不是只有“打开”和“销毁”
页面暂时不可见,不等于 JavaScript 环境已经销毁。
document.addEventListener('visibilitychange', () => {
console.log(document.visibilityState)
})
window.addEventListener('pagehide', (event) => {
console.log('pagehide', event.persisted)
})visibilitychange 关注页面是否对用户可见;切换标签页、最小化窗口都可能触发。pagehide 表示页面即将离开当前展示流程,但不等于对象立刻被销毁。pageshow 可以在返回时再次通知页面,尤其要注意 bfcache(back-forward cache,后退/前进缓存):浏览器可能冻结整页,稍后恢复同一个页面实例。
DOMContentLoaded 表示 DOM 已构建,load 还要等待更多资源;它们是启动阶段事件,不是组件卸载信号。beforeunload 也不是可靠保存机制,浏览器可能跳过、限制或影响 bfcache,不能把所有清理都押在它上面。
Vue 组件卸载和页面生命周期不是一层
Vue 组件被卸载,页面可能仍在;页面进入后台,组件也可能仍然挂在内存里。组件可以用 onUnmounted 清理自己注册的监听器,但它不知道整个浏览器页面是否进入后台,除非显式监听 visibilitychange。
import { onMounted, onUnmounted } from 'vue'
function onVisibilityChange() {
if (document.hidden) pausePreview()
else resumePreview()
}
onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange))
onUnmounted(() => document.removeEventListener('visibilitychange', onVisibilityChange))这段代码有两种边界:组件销毁时移除监听,页面隐藏时暂停视觉工作。只写其中一种,都会留下另一层生命周期没有处理。
定时器不会因为用户看不见就自动清理
const timer = setInterval(refreshBadge, 30_000)
function dispose() {
clearInterval(timer)
}后台页面里的定时器可能被浏览器降频,但降频不是清理。页面返回时它仍可能继续执行;如果每次路由进入都注册一个 interval,用户多切几次页面,就会看到刷新请求叠加。清理函数必须和创建动作在同一抽象边界里,不能只在“正常返回”路径调用。
监听器也一样。匿名函数不能用另一个匿名函数移除:
const onResize = () => updateLayout()
window.addEventListener('resize', onResize)
window.removeEventListener('resize', onResize)现代浏览器还可以用 AbortSignal 统一关闭一组监听:
const controller = new AbortController()
window.addEventListener('resize', updateLayout, { signal: controller.signal })
window.addEventListener('scroll', updateScroll, { signal: controller.signal })
controller.abort()请求的生命周期要传进去
页面离开时,Fetch 不会因为你不再看它就自动取消。把 signal 作为函数参数传入,调用方才拥有取消权:
async function loadProfile(signal) {
const response = await fetch('/api/profile', { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
const controller = new AbortController()
loadProfile(controller.signal).catch((error) => {
if (error.name !== 'AbortError') console.error(error)
})
window.addEventListener('pagehide', () => controller.abort(), { once: true })取消是控制流,不一定代表服务器没有收到请求。用户离开后,底层连接可能已经发送部分数据;调用方应该把“无需继续消费”与“服务器已经回滚”分开。
Worker、流和锁也要收尾
Worker 需要 terminate() 或接收自定义停止消息;ReadableStream 的 reader 需要在 finally 中释放锁,必要时调用 cancel();WebSocket 要关闭连接。只清理 Vue 的 ref,不会自动清理这些浏览器资源。
const worker = new Worker('/hash-worker.js', { type: 'module' })
function disposeWorker() {
worker.postMessage({ type: 'stop' })
worker.terminate()
}如果资源可能被多个页面状态共享,直接 terminate 也许过强;应让拥有者计数或发送协议消息。清理的关键不是“看到一个 API 就调用”,而是先确认谁创建、谁拥有、谁最后负责停止。
bfcache 带来的反直觉
页面进入 bfcache 后,浏览器可能保存整个页面快照并冻结 JavaScript。返回时,页面不是重新从 HTML 开始,而是触发 pageshow 恢复。把一次性初始化写在 load 里、把恢复逻辑完全忘掉,会导致时间戳、连接和展示状态过期。
window.addEventListener('pageshow', (event) => {
if (event.persisted) refreshVisibleState()
})不要为了“保证触发 beforeunload”而随意注册它;这可能让页面失去 bfcache 优势。需要保存用户输入时,优先在状态变化时保存,或使用更合适的页面可见性信号。
容易踩的坑
页面隐藏不等于销毁:bfcache 和切标签都可能把页面原样唤回。Promise settle 也不等于资源释放——请求、流和 Worker 要各自收尾。beforeunload 不可靠(尤其移动端),别指望它保存一切;window 上的监听器也不会因为组件卸载自动消失,除非框架或你的代码明确做了清理。