Skip to main content

难点:macOS 睡眠,Chrome 唤醒后没有主动触发重绘


难点:macOS 睡眠唤醒后 Chrome 图片不渲染

适用于:项目复盘 / 面试 / 晋升答辩 / 技术分享


一、项目背景

我们的项目是一个面向企业的内容管理平台,核心功能之一是在类聊天/消息流中展示大量图片。消息流采用虚拟列表(@tanstack/react-virtual) 支撑数千条消息的流畅滚动,图片组件使用 IntersectionObserver 实现懒加载。

技术栈:

  • React 18 + TypeScript
  • @tanstack/react-virtual(虚拟列表)
  • IntersectionObserver(懒加载)

二、问题现象

用户反馈

“第二天打开电脑,切回页面,所有图片都变成空白了。上下滚动一下,图片又出来了。”

精确复现路径

操作是否复现
合盖睡眠 → 过夜/数小时 → 唤醒 → 切回该标签页必现
单纯切换 Chrome 标签页再切回来❌ 不复现
切换到其他 Mac 应用再切回来❌ 不复现
锁屏再解锁❌ 不复现
刷新页面(F5)✅ 恢复正常
切到后台 5 分钟以上再切回⚠️ 部分复现

为什么是“难点”?

难点说明
复现周期长每次复现需要等过夜/睡眠数小时,调试效率极低
现象诡异“滚动就好,不滚动就坏”,直觉上难以理解
涉及层面多操作系统(macOS)+ 浏览器(Chrome)+ 前端框架(React)+ 虚拟列表
网上资料误导大多归因于“Chrome 节能模式”,但关闭后问题依旧

三、排查过程

第一阶段:排除常见嫌疑

❌ 猜测 1:Chrome 内存节省模式

  • 关闭 chrome://settings/system 中的“内存节省程序”——无效
  • 将网站加入“始终保持活跃”白名单——无效

❌ 猜测 2:网络问题 / HTTP 缓存失效

打开 DevTools Network 面板,切回页面时截图:

Network 面板截图

指标数值含义
Requests0/139没有发起任何网络请求
Transferred0.0 kB / 3,442 kB网络传输为 0
Resources10,082 kB资源总大小仍在

结论:HTTP 缓存数据完好,图片字节没丢,但浏览器连“从缓存读取”的动作都没做。

❌ 猜测 3:React 组件没有重新渲染

检查源码,发现 inView 是“单向门”:

if (entries[0]?.isIntersecting) setInView(true); // 一旦 true 永不回头

切回页面时,组件状态不变,React 确实不重新渲染。但这是放大因素,不是根本原因

❌ 猜测 4:“src 没变,所以浏览器不重新解码”

这个说法站不住脚。Chromium 的图片绘制与 src 是否变化没有强绑定,只要触发 paint invalidation,就应该重新走 decode。问题是根本没有触发 paint invalidation


第二阶段:用 Chrome 内部工具定位根因

🔍 关键操作:chrome://discards

  1. 打开新标签页,访问 chrome://discards
  2. 找到我们的页面标签页,状态显示为 Frozen(冻结)
  3. 点击 Discard 按钮
  4. 切回页面 → 图片空白 → 立即复现

这排除了“必须等过夜”的限制,10 秒内即可复现

chrome://discards 截图

🔍 控制变量法验证

场景是否复现结论
后台 1 分钟切回未触发 Freeze
后台 5 分钟切回⚠️ 偶现可能触发 Freeze
后台 10 分钟切回✅ 必现已触发 Freeze
chrome://discards 手动 Discard✅ 必现模拟 Freeze 后的资源回收
合盖睡眠过夜✅ 必现Freeze + GPU 释放叠加

第三阶段:锁定真正原因

完整因果链

用户合盖睡眠 / 标签页后台停留过久

macOS 进入睡眠,释放 GPU/Metal Context
同时 Chrome 检测到标签页非活跃

触发 Chrome Freeze(标签页冻结)

暂停 JavaScript 执行、渲染管道、GPU 合成
释放 GPU 显存中的图片像素数据(解码后的位图)

⚠️ DOM 节点和 HTTP 缓存字节保留

用户唤醒 Mac,切回标签页

Chrome 解冻(Unfreeze)标签页

❌ 合成器(Compositor)没有自动触发全量重绘

图片空白(DOM 在,HTTP 缓存在,但 GPU 显存空了)

用户滚动 → 虚拟列表重建可见图片的 DOM

✅ 新 <img> 强制触发“从缓存读取 → 重新解码 → 渲染”

根因总结

层级机制作用
操作系统层macOS 睡眠释放 GPU/Metal Context清空了 GPU 显存中的像素数据
浏览器层(主因)Chrome Freeze 冻结标签页暂停了渲染管道,解冻后未触发重绘
渲染层合成器未触发 paint invalidation浏览器认为“页面没变”,跳过重绘
应用层(放大)React inView 单向门组件状态不变,不触发重新渲染
虚拟列表层(放大)仅渲染可见区域不滚动就不重建 DOM,图片永远不会恢复

