记一次图片缺少EOI导致的渲染异常
最近排查了一个图片预览切换偶发空白的问题,前前后后试了好几种前端方案都没修好,最后发现根因根本不在前端逻辑,而是一张JPEG缺少了EOI结束标记。整个过程有点绕,记录一下。
问题场景
业务用的是基于rc-image的图片预览组件(Image.PreviewGroup),点击缩略图打开预览层,左右切换浏览同一组抓拍图。线上反馈的现象有几条:
- 切换图片时偶发画面空白,图片本身尺寸这些是有的,设置上背景色都能显示,但是图片的内容纹理并没有渲染出来
- 打开DevTools时首次切换更容易空白,关掉DevTools反而恢复正常
- 禁用浏览器缓存 + 快速连续点下一张,空白概率明显升高
- 后来才注意到的一条:Chrome必现,Firefox完全正常
前面几条把人牢牢钉在"渲染、合成层、缓存"的思路上,最后一条其实才是定位问题最重要的线索,可惜被我放到很后面才用上。
走过的弯路
这部分值得记一下,因为每条思路看起来都挺有道理,但都没说到点子上。
预览图用了translate3d和scale3d实现缩放平移,这会把<img>提升成独立的合成层(GPU layer),位图作为纹理上传到显存。切换时<img>这个DOM节点是被复用的,只改src。于是第一个怀疑就是合成层纹理刷新竞态:
新位图已经decode了,但合成层的纹理还没刷新,画面就空白了;DevTools开关会强制浏览器重新提交合成树,相当于强刷一遍纹理,所以一关就恢复。
对应的处理办法是用imageRender + cloneElement给<img>注入一个随src变化的key,强制React卸载旧节点、挂载新节点,新DOM就是新合成层,没有旧纹理可以残留。
const renderPreviewImage = (imgNode, info) =>
cloneElement(imgNode, { key: info.image?.url });能缓解,但没根治。现在回头看,这其实是个信号:如果一个方案只能缓解、不能根治,往往说明思路没对上。
接着又怀疑是新位图没就绪,那就在切换前把图准备好。先试了后台预解码:
const loader = new window.Image();
loader.onload = () => setCommittedSrc(src); // 解码完成才切到新 src
loader.src = src;禁缓存 + 快速切还是空白。原因是new Image()是个游离节点,不在文档树里,它解码进的是自己的内存,真正渲染的那个<img>在禁缓存时会重新发请求,预解码白做了。
然后改成在真正会被渲染的节点上decode(),又踩了两个坑:一是还在游离节点上调用的话禁缓存照样重发;二是Chrome对零面积、opacity:0的元素会主动跳过解码和纹理上传(省电优化),等真正显示那一刻才补解码,照样空白。
中间还试过关掉transition、怀疑demo自己维护currentIndex受控引入的一帧延迟。前者跟合成层创建没关系(合成层是translate3d建的),只是降低概率;后者确实有放大效果,但不是根因。
折腾到这里,所有"前端时序、合成层、缓存"方向的方案都只能缓解、修不彻底。把试过的方案列一下:
| 方案 | 是否根治 | 没修好的原因 |
|---|---|---|
| imageRender 注 key | 否 | 只是绕开纹理残留 |
| new Image() 预解码 | 否 | 游离节点,禁缓存重发 |
| DOM 双缓冲 + key 复用 | 否 | 上游位图就绪不了 |
| staged decode() | 否 | Chrome 跳过离屏解码 |
| 关 transition | 否 | 跟合成层无关,只降概率 |
| 非受控 currentIndex | 否 | 只是放大器 |
这时候再回头看"Chrome必现、Firefox正常"这条线索。如果是纯粹的网络或解码延迟(物理瓶颈),两个浏览器应该都慢;只有Chrome出问题,说明Chrome对某种异常输入更敏感。这才把注意力从代码挪到了数据上。
JPEG的SOI与EOI
先补点背景。JPEG用一系列marker(标记)把文件分段,每个marker都是0xFF开头的两字节:
| Marker | 字节 | 含义 |
|---|---|---|
| SOI | FF D8 | Start of Image,文件起始 |
| SOS | FF DA | Start of Scan,扫描数据起始 |
| EOI | FF D9 | End of Image,文件结束 |
也就是说,一个完整的JPEG必须以FF D8开头、以FF D9结尾,EOI是解码器判断"图像数据到此为止"的依据。
关键点在于,浏览器解码器对缺失EOI是部分容错的。SOS后面的扫描数据是一段连续的熵编码流,解码器可以边读边解出MCU(最小编码单元)。如果流在中途断了、只是缺了结尾的FF D9,解码器往往已经解出了绝大部分甚至全部像素,所以"看起来能显示"。但它内部其实知道这张图不完整,这个"知道"会以延迟的error事件、脏掉的资源缓存等形式,在后面埋雷。
EOI丢失在真实系统里并不少见,流式或分片传输被截断、后端生成图片时没正常flush、CDN或代理层截断响应、自定义图像管线(抓帧、转码、拼接)漏写结束标记,都会产出一张"除了结尾啥都对"的图。
根因在图片本身
把出问题的那两张图单独抠出来,直接看字节。坏图在Network面板里是红色的裂图,但<img>居然还能勉强显示,这正是上面说的部分容错。
用xxd看两张图的结尾:
# 坏图:1300287 字节全部收到,但结尾不是 FF D9
$ xxd 5_4776102593225048067.jpg | tail -1
0013d730: ... d7b5 283d c8a3 70 .K.c.Ru...(=..p ← 结尾 70,没有 FF D9
# 好图:正常以 FF D9 收尾
$ xxd 5_4776098263897997315.jpg | tail -1
0000d6c0: ... fd4b 7ff2 afff .K.... ← ...ff d9坏图字节数是完整的(1300287字节,只比正常少了结尾两字节),但末尾缺了FF D9,JPEG数据流被截断了,应该是服务端切流或写文件没flush。到这里根因就清楚了,这不是渲染问题,是一张编码不完整的图片。
坏图为什么会连累好图
这是整件事最反直觉的地方。坏图自己能凑合显示,但显示完坏图、再切回好图,好图反而空白了,而且只在Chrome上。
原因是预览组件为了性能复用同一个<img>元素,切换只改src、不重建节点。坏图把这个被复用的元素,连带它背后的共享资源缓存一起搞脏了。Chrome(Blink)有几种机制都可能中招:
- 延迟的error事件。Blink对截断JPEG是"先解出已有MCU触发
load,流结束发现没EOI再异步触发error"。这个迟到的error落在同一个<img>上时,src可能已经切到好图了,Blink把元素标记成complete=true, naturalWidth=0,好图走broken分支被跳过绘制。 - ImageResource缓存中毒。截断图解码失败被标
LoadFailed,但元素持有的资源loader在src切换时cancel不彻底,新的fetch队列被卡住。 - GPU纹理槽位污染。大图解码失败后纹理槽"被占但内容残缺",dirty flag没置位,合成器以为旧纹理还有效、跳过上传。这点跟"开关DevTools能恢复"正好对上,因为DevTools切换会强制GPU全量重渲。
而Firefox(Gecko)对截断JPEG更宽容,出错后清理也更彻底,坏图不会连累好图。这就是"Chrome必现、Firefox正常"的真正原因。说到底不是Chrome有bug,是Chrome比较严格地暴露了坏数据,Firefox默默替你兜了底。
验证与修复
验证最快的办法就是给坏图末尾补上FF D9,看症状是否消失:
cp 5_4776102593225048067.jpg broken_fixed.jpg
printf '\xff\xd9' >> broken_fixed.jpg # 手工补全 EOI
xxd broken_fixed.jpg | tail -1 # 结尾变成 ...70 ff d9为了把结论坐实,写了个最小复现页面,三路对照、共用打印日志:
- SAME
<img>:同一个元素切src(复刻rc-image复用路径),"好 → 坏 → 好",第三步好图空白 - NEW
<img>:每次切换都createElement新元素,坏图不污染后续,全部正常 - EOI补全:把坏图换成
broken_fixed.jpg,全部正常
页面里还用fetch(name, { headers: { Range: 'bytes=-32' } })读尾部32字节,断言坏图结尾不是FF D9、补全后是。跑下来"复用元素 + 缺EOI"这个组合条件就被坐实了,只有SAME路径下的坏图会污染后续。
修复要分层看:
| 层面 | 做法 | 性质 |
|---|---|---|
| 服务端 | 图片落库前校验完整性(检查结尾FF D9),拒绝缺EOI的文件 | 治本 |
| 组件层 | <img>监听onError,出错时改key强制remount,斩断元素级脏状态 | 缓解 |
| 组件层 | 切换前用staged <img>先decode()校验,reject就走fallback | 兜底 |
| 业务层 | 接onError把损坏URL从图片数组里剔除 | 降级 |
根子在服务端,只要不让一张缺EOI的图进库,前端这一连串脏状态都不会发生。前端能做的都是兜底,挡不住源头持续产出坏数据。
小结
本文记录了一次图片切换渲染异常的排查,根因是JPEG缺少EOI结束标记。
- 偶发的渲染异常别只盯着前端逻辑,先怀疑下数据本身,这次在合成层、React时序、缓存上绕了一大圈,根因却在图片字节流里
- 方案只能缓解、不能根治,往往是思路没对上的信号
- 两个浏览器表现不一致,是"输入有问题"而不是"渲染有bug"的强信号,这条线索我重视得太晚了
- 怀疑图片损坏,先看最后两个字节,
xxd file.jpg | tail,JPEG必须以FF D9结尾
排查的时候在前端方向上花了太多时间,如果早点把那两张图抠出来看字节,能省下不少功夫。下次遇见这种偶发又跟浏览器相关的问题,得先把数据本身排除掉再说。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
