性能优化之全面图片改造方案
大厂技术 坚持周更 精选好文
背景
在最近观察业务表现过程中,注意到系统中图片占较大比重,但是图片的加载经常会出现空白闪烁等等的一些体验问题,部分页面如下
一些场景的加载卡顿截取
可以看到是典型的图文为主的展示页面,系统内有多处类似的场景。并且加载首屏的图片资源消耗也是非常耗时,lighthouse对课程列表的分析结果。图片比重和大小都偏大。
因此这里做优化的收益是比较明显的能给用户和公司带来收益的。但是缺少一个系统化的优化流程。
开始之前
在开始之前我们先对一些基本只是有些了解,如图片格式,什么是无损和有损压缩。
回顾下图片格式
既然是说图片加载,那么我们先对常见的图片格式做一个梳理和回顾,因为格式也是影响图片加载的一个重要因素,简单列举一下常见的图片格式:
jpg/jpeg
png
gif
WebBP
Avif
Jpeg xl
无损 OR 有损
有损压缩
维基百科定义:有损数据压缩(英语:lossy compression)是一种数据压缩[1]方法,经过此方法压缩、解压的数据会与原始数据不同但是非常接近。有损数据压缩又称破坏性资料压缩、不可逆压缩。有损数据压缩借由将次要的数据舍弃,牺牲一些质量来减少数据量、提高压缩比。根据各种格式设计的不同,有损数据压缩都会有代间损失[2]——每次压缩与解压文件都会带来渐进的质量下降。
由于有损压缩减少了文件本身的数据量,且以牺牲图像质量为代价,因此压缩后的文件无论是在磁盘占用还是内存占用上都会比原始图像要小。针对于目前探讨的图片加载方式,对应的都是有损压缩,目标都是更小的内存占用和更快的解码速度。
无损压缩
维基百科定义:无损数据压缩(Lossless Compression),是指资料经过压缩[3]后,信息不被破坏,还能完全恢复到压缩前的原样。相比之下,有损数据压缩[4]只允许一个近似原始资料进行重建,以换取更好的压缩率。无损数据压缩在许多应用程序中使用。例如,ZIP[5]和gzip[6]。无损数据压缩通常用于严格要求“经过压缩、解压缩的资料必须与原始资料一致”的场合。
无损压缩的方法可以通过一些编码手段,用结构化的数据来减少对重复信息的磁盘占用,针对图片来说减少了图片在磁盘上的空间占用。但是并不能减少图像的内存占用量,这是因为,当从磁盘或网络请求上获取图像时,浏览器又会对图片进行解码,把丢失的像素用适当的颜色信息填充进来。
因此如果要减少图像占用内存的容量,就必须使用有损压缩方法。
聊一聊webp
概念一览
WebP 是一种现代图像格式,可为 Web 上的图像提供卓越的无损和有损压缩。使用 WebP可以创建更小、更丰富的图像,从而使 Web 更快。与 PNG 相比,WebP 无损图像的大小要小 26% 。[7]在同等 SSIM[8]质量指数下, WebP 有损图像比可比较的 JPEG 图像小 25-34% 。[9]无损 WebP支持透明度(也称为 alpha 通道),成本仅为22% 额外字节[10]。对于可以接受有损 RGB 压缩的情况,有损 WebP 还支持透明度,通常提供比 PNG 小 3 倍的文件大小。
来个直观体验
也可以戳这里看下社区其他同学做的对比效果[11],可以看到webp在图片体积和效果上都做的不错,很适合我们的场景。并且webp的使用目前已经比较广泛,如在youtube以及抖音pc上都可以看到。
Youtube 部分页面的截取,在封面图等大图场景均使用的webp格式
抖音pc站
压缩技术
webp的压缩技术基于 VP8[12]关键帧编码,无损 WebP 压缩使用已知的图像片段来精确地重建新的像素,在无法找到相应的匹配值的情况下,使用本地调色板进行优化。在webp的开发者平台已经有详细的压缩技术的推演,可以直接戳这里[13]看下。
WebP 应用效果
随着浏览器对 WebP 支持的普及,目前也有越来越多的互联网开始使用 WebP,这里分享几个数据:
YouTube 的视频略缩图采用 WebP 后,网页加载速度提升了 10%;
Google Chrome 应用商店采用 WebP 后,每天可以节省几 TB 的带宽,页面加载时间减少了30% 左右;
花瓣网在 2017 年 5 月开启 WebP 后,在网站总体请求量没有减少的情况下,整体带宽下降了近 50%。
结论:无论是技术上还是使用上都已经得到了可行的验证,并且有明显收益。
优化思路
图片的优化分为加载阶段和显示阶段。
加载阶段
图片体积
图片体积直接反应了网路需要加载的时间,等同于磁盘占用,因此减少图片体积能直接减少图片请求的时间。进而在首屏提升FCP等相关指标,让浏览器能更快拿到数据进行绘制。
内存占用
内存占用和图片体积不等同,两张不同体积的图片可能有着相同的内存占用,因此优化内存占用可以让浏览器解码图片和光栅化的时间减少,因为不需要计算绘制那么多的图片信息。光栅化时间的减少直接影响了页面的渲染速度,以及页面的卡顿。
显示阶段
加载占位
占位图是为了给用户有感知的加载,提升用户体验。避免用户等待过程中的流失。
懒加载
懒加载也已经是当前各种站点的常规优化手段,懒加载尽量减少了不必要的资源请求以提高浏览器的渲染效率,减少内存占用。并显著减少不必要的带宽,是为用户和公司都省钱的方式。
格式回退
对于浏览器对不同格式的图片支持程度不同,我们的一些优化手段和格式可能不太适用所有浏览器,但是为了保证性能和体验并最大兼容支持的浏览器,我们需要对图片进行格式降级处理。如对于不支持webp的浏览器自动降级为png。
错误占位
错误占位也是必要的一步,当所有的尝试都失败后我们也需要一种良好的方式展示并给用户感知到。比如目前业务内的错误展示。
实践-实验阶段
图片压缩
对应于我们优化思路的加载阶段,使用公司已有的平台能力。我们可以获得不同格式和压缩比例的图片。比如我们选择压缩比75的webp以及原图两种格式。webp作为默认格式,原图则作为backup的兜底资源。这里需要注意的是,图片列表需要服务端的支持,因为目前系统的图片是经由服务端返回的鉴权url,因此这部分需要配合改造。
基本格式如下
type ImgUrlList={
// 原图
origin:string,
// webp格式
webp:string,
// avif格式
avif:string,
}
模板配置如图
对于为什么图片地址需要多个,主要是为了方便我们做回退处理,遇到浏览器不兼容的格式就牺牲流量换取可正常展示的图片,保证内容可见。这里获得的图片格式消费流程如下
通过近一周的站点数据统计,目前业务方浏览器数据如下,其中chrome占比78.66% ,浏览器版本chrome最低55,fireforx最低99,均在webp的支持范围内。数据均兼容不考虑移动端浏览器。由于IE也存在极小的比重,所以IE应该会是触发降级占比最高的。
图片加载
图片加载这里是优化思路的显示阶段的实现,主要包含从加载占位到失败占位的整个流程,当然也包含懒加载。加载我们在观测阶段和稳定阶段使用了不同的方式。这里针对观测阶段的方案展开介绍。最稳定方案是Picturede 方式,可以在下文稳定阶段看到。
观测主要是为了有数据对比,这里我们使用到了xx图片处理包来做图片加载,主要原因有三:一经过抖音pc和西瓜视频的场景验证、二集成上报的能力,能够拿到图片的相关数据、三提供了图片加载和回退的支持,满足当前场景。使用示例如下
import type ImageObserver from 'xxxxxxxxx';
let imgObserver: ImageObserver;
export async function getImgObserver(): Promise<ImageObserver> {
if (imgObserver) {
return imgObserver;
}
const ImageObserverSDK = import('xxxxxxxxx');
const LoggerSDK = import('xxxxxxxxx-logger');
const [imgObserverSdk, logggerSdk] = await Promise.all([ImageObserverSDK, LoggerSDK]);
const ImageObserver = imgObserverSdk?.default;
const Logger = logggerSdk?.default;
if (ImageObserver && Logger) {
imgObserver = new ImageObserver({
plugins: [Logger],
divider: {
dataSrc: 'src',
backUpSrc: 'backup-src',
},
logger: {
user_unique_id: 'cccccc', // TODO,
app_id: 111111, // TODO, },
});
}
return imgObserver;
}
本图片处理包包含了图片加载错误重试的逻辑,跟我们上面图片压缩章节设计的图片列表相结合,可以完成自动回退。
错误示例如下,我们给定一个可用地址,其中src以及backup-src的第一个均不可用,预期是可以自动降级到最后一个可用地址
为了保证图片加载流程的可控性,比如在图片即将出现再去做响应的加载处理。因此一些通用的默认拦截图片并自动做加载处理的方式就不在适用了,因为我们没办法严格控制每个图片的显示时间也不好做拦截处理。因此懒加载我们手动通过IntersectionObserver
来实现,基本代码如下,其中useIntersectionObserver
是IntersectionObserver
的一个实现封装。
const observerCb: IntersectionObserverCallback = useCallback((entrys, observer) => {
const entry = entrys[0];
if (entry.isIntersecting) {
setImgVisible(true);
observer.disconnect();
}
}, []);
const { updateObserverEl } = useIntersectionObserver({
cb: observerCb,
});
这样我们明确控制了每个图片的加载时机,并对加载结果精细化控制和处理。在一次观测完成后立即清除观测,完成一次加载。
加载数据上报
我们通过第一步获取了可用的几种格式,因为我们不知道用户的浏览器会是什么样子,所以不能一股脑的都换成webp格式,所以我们需要知道webp的格式加载成功了多少,我们的图片加载耗时情况是什么样子。有多少是回退到了原图,加载耗时又是什么样子。那当我们有新的方案能不能让用户无缝切换过去,怎么做用户放量等等问题。因此我们需要对图片加载做监控。
细心的你可能已经注意到我们图片加载部分有一个xxxxxxxx-logger
,没错这个就是用来做上报的,上报流程为尝试加载->失败重试->加载结果->上报
。logger插件会收集加载过程中的图片信息,加载时长,失败情况进行上报。这样我们就能够根据数据情况查看我们改造的用户覆盖度和使用情况,以便我们做后续分析。
优化反推
这一步是对我们优化结果的进一步结论导出,什么意思呢。以我们加载的图片类型数据为例,如果我们的webp支持程度很好,那是不是可以实验性的将avif格式作为下一次的实验对象来验证更高的性能。如果我们的图片每种格式都很慢,那么我们自然可以反推cdn来优化解决方案。同时如果webp的不支持,也可以看下我们的降级策略是不是很好的生效了,保证的系统的高可用。等等。因为我们有了数据支撑,反推变得更加容易。
实践-稳定阶段
我们通过上一步的实践已经完成了我们需要的数据观测和预期效果。这时我们已经有了图片在线上的加载耗时,解码耗时,加载稳定性相关的数据,并且反推了在系统整体设计的上下游对图片的限制的合理性,比如课程封面场景限制图片上传尺寸10M,但是这个限制无论如何都严重影响加载性能,那降低到200K是既满足需要又不影响性能的适合值,那么这就是通过实验阶段推导到的优化结果。也是进入稳定阶段的重要一步。因此上一步的实验阶段需要尽可能有效的分析全面数据。
上报移除+浏览器支持
那么说了一堆之后,我们稳定阶段可以做点什么。当然是期望再优化一点,于是我们做的事情有两个,一是下掉上一步的监控,二是变更为浏览器处理图片,同时满足我们的场景。第一步就比较明显因为监控本身是有流量损耗和代码体积影响的。那么第二步就是加个js处理图片降级的方式平滑过渡到浏览器一支持。于是就有了如下形式的代码
const pictureRender = () => {
const { webp, avif, image } = remain.urlList;
return (
<picture>
<source srcSet={avif} type="image/avif" />
<source srcSet={webp} type="image/webp" />
<img src={image} onError={() => onError?.()} {...remain} />
</picture>
);
};
这里我们使用了picture标签来做图片的自动降级,关于picture标签的用法和场景可以这篇文章[14]。总的来说就是做响应式图片和自动降级的一个比较好的方式。这里就不展开了。我们通过上面的代码把我们兼容的格式进行分类指定,以满足picture的使用场景。示例的集中格式会在加载不满足条件时依次降级。因为picture的加载事件最终还是会落到img标签上,所以我们上面的监听方式依然适用。
兼容实验场景和稳定阶段
到这里我们已经总结了稳定阶段和实验阶段各自采用的加载策略。但是有一点好处是,这两者是不冲突的。我们希望继续保持对新业务场景开启实验观测的能力,稳定业务可以继续用稳定场景方案。因此我们只需要轻微改造就可以完成这个支持,完整代码贴在下方。这里需要注意的是,虽然保留了两者的能力,但是并不会影响首页体积,因为本身js监控图片的方式也是动态加载的,因此除了打包阶段会有总包体积的占用,对系统性能是没有损耗的。
import { getImgObserver } from '../../utils/observer';
import React, { useRef, useEffect } from 'react';
export const ImageMonitor: React.FC<any> = (props: any) => {
const { currentref, onError, usePicture, ...remain } = props;
const imgNode = useRef<HTMLImageElement | null>(null);
useEffect(() => {
if (!usePicture) {
const monitor = async () => {
const observer = await getImgObserver();
observer?.observer?.(imgNode.current).then((res: any) => {
if (res.code !== 0) { // 加载最终失败
onError?.();
}
});
};
monitor();
}
}, []);
const pictureRender = () => {
const { webp, avif, image } = remain.urlList;
return (
<picture>
<source srcSet={avif} type="image/avif" />
<source srcSet={webp} type="image/webp" />
<img src={image} onError={() => onError?.()} {...remain} />
</picture>
);
};
// 兼容js处理图片和浏览器原生处理图片
if (usePicture) {
return pictureRender();
}
return (
<img
{...remain}
ref={el => {
if (!imgNode.current) {
imgNode.current = el;
currentref?.(el);
}
}}
flag="monitor"
/>
);
};
❤️ 谢谢支持
以上便是本次分享的全部内容,希望对你有所帮助^_^
喜欢的话别忘了 分享、点赞、收藏 三连哦~。
欢迎关注公众号 趣谈前端 收货大厂一手好文章~
点个在看你最好看