freeze事件是唯一能同步保存状态的时机,因其在页面冻结前最后执行且支持DOM读取、localStorage写入和JSON序列化;visibilitychange仅表示页面不可见而非即将冻结,pagehide在persisted为false时页面已销毁,均不可靠。

freeze 事件是唯一能同步保存状态的时机,错过它,后续所有 JS 都会被挂起,localStorage.setItem() 都可能写不进去。
为什么不能靠 visibilitychange 或 pagehide 单独保存
visibilitychange 只表示页面不可见,不代表即将冻结——桌面浏览器切到其他窗口时它会触发,但页面根本没被冻结;而 iOS Safari 在锁屏后可能直接跳过 visibilitychange,JS 就被硬性挂起。pagehide 本身也不区分用户导航离开还是系统冻结,event.persisted === false 时页面大概率已销毁,再存也没用。
常见错误现象:document.addEventListener('visibilitychange', () => { localStorage.setItem('draft', value); }) 在 iOS 真机上锁屏后根本没执行完,value 永远没存上。
- visibilitychange 是“用户看不见了”,不是“JS 快死了”
- pagehide 在 event.persisted === false 时,说明页面正被彻底卸载,不适合做状态快照
- beforeunload 更不可靠:只能同步执行、不能发请求、现代浏览器还限制弹窗
freeze 事件里必须做的三件事
freeze 是浏览器冻结页面前最后一个可同步执行 JS 的钩子,此时 DOM 可读、localStorage.setItem() 可写、JSON.stringify() 能跑完——但之后所有异步操作(包括 await indexedDB.put())都会被静默丢弃。
立即学习“前端免费学习笔记(深入)”;
- 立即调用状态序列化函数,例如
saveState(),只保留可 JSON 化的原始值:window.scrollY、input.value、new Date().toISOString() - 用
{ once: true }绑定,避免重复触发:window.addEventListener('freeze', handler, { once: true }) - 加 try/catch 包裹
JSON.stringify()和localStorage.setItem(),防止因不可序列化字段(如函数、DOM 节点)导致整个保存流程中断
pagehide + event.persisted === true 是兜底关键
iOS Safari 16.4 之前、部分安卓 WebView 根本不触发 freeze,而是直接走 pagehide 并把 event.persisted 设为 true。只监听 freeze 会漏掉这部分用户。
- 在
pagehide回调中先判断e.persisted === true,再检查是否已由freeze处理过(例如用闭包变量frozen = false标记) - 加时间防抖:用
performance.now()记录上次保存时间,200ms 内不再触发第二次写入 - 别在
pagehide中读取element.getBoundingClientRect()或offsetHeight—— 此时布局可能已失效,返回0
恢复时 pageshow 不等于“可以放心还原”
pageshow 会在页面从 bfcache 或冻结中恢复时触发,但也会在普通刷新后触发(此时 event.persisted === false)。如果无脑恢复,刚刷新完又把旧草稿刷回去。
- 只在
event.persisted === true且localStorage.getItem('uiState')非空时才尝试还原 - 还原后立刻
localStorage.removeItem('uiState'),避免下次打开又恢复(除非你明确要跨会话持久化) - 注意 DOM 可能还没 ready:不要在
pageshow中直接操作未挂载的元素,优先用requestAnimationFrame延迟一帧再同步 UI
真正难的不是写几行监听代码,而是理解 freeze 不是“建议保存”,而是“最后机会”;pagehide.persisted 不是“缓存成功”,而是“补救开关”。这两个信号的组合逻辑和防重机制,才是移动端真机测试时最容易翻车的地方。



















