确认:根因是 Chrome Freeze(标签页冻结)机制
你的判断非常准确!我之前分析的"macOS 睡眠导致 GPU Context 丢失"是触发条件,而 Chrome Freeze 是直接机制。两者叠加导致了图片不渲染的问题。
先看快速复现结果及简单fix lazyimage



Chrome Freeze 机制详解
什么是 Chrome Freeze?
从 Chrome 133 开始,Chrome 引入了更激进的标签页冻结(Tab Freezing) 机制:
当标签页在后台停留时间过长,或被系统判定为"非活跃"状态时,Chrome 会冻结(Freeze) 该标签页的所有 JavaScript 任务、定时器、渲染更新和 GPU 资源。
Freeze 和 Discard 的区别
| 机制 | 触发条件 | 效果 | 恢复方式 |
|---|---|---|---|
| Freeze(冻结) | 后台停留 5 分钟以上 / 系统内存压力大 | 暂停 JS 执行、渲染、GPU 合成;DOM 和内存状态保留 | 切回标签页时自动解冻,但不会自动重绘 |
| Discard(废弃) | 内存严重不足 / chrome://discards 手动触发 | 完全卸载页面,释放所有资源;需要重新加载 | 切回标签页时重新加载整个页面 |
你的场景:Chrome Freeze + macOS 睡眠
完整链条:
合盖睡眠 (macOS)
↓
Chrome 进入后台 → Chrome 判定为"非活跃"
↓
触发 Freeze(冻结标签页)
↓
暂停渲染、GPU 合成、JS 任务
↓
GPU 显存中的图片像素数据被释放(但不释放 DOM 和 HTTP 缓存)
↓
唤醒后,Chrome 解冻标签页
↓
❌ 但合成器(Compositor)没有主动触发重绘
↓
图片空白(因为 DOM 在,但像素数据没了)
为什么滚动一下就好了?
滚动触发 IntersectionObserver → 虚拟列表 unmount/remount <img> → 强制合成器重新绘制 → 图片恢复。
我们的解决方案就是:不等用户滚动,在唤醒时主动触发可见图片的重绘。
验证方法
最快验证:使用 chrome://discards
- 打开新标签页,访问
chrome://discards - 找到你的页面标签页
- 点击 Discard(这会立即触发类似 Freeze 的资源回收)
- 切回你的页面
- 如果图片空白 → 确认是 Freeze/Discard 机制导致
查看标签页状态
在 chrome://discards 中,你可以看到每个标签页的:
- 状态:Active / Frozen / Discarded
- 最后活跃时间
- 内存占用
如果你的标签页显示为 Frozen,说明被冻结了。
修正确认后的解决方案
既然根因是 Chrome Freeze + 唤醒后未自动重绘,我们的方案可以更简单、更精准:
简化版 Hook(针对 Freeze 场景)
💡 注意:Freeze 唤醒时一定会触发
visibilitychange(因为页面从后台切回前台),所以不需要心跳检测和pageshow事件了。
import { useState, useEffect, useRef, useCallback } from 'react';
interface UseWakeUpRefreshOptions {
scrollRoot?: HTMLElement | null;
rootMargin?: string;
threshold?: number;
}
export function useWakeUpRefresh(options: UseWakeUpRefreshOptions = {}) {
const { scrollRoot, rootMargin = '100px', threshold = 0.01 } = options;
const [inView, setInView] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [node, setNode] = useState<HTMLElement | null>(null);
const inViewRef = useRef(inView);
inViewRef.current = inView;
// 主动检测元素是否在视口内
const checkInView = useCallback((): boolean => {
if (!node) return false;
const rect = node.getBoundingClientRect();
const rootEl = scrollRoot ?? document.documentElement;
if (!rootEl) return false;
const rootRect = rootEl.getBoundingClientRect();
return rect.top < rootRect.bottom && rect.bottom > rootRect.top;
}, [node, scrollRoot]);
// 强制刷新:仅在页面可见且元素在视口内时触发
const forceRefresh = useCallback(() => {
if (document.visibilityState === 'visible') {
const visible = checkInView();
if (visible) {
// 如果元素可见但 inView 为 false,先修正
if (!inViewRef.current) {
setInView(true);
}
// 触发图片重绘
setRefreshKey(prev => prev + 1);
}
}
}, [checkInView]);
// 监听唤醒(Freeze 解冻 + 切回前台)
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 使用 requestAnimationFrame 确保布局稳定
requestAnimationFrame(() => {
forceRefresh();
});
}
};
// focus 事件捕获用户点击/唤醒
const onFocus = () => {
requestAnimationFrame(() => {
forceRefresh();
});
};
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('focus', onFocus);
// 初始化检测
const timer = setTimeout(() => {
requestAnimationFrame(() => {
const visible = checkInView();
if (visible) setInView(true);
});
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('focus', onFocus);
};
}, [forceRefresh, checkInView]);
// IntersectionObserver 用于滚动时的懒加载
useEffect(() => {
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setInView(true);
}
},
{ root: scrollRoot ?? null, rootMargin, threshold }
);
observer.observe(node);
return () => observer.disconnect();
}, [node, scrollRoot, rootMargin, threshold]);
return {
ref: setNode,
inView,
refreshKey,
forceRefresh,
};
}
为什么这次一定能解决?
| 之前的问题 | 现在的修复 |
|---|---|
inView 是单向门,唤醒后不更新 | 唤醒时主动调用 checkInView() 修正 |
visibilitychange 检测到但 inView 为 false | forceRefresh 中主动检测并修正 inView |
| 依赖 IntersectionObserver 被动触发 | 唤醒时主动检测,不等待 Observer |
担心 visibilitychange 不触发 | Freeze 唤醒时必定触发 visibilitychange |
测试方法(10 秒验证)
# 1. 打开页面,滚动让图片加载
# 2. 新标签页打开 chrome://discards
# 3. 找到你的页面,点击 "Discard"
# 4. 切回你的页面
# 5. ✅ 图片应该自动恢复
如果还是空白,在 Console 中执行:
// 手动触发刷新
document.dispatchEvent(new Event('visibilitychange'));
看图片是否恢复。如果恢复了,说明 forceRefresh 逻辑没问题,只是事件监听没触发,可以检查 visibilityState 是否正确。
总结
| 维度 | 说明 |
|---|---|
| 根因 | Chrome 在后台冻结(Freeze)标签页,释放 GPU 显存,唤醒后未主动重绘 |
| 触发条件 | 合盖睡眠 / 后台停留过久 / 系统内存压力 |
| 关键证据 | chrome://discards 中标签页显示为 Frozen / Discarded |
| 解决方案 | 监听 visibilitychange + focus,唤醒时主动检测可见性并刷新 key |
| 检测方法 | chrome://discards 点击 Discard,10 秒内验证 |