K 的一隅

浏览器 浏览器核心机制

页面已经离开了,为什么请求和监听器还在跑:页面生命周期与资源清理

从店铺关门后设备仍在工作说起,理解页面生命周期、可见性、bfcache、AbortController 与资源清理。

4 分钟阅读 更新于 2026-07-24
本文目录

店铺拉下卷帘门,不代表广播、空调、配送单和后厨炉火都自动关闭。页面离开也一样:用户看不到页面了,但定时器、事件监听、网络请求、Worker 和流读取可能仍有自己的生命周期。

页面不是只有“打开”和“销毁”

页面暂时不可见,不等于 JavaScript 环境已经销毁。

js
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

ts
import { onMounted, onUnmounted } from 'vue'

function onVisibilityChange() {
  if (document.hidden) pausePreview()
  else resumePreview()
}

onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange))
onUnmounted(() => document.removeEventListener('visibilitychange', onVisibilityChange))

这段代码有两种边界:组件销毁时移除监听,页面隐藏时暂停视觉工作。只写其中一种,都会留下另一层生命周期没有处理。

定时器不会因为用户看不见就自动清理

js
const timer = setInterval(refreshBadge, 30_000)

function dispose() {
  clearInterval(timer)
}

后台页面里的定时器可能被浏览器降频,但降频不是清理。页面返回时它仍可能继续执行;如果每次路由进入都注册一个 interval,用户多切几次页面,就会看到刷新请求叠加。清理函数必须和创建动作在同一抽象边界里,不能只在“正常返回”路径调用。

监听器也一样。匿名函数不能用另一个匿名函数移除:

js
const onResize = () => updateLayout()
window.addEventListener('resize', onResize)
window.removeEventListener('resize', onResize)

现代浏览器还可以用 AbortSignal 统一关闭一组监听:

js
const controller = new AbortController()
window.addEventListener('resize', updateLayout, { signal: controller.signal })
window.addEventListener('scroll', updateScroll, { signal: controller.signal })
controller.abort()

请求的生命周期要传进去

页面离开时,Fetch 不会因为你不再看它就自动取消。把 signal 作为函数参数传入,调用方才拥有取消权:

js
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,不会自动清理这些浏览器资源。

js
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 里、把恢复逻辑完全忘掉,会导致时间戳、连接和展示状态过期。

js
window.addEventListener('pageshow', (event) => {
  if (event.persisted) refreshVisibleState()
})

不要为了“保证触发 beforeunload”而随意注册它;这可能让页面失去 bfcache 优势。需要保存用户输入时,优先在状态变化时保存,或使用更合适的页面可见性信号。

容易踩的坑

页面隐藏不等于销毁:bfcache 和切标签都可能把页面原样唤回。Promise settle 也不等于资源释放——请求、流和 Worker 要各自收尾。beforeunload 不可靠(尤其移动端),别指望它保存一切;window 上的监听器也不会因为组件卸载自动消失,除非框架或你的代码明确做了清理。