PhotoImg 组件(6 行)
function PhotoImg({ src, alt, className, imgClassName, showSkeleton: _showSkeleton }) {
return (
<div className={`relative overflow-hidden ${className ?? ''}`}>
<img src={src} alt={alt ?? ''} decoding="async"
className={`w-full ${imgClassName ?? ''}`} />
</div>
);
}
页面级预加载(7 行)
useEffect(() => {
albums.forEach(album => {
new window.Image().src = album.cover;
album.photos.slice(0, 3).forEach(p => {
new window.Image().src = p.url;
});
});
}, []);
改动对比
项目原始版最终版PhotoImg单层 <img>单层 <img decoding="async">模糊层无无淡入动画无无状态管理无无预加载无useEffect 预加载所有封面CSS 冗余animate-img-reveal已删除
技术要点
decoding="async"
告诉浏览器在后台线程解码图片,不阻塞主线程。页面过渡动画不受图片解码干扰。
页面级预加载
PhotoWallClient 挂载时用 new Image().src 预加载所有相册封面和前 3 张照片,后续 PhotoImg 渲染时图片数据大概率已在浏览器缓存中。
架构极简
不引入模糊层、淡入动画、ref 回调、状态切换等任何复杂逻辑。浏览器原生行为处理缓存命中和渐进加载,零额外状态、零额外渲染。
迭代试错总结
尝试结果img.decode() 离屏预解码DOM <img> 仍需重解码双层图片 (模糊 + 清晰)水波纹闪烁、灰底CSS blur-sm 模糊遮罩GPU 负担导致动画卡顿CSS transition-opacity 淡入白底/黑底透出CSS animation animate-img-reveal缓存命中时仍有闪烁useLayoutEffect 缓存检测仍无法消除首帧闪烁useState 初始化缓存检测检测不可靠,未命中时显示灰底backdrop-blur父容器白底挡住无效shimmer 骨架用户认为是灰底CSS 渐变占位用户认为是灰底Canvas 重编码基线 JPEG绕过缓存,动画变卡
核心教训:
- 缓存命中的首帧渲染无法中途取消,任何基于 ref callback 的缓存检测都会导致首帧闪烁
- 双层图片方案让模糊层和清晰图同时可见一瞬间,产生水波纹
- CSS
filter:blur()多图同时渲染时 GPU 负担过重,导致页面过渡动画掉帧 - 最简方案反而是最鲁棒的——单层
<img>+decoding="async"+ 页面预加载
性能特征
缓存命中浏览器原生即时渲染,0ms 延迟未命中渐进式 JPEG 逐通道解码,decoding="async" 保证不阻塞动画页面过渡无额外 GPU 负担(无 blur filter),动画流畅预加载useEffect 在挂载后立即启动,PhotoImg 渲染时数据大概率已缓存