四、解决方案

核心思路

监听页面唤醒/解冻,强制当前可见的 <img> 重新走一遍 mount → decode 流程

等价于“用户滚动”的效果,但自动化触发。


关键技术挑战

挑战 1:如何可靠检测“唤醒/解冻”?

visibilitychange 在大部分场景下有效,但有一个边界情况

如果睡眠时该标签页本来就是最前台窗口,唤醒后 document.visibilityState 一直为 visiblevisibilitychange 不会触发

解决方案:使用心跳计时器检测时间跳变作为兜底。

let lastTick = Date.now();

setInterval(() => {
const now = Date.now();
const drift = now - lastTick;
lastTick = now;

// 正常间隔约 5000ms,如果远超预期(>15s),说明系统被挂起过(睡眠)
if (drift > 15000) {
forceRefresh();
}
}, 5000);

挑战 2:如何只刷新可见区域,不影响性能?

不能全量刷新(1000+ 张图会卡死),也不能用 img.src = img.src(触发完整资源加载,太重)。

解决方案:利用虚拟列表的 getVirtualItems() + React key 机制。

const virtualizer = useVirtualizer({ /* ... */ });
const [refreshMap, setRefreshMap] = useState<Record<number, number>>({});

function forceRefresh() {
// 只拿当前可见的 item(含 overscan)
const visibleItems = virtualizer.getVirtualItems();
setRefreshMap(prev => {
const next = { ...prev };
visibleItems.forEach(v => {
next[v.index] = (next[v.index] ?? 0) + 1;
});
return next;
});
}

// 渲染时,key 变化会触发 React unmount + remount
<div key={`${virtualItem.key}-${refreshMap[virtualItem.index] ?? 0}`}>
<img src={...} />
</div>

最终方案:可复用的 useWakeUpRefresh Hook

将上述逻辑封装成一个独立的 Hook,供所有图片组件复用。

Hook 完整代码

import { useState, useEffect, useRef, useCallback } from 'react';

interface UseWakeUpRefreshOptions {
scrollRoot?: HTMLElement | null;
rootMargin?: string;
threshold?: number;
/** 时间跳变阈值(毫秒),默认 15000 */
wakeThreshold?: number;
/** 心跳间隔(毫秒),默认 5000 */
heartbeatInterval?: number;
}

export function useWakeUpRefresh(options: UseWakeUpRefreshOptions = {}) {
const {
scrollRoot,
rootMargin = '100px',
threshold = 0.01,
wakeThreshold = 15000,
heartbeatInterval = 5000,
} = 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 状态(应对唤醒后 Observer 未触发的情况)
if (!inViewRef.current) {
setInView(true);
}
setRefreshKey(prev => prev + 1);
}
}
}, [checkInView]);

// 监听唤醒 + 心跳检测
useEffect(() => {
let lastTick = Date.now();

// 1. visibilitychange:切回标签页/Freeze 解冻
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
requestAnimationFrame(() => forceRefresh());
}
};

// 2. focus:用户点击/唤醒
const onFocus = () => {
requestAnimationFrame(() => forceRefresh());
};

// 3. pageshow:bfcache 恢复
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) {
requestAnimationFrame(() => forceRefresh());
}
};

// 4. 心跳检测时间跳变(覆盖"睡眠时在最前台")
const heartbeat = setInterval(() => {
const now = Date.now();
const drift = now - lastTick;
lastTick = now;
if (drift > wakeThreshold) {
requestAnimationFrame(() => forceRefresh());
}
}, heartbeatInterval);

// 5. 初始化检测(防止初次加载时 Observer 未及时触发)
const initTimer = setTimeout(() => {
requestAnimationFrame(() => {
const visible = checkInView();
if (visible) setInView(true);
});
}, 100);

document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('focus', onFocus);
window.addEventListener('pageshow', onPageShow);

return () => {
clearTimeout(initTimer);
clearInterval(heartbeat);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('focus', onFocus);
window.removeEventListener('pageshow', onPageShow);
};
}, [forceRefresh, checkInView, wakeThreshold, heartbeatInterval]);

// 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,
};
}

使用示例:重构 LazyImage

