移动端页面生命周期与 bfcache:保存草稿、暂停任务、恢复连接
本文目录
移动端用户切到聊天软件、锁屏或从应用切换器关闭浏览器时,页面未必会按桌面经验触发 beforeunload 或 unload。浏览器可能先把页面隐藏、冻结,稍后放回前进/后退缓存(back/forward cache,简称 bfcache),也可能在内存紧张时直接丢弃。把保存和清理只放在“页面卸载”回调中,既可能丢数据,也可能让浏览器无法缓存页面。
先分清可观察的状态
页面可处于 active、passive、hidden、frozen、terminated 或 discarded 等状态。它们不是每个浏览器都能逐一监听的通用状态机:
visibilitychange到hidden是移动端最重要的早期信号之一。它可能表示切换应用、最小化、关闭标签或导航离开,不等于页面已经销毁。pagehide会携带persisted。为 true 通常表示页面进入 bfcache,之后可能通过pageshow的persisted再次恢复;为 false 也不意味着此事件在所有关闭场景都必然触发。freeze/resume是 Chrome Page Lifecycle 扩展提供的状态提示,适合处理被冻结的后台任务;不能当成所有浏览器一致支持的唯一保存时机。- 页面可能被系统丢弃且不执行最后一个回调。因此,关键草稿应在编辑期间增量保存,而不是赌最后一次退出事件。
草稿采用“平时保存、隐藏再兜底”
用户每次输入后做节流或防抖保存,定期将草稿写入 IndexedDB 或服务端草稿接口,并携带文档 revision / 客户端 mutation ID。进入 hidden 时立即刷新本地副本,尽力提交已准备好的轻量数据;不要等到 unload 才第一次保存。网络失败时保留本地副本,页面恢复后通过 revision 检测冲突,不能静默覆盖更新版本。
下面的函数名代表应用自己的 IndexedDB/API/轮询适配器,用于说明生命周期中的调用位置,不是浏览器内置 API;落地时要处理写入失败、并发 revision 和请求取消。
let saveTimer;
function scheduleDraftSave(draft) {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
void saveDraftLocally(draft);
void syncDraftWithServer(draft, { mutationId: crypto.randomUUID() });
}, 400);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
clearTimeout(saveTimer);
void flushCurrentDraftToLocalStore();
stopNonessentialPolling();
} else {
refreshDraftRevisionAndResumeVisibleWork();
}
});
最后一次 flush 是补救,不是唯一保存点。IndexedDB 仍可能遇到配额、隐私模式或写入失败,所有 Promise 都应处理错误并保留可恢复状态。若用 navigator.sendBeacon() 上报少量分析事件,它只适合不需要读取响应的有限数据;它不是大文件上传、草稿同步或可靠任务队列,也不能携带任意自定义 header。不要用它发送正文、Token、Cookie 或其他敏感内容。
bfcache 恢复不是重新加载
前进/后退命中 bfcache 时,浏览器会冻结并保存原页面快照,再恢复既有 DOM/JavaScript 状态;不一定重新执行初始化脚本。此时不要重复插入事件监听器、重建同一个播放器或假设初始 API 请求会再次执行。从 pageshow 事件检查 event.persisted,只刷新可能过期的登录状态、服务器数据和短期连接;保留用户的焦点、表单与滚动位置。
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
void revalidateSessionAndVisibleData();
if (document.visibilityState === 'visible') reconnectLiveUpdates();
});
window.addEventListener('pagehide', () => {
closePageOwnedWebSocket();
closePageOwnedBroadcastChannel();
releasePageOwnedLock();
});
页面进入 bfcache 前应释放会妨碍其他页面的 Web Lock,并关闭不应被冻结的轮询、WebSocket、BroadcastChannel、WebRTC 和 IndexedDB 连接。恢复时按需重新打开;不能依赖被冻结的 timer 来维持租约。跨标签页工作的权威互斥仍应由服务端或数据库协调,单一页面持有的浏览器锁不是分布式锁。
beforeunload 与路由切换的正确位置
不要全站常驻注册 unload。它不可靠,还会降低 bfcache 命中机会。只有用户确有未保存修改、且应该阻止离开时,才暂时加上 beforeunload;保存成功或离开条件消失后立刻移除。浏览器决定提示文案,且通常要求用户先有交互。
SPA 内的路由切换不会触发整个 Document 的 pagehide。路由级资源应由框架的导航/卸载钩子清理,并在新路由挂载时重建;Document lifecycle 只管理整个页面的可见、冻结、返回缓存与离开。两层混用会造成离开文章后计时器仍跑、或返回时重复订阅。
验收矩阵
- 桌面与 iOS/Android 上输入后立即切 App、锁屏、关闭浏览器,再重新打开,确认最新本地草稿仍可恢复。
- 文章 A 到 B、浏览器后退/前进及 bfcache 恢复时,检查事件监听器、播放器、WebSocket、批注/分析请求没有重复。
- 模拟慢网、断网、服务端 409、存储配额不足和多标签同时编辑;验证提示、重试、冲突副本及最后成功 revision。
- 在 Chrome DevTools 的 Application > Back/forward cache 诊断不符合原因;不要用挂载
unload“修复”返回状态。 - 检查隐藏期间不再执行非必要轮询;音频等明确由读者启动的工作按产品策略处理,不能因 hidden 一律停止或自行恢复播放。
參考:Chrome Page Lifecycle API、MDN visibilitychange、MDN pagehide、MDN pageshow、web.dev bfcache。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。