存储整理列表滚动空白:44px 的格子不该拉原图
现象:滚动条在走,页面却“人间蒸发”
在后台的「存储整理」(/admin/storage)页面,当你试图快速滚动长长的文件列表时,会触发一个非常诡异的现象:
整片列表瞬间变成大面积的空白底色。
这不是常见的“骨架屏还在、只是图片在转圈等待加载”,而是整行内容彻底凭空消失了——没有文件名、没有文件体积、没有分割线,什么都没有。只有当你放慢滚动速度,或者松开鼠标滚轮停下来歇一两秒,列表内容才会像是刚缓过神来一样,“刷”地一下突然冒出来。
![[Pasted image 20261017101207.png]]
排查思路:排除常规嫌疑
这种“只要停下来内容就能恢复”的特征,直接排除了纯粹的接口数据问题——如果是后端接口慢或数据没拿到,那你停多久它也不可能自己弹出来。这显然是浏览器渲染管线被打爆了,来不及画出来。
按照前端排查渲染卡顿的常规套路,我先自查了三件事,结果全部推翻了最初的假设:
| 怀疑方向 | 检查方式 | 结论 |
|---|---|---|
| 双滚动容器冲突 外层内层都有滚动,事件错乱 | 从 admin layout 一层层排查到列表最内层容器 | 整个页面只有一层 overflow-y-auto,外层 .flex h-screen overflow-hidden 并没有产生滚动,排除冲突。 |
| CSS 合成层/渲染优化反噬 如 will-change 导致图层过大,或 content-visibility 留白 | 全仓库检索相关 CSS 属性 | will-change / translateZ(0) 只用在了个别滑块组件上;content-visibility 仅用在公开的博客列表,存储整理页面完全没用。 |
| 虚拟滚动配置不当 以为写了虚拟化但上下缓冲区配得太小 | 全文搜索 useVirtualizer / overscan / translateY | 破案了:整个存储整理页压根就没有做任何虚拟化,接口返回多少条,前端就老老实实 visibleFiles.map() 全部硬塞进 DOM。 |
第三行结论揭开了问题的一半面纱:测试桶里随随便便就有 400 多行,满载时甚至能到 850+ 行。几百个复杂的行节点全部常驻 DOM,整个列表撑出的实际高度超过两万五千像素。
但这依然不能完全解释为什么会“整片大白屏”——现代浏览器渲染几百个常规文本 DOM 还不至于当场休克。肯定还有更沉重的包袱压在上面。
真正的原因:两个性能杀手的致命乘法
顺着代码继续往下挖,终于揪出了幕后黑手。这不是单一维度的瓶颈,而是两个问题叠在一起产生的性能雪崩:
1. 44px 的指甲盖预览框,拉的是上千万像素的原图
列表最左侧有一个小巧精致的图片预览格(size-11,即 44×44 像素):
function FileThumb({ file, onClick }: { file: StorageFile; onClick: () => void }) { const showImage = isImageFile(file.key) && Boolean(file.url) && !failed // ... <img src={file.url} loading="lazy" className="size-full object-cover" />仔细看这里的 file.url——它指向的居然是对象存储里的原始高清大图!
图库里的照片大多是单反或手机直出,单张体积在 1~3MB 之间,分辨率普遍在 4096×3072 以上。这意味着,浏览器为了在这块 44px 的小格子里展示一张缩略图,必须在后台完整下载几兆的数据,并在 CPU 主线程里把这张上千万像素的图像完整解压成未压缩的位图,然后再做高质量缩放。
可能有人会问:“代码里不是写了 loading="lazy" 吗?”
loading="lazy" 给很多人带来了虚假的安全感。 懒加载确实能保证“屏幕外的内容先不加载”,但在快速滚动场景下,视口在以每秒几千像素的速度飞速扫过列表,一瞬间会有几十上百张大图同时越过视口边界,毫无防备地并发触发懒加载。
主线程瞬间涌入几十个千万像素级图像的解码任务。CPU 算力全部被解图和缩放占满,连这一帧最基础的文字与背景栅格化(Rasterization)都无暇顾及。浏览器为了不让整个页面彻底卡死无响应,只能采取保命策略——来不及绘制的区域先填一层背景底色(在图形学和浏览器引擎中常称为 checkerboarding 现象)。
这也完美解释了为什么“滚得快就白屏,停下来等会儿就恢复”。
2. 全量 DOM 让重排重绘雪上加霜
如果只是几张大图,浏览器咬咬牙还能挺过去;如果是纯文本列表,几百个 DOM 也不至于瞬间休克。但现实是:400~800+ 行完整的 DOM 节点常驻内存,每行还绑着一张在后台疯狂解码的千万像素大图。
布局成本(Layout)与大图解码的开销在每一帧里做了乘法。因此,优化方案不能单点突破,必须双管齐下:
- 网络与解码降级:把图片请求体积从 MB 级压到几十 KB 的真实缩略图;
- 渲染节点降级:通过虚拟滚动,把几百行常驻 DOM 砍到仅保留可视区域的二三十行。
方案一:让缩略图名副其实(44px 就该吃 44px 的粮)
服务端早就算好了缩略图,只是漏送了
在翻看后端存储处理逻辑时,我发现了一个哭笑不得的细节:服务端在图片上传时早就在后台异步生成了缩略图(thumbnailUrl),在文件扫描逻辑里也准确判断了该文件是否有缩略图(hasThumb)。
但偏偏在返回给前端列表的 DTO 数据结构里,把缩略图地址给“漏”掉了:
// server/lib/storage/types.ts —— 列表接口返回的 StorageFileexport interface StorageFile { key: string size: number lastModified: string url: string // 只有原图 URL!}后端辛辛苦苦切好了小图,前端因为拿不到地址,只能无可奈何地去啃原图。
改动 1:扫描接口补齐字段(带防 404 机制)
在 hono/storage.ts 中给返回的数据行补充 thumbnailUrl 字段:
return { key: file.key, url: file.url, // 必须确保缩略图真实存在时才拼接地址,否则前端会去拉一个必然 404 的无效请求 thumbnailUrl: hasThumb && photo?.thumbPath ? storageProvider.getUrl(photo.thumbPath) : undefined, size: file.size, // ...}这里有一个至关重要的业务考量:绝不能无脑给每行都拼缩略图 URL。
扫描文件列表时,本身就会扫出一部分“尚未生成缩略图”或“缩略图丢失”(missing_thumbnail)的脏数据。如果不管三七二十一都拼上 URL,前端在渲染列表时就会瞬间打出几十个注定 404 的 HTTP 请求,不仅浪费网络连接,还会让浏览器控制台满江红。因此,必须以 hasThumb && photo?.thumbPath 双重验证为前置条件。
改动 2:前端消费——优雅类型与防呆三级回退
避开繁琐的跨包依赖,用交叉类型快速落地
StorageFile 属于 packages/api-client 共享包(mo-gallery-shared 镜像仓库),如果直接改共享包协议,需要跑包同步(pnpm sync)并分别在多端提交代码,成本过高。既然只是前端列表消费端需要一个可选属性,直接利用 TypeScript 的交叉类型扩展即可:
type ScanFile = StorageFile & { thumbnailUrl?: string }精细化状态记录与异步解码
前端组件改造为:缩略图优先 -> 原图兜底 -> 都挂了才走占位图标。
function FileThumb({ file, onClick }: { file: ScanFile; onClick: () => void }) { const [thumbFailed, setThumbFailed] = useState(false) const [originalFailed, setOriginalFailed] = useState(false)
// 缩略图可用时优先用缩略图,加载失败或没有时回退原图 const src = file.thumbnailUrl && !thumbFailed ? file.thumbnailUrl : file.url
return ( // ... <img src={src} loading="lazy" decoding="async" onError={() => { if (file.thumbnailUrl && !thumbFailed) { setThumbFailed(true) // 缩略图失败了,退回去拉原图试试 } else { setOriginalFailed(true) // 原图也挂了,彻底放弃并展示占位图 } }} className="size-full object-cover" /> )}这里有两个极易被忽视但极其重要的细节:
- 失败状态必须区分“具体哪个地址失败了”,而不能只用一个布尔值
failed: 如果只用一个failed标记,一旦缩略图 404,整个格子会立刻被一竿子打死变成占位图,哪怕原图完好无损也看不到了;更严重的是,当管理员点击后台的“重新生成缩略图”修复之后,这个粗糙的failed状态并不会自动重置,页面依然会一直显示占位图。将失败状态细分为thumbFailed和originalFailed,逻辑闭环且支持自愈。 - 显式声明
decoding="async": 这个属性允许浏览器把图片解码彻底丢给子线程异步处理,防止占用渲染主线程。单张图片感受不明显,但在百图滚动的高并发下,这是保住主线程帧率的灵丹妙药。
方案二:列表虚拟化(只渲染你眼睛能看到的那几行)
消除了大图解码的冲击,接下来要解决几百行 DOM 的布局和内存浪费。
先手写 Hook,却被 React Hooks 规则“上一课”
一开始我想本着“能不装新库就不装新库”的原则手写一个精简的虚拟列表 Hook:取第一行高度计算行高,监听滚动事件配合 requestAnimationFrame 节流,计算出可视窗口的 start 和 end 索引,上下用 padding 撑起实际滚动条高度,仅把视口上下各 10 行放进 DOM。
思路没问题,DOM 数量瞬间从 400+ 骤降到 30 行左右,性能提升显著。但在构建检查时,新版 eslint-plugin-react-hooks 的 react-hooks/refs 规则报了整整 10 处错误:
// ❌ 错误示范:把 ref 和 render 期间需要直接读取的变量揉在同一个返回对象里return { scrollRef, start, end, paddingTop, paddingBottom, measureRow }ESLint 严厉警告:Cannot access refs during render。因为组件在渲染阶段(Render Phase)需要读取 start、end 来切片数据,而这些值跟 scrollRef 捆绑在一个对象中,被 React 严格模式视为“在渲染期间存在隐式读取 ref 的副作用风险”。
经验教训:在编写自定义 Hook 时,一旦需要同时暴露 ref 和组件在 render 期间必须消费的状态值,切记通过元组(Tuple)将其严格隔离,或者将 ref 的操作收拢在函数内:
// ✅ 正确做法:元组隔离const [listScrollRef, rowWindow] = useRowVirtualizer({ count: visibleFiles.length })转向工业级方案:拥抱 react-virtuoso
手写 Hook 虽然修好了校验,但长列表滚动还有很多边界情况(例如动态高度计算、滚动白屏垫片、触摸屏惯性滚动等)。为了系统的健壮性,最终决定引入成熟的 react-virtuoso(^4.18.19)。
相比于偏底层的 @tanstack/react-virtual,react-virtuoso 对这种高频率滚动的列表有非常贴心的防御机制(如 scrollSeek 可以在快速滑动时直接先显示轻量骨架而不是直接留白):
<Virtuoso data={visibleFiles} computeItemKey={(_, file) => file.key} increaseViewportBy={{ top: 600, bottom: 900 }} itemContent={(_, file) => <FileRow key={file.key} file={file} />}/>increaseViewportBy:设置视口外的预渲染缓冲区(上方提前渲染 600px,下方提前渲染 900px)。相当于给滚动方向做了“预热”,等你滑过去的时候内容早就已经就绪,肉眼完全感知不到虚拟化的存在。- 状态联动与 Key 重建:当切换存储源、更改筛选状态、搜索关键词或切换目录时,列表容器需要重新绑定
key进行局部重置。否则虚拟化容器内部仍会保留上次浏览的滚动偏移量,导致切完筛选条件后列表停留在半中间,给用户一种“列表空了/没搜出来”的错觉。
核心底线:虚拟化绝不能污染业务数据源!
虚拟化改造有一条万万不可逾越的红线:它只能改变渲染展示层,绝对不能污染原始业务数据。
列表上的复选框多选、批量清理、批量删除、目录过滤等操作,必须始终基于完整的 visibleFiles 数组去运算。如果不小心把业务操作的数据源换成了虚拟化切片后的局部数组,那么用户点击“全选并删除”时,就会悄无声息地只删掉了屏幕上露出来的这十几条,这在文件管理系统中是极其危险的灾难级 Bug。
桌面端(Desktop):同病相怜,但有异构存储源的考量
Web 端的修缮立竿见影,回过头来看基于 Go + Wails + React 的桌面客户端(emulsion-desktop-v3),发现它的「存储整理」(StoragePage.tsx)也患有完全一模一样的“44px 方格吃大图”的毛病。
理所当然要同步修复,但桌面端由于底层支持本地与云端多种形态,多了一层复杂度。
1. Go 后端 DTO 字段对齐与双源处理
Go 端的 DTO 同样需要补齐 ThumbnailURL 字段:
type StorageObjectDTO struct { // ... ThumbnailURL string `json:"thumbnailUrl,omitempty"`}桌面端有两种完全不同的存储源,处理策略也截然不同:
- 自部署 Web 站点存储源:
services/storage_web.go本质是 Web 端接口的透明代理。由于我们先改好了 Web 端,接口返回的 JSON 直接反序列化就能无缝映射出thumbnailUrl,Go 侧一行多余的代码都不用改。 - 本地/直连插件存储源(S3 / WebDAV 等):在
services/storage_maintenance.go中,需要根据列举出的文件建一张key -> URL的反查映射表,通过缩略图路径反查出最终可用地址:
if owner.HasThumb && owner.ThumbPath != "" { row.ThumbnailURL = urlByKey[owner.ThumbPath]}为什么一定要用表反查,而不是简单粗暴地用 publicUrl + "/" + thumbPath 去拼字符串?
因为插件存储源的规则极其多变:有的源走的是公网 CDN 域名,有的走的是带有时效性 Token 的私有签名链接,甚至有的只是本地磁盘的文件协议。如果你自作聪明在前端或服务层按固定规则去拼字符串,等于在强行要求所有外部插件都必须遵守某种特定目录约定——一旦猜错,不会有任何报错提醒,只会换来一片静默的 404。
2. 前端复用已有技术栈:@tanstack/react-virtual
桌面端并没有照搬引入 react-virtuoso,而是复用了它本身已有的 @tanstack/react-virtual(原本在相册网格 LocalAssetGrid 里就在使用)。
对于单行等高的规则列表,TanStack Virtual 已经足够轻便高效,避免为了一个页面徒增多余的依赖体积:
const rowVirtualizer = useVirtualizer({ count: visibleFiles.length, getScrollElement: () => scrollRef.current, estimateSize: () => 61, // 预估行高:py-2 + size-11 + 1px 边框,影响首帧占位 overscan: 12, // 上下各缓冲 12 行})3. 一个深思熟虑的“产品差异化”设计
在处理缩略图丢失时,Web 端和桌面端做出了截然不同的选择:
| 平台 | 缩略图丢失时的表现 | 背后考量 |
|---|---|---|
| Web 端 | 回退加载并显示原图 | Web 端侧重“内容消费与浏览”,能看到图片永远比看到空白或警告更重要。 |
| 桌面端 | 坚决展示橙色“缺失”警告占位符 | 桌面端定位为“管理与维护工具”,列表提供了“一键重新生成缩略图”的能力。保留缺失状态是明确提醒管理员这里有脏数据需要修复;如果擅自降级显示原图,反而会把问题掩盖掉,导致管理员注意不到需要补全缩略图。 |
这种差异并不是多端没对齐,而是遵循不同端的核心场景做出的合理取舍。
验证效果
- 类型与语法检查:
- Web 端:
pnpm exec tsc --noEmit顺利通过改动范围;eslint零新增告警; - 桌面端:
go build ./...编译通过;前端 TypeScript 零错误;Wails TS 绑定自动更新且无意外漂移。
- Web 端:
- 体验实测:
- 重新打开 800+ 文件的存储整理列表,鼠标滚轮快速一滑到底,列表平滑如丝,再也没有出现哪怕半帧的背景大白块;
- 内存占用与 CPU 占用在快速滚动时表现平稳,主线程彻底告别了大图解码的高并发拥堵。
复盘小结
- 别用千万像素喂几十像素的格子:用原图做列表预览,是在用成百上千倍的带宽和 CPU 解码时间,换取开发时省写几行映射代码的“偷懒”。
- 虚拟化与图片优化相辅相成:只做图片优化,上千个 DOM 节点的布局依然会在滚轮飞速滑动时卡顿;只做虚拟滚动,可视区域内并发解码原图照样能把主线程干趴。两者是乘法关系,必须合力收敛。
- 容错状态要可自愈:记录错误状态时,精确到“哪个资源 URL 挂了”,而不是简单设一个全局的布尔值
failed。前者在数据更新后具备自愈能力,后者会导致组件陷入永久降级。 - 虚拟化绝不能动业务数据源:虚拟切片只负责屏幕绘制,批量选择、删除、导出等业务逻辑必须始终锚定全量数据,防止局部渲染导致灾难性的静默 Bug。
- 动手前先看服务端已经备齐了什么:这次排查中最值得庆幸的一点,就是发现服务端早早就切好了缩略图并做好了状态校验,往往最优雅的优化只需要顺水推舟地连上最后一环。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!