function LazyImage({ fileId, file, onPreview, scrollRoot, compact }) {
const [failed, setFailed] = useState(false);
const url = fileApi.getRawUrl(fileId);

const { ref, inView, refreshKey } = useWakeUpRefresh({
scrollRoot,
rootMargin: '100px',
});

// refreshKey 变化时重置失败状态,重新尝试加载
useEffect(() => {
setFailed(false);
}, [refreshKey]);

if (failed) {
return <div className={styles.imagePlaceholder}>Preview unavailable</div>;
}

return (
<div ref={ref} className={styles.imageThumbWrapper}>
<button onClick={() => onPreview(url)}>
{inView ? (
<img
key={`${fileId}-${refreshKey}`}
src={url}
onError={() => setFailed(true)}
/>
) : (
<div className={styles.imagePlaceholder}>Loading...</div>
)}
</button>
</div>
);
}
export default function InlineImageLink({ url, onPreview, variant, scrollRoot, fallbackToLinkCard }) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);

const { ref, inView, refreshKey } = useWakeUpRefresh({
scrollRoot,
rootMargin: '120px',
});

const hostname = getUrlHostname(url);

useEffect(() => {
setLoaded(false);
setFailed(false);
}, [refreshKey]);

// ... 渲染逻辑
return (
<figure ref={ref}>
<button>
{inView ? (
<img
key={`${url}-${refreshKey}`}
src={url}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : (
<div className={styles.shimmer} />
)}
</button>
</figure>
);
}

五、验证方法

快速验证(10 秒内)

# 方法1:chrome://discards
1. 打开页面,滚动让图片加载
2. 新标签页打开 chrome://discards
3. 找到目标页面,点击 "Discard"
4. 切回目标页面
5. ✅ 图片应自动恢复(无需滚动)

# 方法2:后台等待 5-10 分钟
1. 打开页面,加载图片
2. 切换到其他标签页,等待 5-10 分钟
3. 切回原页面
4. ✅ 图片应自动恢复

验证结果

修复效果


六、技术收获

对浏览器的深入理解

层级关键认知
Chrome Freeze后台标签页冻结机制,会暂停渲染和 GPU 合成
chrome://discards可手动触发资源回收,是调试此类问题的利器
渲染管线显存(解码像素)≠ HTTP 缓存(原始字节),两者生命周期独立
Paint Invalidationsrc 不变 ≠ 不重新解码,核心是“是否触发了绘制失效”

对 React 渲染的深入理解

知识点应用
key 机制改变 key 是强制重建 DOM 的最可靠手段
虚拟列表 APIgetVirtualItems() 天然提供了“只刷新可见区域”的能力
onError 的局限显存丢失不是“错误”,onError 不会触发
状态设计单向门(inView 只进不出)在特殊场景下可能成为陷阱

前端 Debug 方法论

  1. 控制变量法:用“切标签页 vs 睡眠 vs chrome://discards”精准隔离变量
  2. Network 面板:判断是网络层还是渲染层的问题
  3. 不盲信“网上说的”:关闭 Chrome 节能模式无效,说明问题不在那
  4. 善用浏览器内部工具chrome://discardschrome://process-internalschrome://gpucrash

七、面试/答辩常见追问

Q1:为什么不直接用 img.src = img.src 这种 hack?

  • 会触发完整的资源加载流程(含缓存验证),比 key 方案更“重”
  • 可能触发额外的 304 请求
  • key 方案更可控、更干净,且利用 React 自身的渲染机制

Q2:refreshKey 变化会导致所有可见图片闪烁吗?

不会。refreshKey 只在唤醒/解冻检测触发时变化,一天可能只触发 1-2 次。正常滚动不会触发,不影响用户体验。

Q3:这个方案有没有兼容性问题?

API兼容性
visibilitychangeIE10+,所有现代浏览器
requestAnimationFrameIE10+,所有现代浏览器
IntersectionObserver需要 polyfill(但项目已用)
pageshow所有现代浏览器

Q4:问题的根因是 Chrome 的 Bug 还是 macOS 的 Bug?

更准确地说,是 Chrome Freeze 机制在 macOS 上的行为缺陷

  • macOS 睡眠释放 GPU 资源是正常行为
  • Chrome Freeze 暂停渲染管道也是设计行为
  • 问题出在:解冻后合成器(Compositor)没有主动触发全量重绘

Chrome 的 Bug Tracker 上有大量相关 Issue,长期未彻底修复。我们的方案是在前端层面做兜底。

Q5:如果用户禁用 JavaScript,这个方案还生效吗?

不生效。但我们的产品是 Web 应用,禁用 JS 无法使用,所以不是问题。


八、一句话总结

Chrome 在后台冻结标签页并释放 GPU 显存,唤醒后合成器未主动触发重绘,导致图片空白。解决方案是用 visibilitychange + 心跳检测捕获唤醒事件,通过改变 React key 强制重建可见图片的 DOM,让浏览器从 HTTP 缓存中重新解码渲染。


附录:完整代码文件

hooks/useWakeUpRefresh.ts

[完整代码见上文 Hook 实现部分]

components/LazyImage.tsx

[完整代码见上文使用示例部分]

components/InlineImageLink.tsx

[完整代码见上文使用示例部分]