图片加载数量多以及图片体积过大往往会影响页面加载速度,造成不良的用户体验。尤其在电商类/影视类项目,往往存在大量的图片,如 banner 广告图,菜单导航图,列表图,封面图等。整体优化方案总结如下:
合适的图片格式
WEB 图片格式有 JPEG/JPG、PNG、GIF、WebP、Base64、SVG 等,特点与适用场景总结如下:
降低网络传输
网络图片加载前,对图片URL
进行预处理,预处理包括添加缩放参数
,添加降质参数
,添加WebP参数
的方式减少图片网络传输大小,预计图片体积可以减少减少接近80%。
- 图片缩放:后台原始图尺寸可能比客户端实际显示的尺寸要大,一方面导致图片加载慢,另一方面导致用户流量的浪费,也会影响渲染性能,会让用户感觉卡顿,影响用户体验。通过添加缩放参数的方式,指定图片服务器下发更小和更匹配实际显示
size
的图片尺寸。 - 图片降质:默认值为原图质量,通过降低图片质量可以减少图片大小,但是质量降低太多也会影响图片的显示效果,弱
4G
网络,则图片质量设置适当低,一方面可以大幅减少图片下载大小和保证用户使用体验,另一方面节省用户浏览 ,添加图片降质参数至少可以减少30-40%
的图片大小。
/**
* 根据网络环境设置不同质量图片
*/
const ImageQuality: Record<string, number> = {
wifi: 85,
'5g': 85,
'4g': 80,
'3g': 60,
'2g': 60,
};
/**
* 获取图片质量
*/
export const getImageQuality = () => ImageQuality[getApp().globalData.networkType ?? 'wifi'];
-
使用 WebP:按照
Google
官方的数据,与PNG
相比,WebP
无损图像的字节数要少26%
,WebP
有损图像比同类JPG
图像字节数少25-34%
。针对兼容性ios 13
系统版本进行降级处理,不转 webp。
80%
对比优化前后,支持 webp 图片体积可以降低 80% 以上,不支持 webp 可以降低 70% 以上。
图片懒加载
使用 Intersection Observer API,监听某些节点是否可以被用户看见、有多大比例可以被用户看见,就能判断图片元素是否在可是范围中,进行图片加载。通过使用图片懒加载的功能,减少图片数量的加载,有效提高页面加载性能。通常如果对图片体积已经优化过,那么只需要在网络情况较差的情况下去自动开启图片懒加载功能即可满足需求。
请求数优化
很多本地图片资源比如一些 icon 图标、标签类切图、背景图、图片按钮等,会被放到 CDN 云服务器中, CDN 服务器虽然可以加速资源的请求速度,当页面打开需要同时下载大量的图片的话,就会严重影响了用户的使用体验。需要找到权衡点来实现来请求数优化,把图片资源与使用场景进行分类,进行优化:
- 较大体积的图片,选择上传到 CDN 服务器;
- 单色图标使用 iconfont 字体图标,多彩图标则使用
svg
格式; - 标签类的图片,则生成雪碧图之后上传到 CDN 服务器;
- 图片体积小于
10KB
,结合使用场景,则考虑base64
,比如由于小程序css background
不支持本地图片引入,一张图片体积为3KB
的背景图,可以使用base64
方式实现。
再者,利用图片请求数检查技术,可以检测短时间内是否有发起太多的图片请求,以及是否存在图片太大而有效显示区域较小的问题。可以通过需要合理控制数量,可考虑使用雪碧图技术、拆分域名或在屏幕外的图片使用懒加载等。
大图检测
通过大图检测机制,发现图片尺寸太大,不符合图尺寸标准时进行上报。图片组件通过自动检测到大图片时,显示当前图片尺寸、以及设置图片高亮/翻转
的方式提醒运营和设计同学进行处理。
加载失败处理
上述的图片URL预处理操作,可能会存在少量图片不存在的异常场景导致加载失败
。需要重新加载原始图片 URL,并将预处理的错误图片 URL 上报到监控平台,方便之后调整 URL 预处理转换规则,同时将发现的这部分错误的图片 URL 推动业务修改。
上传压缩
图片在上传前就在保持可接受的清晰度范围内同时减少文件大小,进行合理压缩。推荐 TinyPNG – 智能压缩您的WebP、JPEG和PNG图片
AVIF
AVIF支持HDR, 透明度(即支持alpha通道)和宽色域,它支持任何图像编解码器,可以是有损或无损。与 JPEG 或 WebP 相比,AVIF 为图片提供了显著的文件大小压缩,与 JPEG 相比,可节省 ±50%,与 WebP 相比节省 20%。
可以在 HTML 使用它:
<picture>
<source srcset="img/photo.avif" type="image/avif">
<source srcset="img/photo.webp" type="image/webp">
<img src="img/photo.jpg" alt="Description of Photo">
</picture>
而对于不支持AVIF的浏览器可以使用 polyfill avif.js——使用service worker劫持fetch事件然后对avif图片进行处理,而 service worker 兼容性还行。
异常兜底方案
<img>加载错误
事件会经历三个阶段:
- 捕获阶段
- 处于目标阶段
- 冒泡阶段
img 和 srcipt 标签的 error 并不会冒泡,但是会经历捕获阶段和处于目标阶段。直接给 img元素添加 onerror 监听的方案就是利用处于目标阶段触发事件函数,我们可以在捕获阶段截获并触发函数,从而减少性能损耗。
document.addEventListener(
'error',
e => {
let target = e.target;
const tagName = target.tagName || '';
if (tagName.toLowerCase = 'img') {
target.src = 'default.png';
}
target = null;
},
true
);
这种通过替换src的方案有两个缺点:
- 如果是因为网络差导致加载失败,那么加载默认图片的时候也极大概率会失败,于是会陷入无限循环。
- 如果是网络波动导致的加载失败,那么图片可能重试就会加载成功。
最优的替换 src 方案是——为每个img标签额外添加一个data-retry-times计数属性,当重试超过限制次数后就用base64图片作为默认兜底:
document.addEventListener(
'error',
e => {
let target = e.target;
const tagName = target.tagName || '';
const curTimes = Number(target.dataset.retryTimes) || 0;
if (tagName.toLowerCase() === 'img') {
if (curTimes >= 3) {
target.src = 'data:image/png;base64,xxxxxx';
} else {
target.dataset.retryTimes = curTimes + 1;
target.src = target.src;
}
}
target = null;
},
true;
);
上述方式是采用替换 src 的方式来展示兜底图,这种解决方式有一个缺陷:
- 原图的资源链接无法从标签上获取(虽然可以通过加data-xxx属性的方式hack解决)。
更好的方式,就是利用CSS伪元素::before和::after覆盖原本元素,直接展示兜底base64图片:
<style>
img.error {
display: inline-block;
transform: scale(1);
content: '';
color: transparent;
}
img.error::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #f5f5f5 url(data:image/png;base64,xxxxxx) no-repeat center / 50% 50%;
}
img.error::after {
content: attr(alt);
position: absolute;
left: 0; bottom: 0;
width: 100%;
line-height: 2;
background-color: rgba(0,0,0,.5);
color: white;
font-size: 12px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<script>
document.addEventListener(
'error',
e => {
let target = e.target
const tagName = target.tagName || ''
const curTimes = Number(target.dataset.retryTimes) || 0
if (tagName.toLowerCase() === 'img') {
if (curTimes >= 3) {
target.classList.remove('error');
target.classList.add('error');
} else {
target.dataset.retryTimes = curTimes + 1;
target.src = target.src;
}
}
target = null;
},
true;
);
</script>
<img>加载超时
图片大多使用CDN加载加速资源请求,但CDN可能存在节点覆盖不全的问题,导致DNS查询超时。此时可以使用嗅探的方式,测试CDN提供的Domain是否能够正常访问,如果不行或者超时就及时切换成可访问Domain。
// 防止嗅探图片存在缓存,添加时间戳保持新鲜度
export const imgUri = `/img/xxxxx?timestamp=${Date.now()}${Math.random()}`;
export const originDomain = 'https://sf6-xxxx.xxxx.com'
// 可采用配置下发的方式
export const cdnDomains = [
'https://sf1-xxxx.xxxx.com',
'https://sf3-xxxx.xxxx.com',
'https://sf9-xxxx.xxxx.com',
];
export const validateImageUrl = (url: string) => {
return new Promise<string>((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(url);
};
img.onerror = (e: string | Event) => {
reject(e);
};
// 由于 Image图片加载没有超时机制,promise 的状态不可变性,借助 setTimeout 模拟超时
const timer = setTimeout(() => {
clearTimeout(timer);
reject(new Error('Image Load Timeout'));
}, 10000);
img.src = url;
});
};
export const setCDNDomain = () => {
const cdnLoop = () => {
return Promise.race(
cdnDomains.map((domain: string) => validateImageUrl(domain + imgUri)),
).then(url => {
window.shouldReplaceDomain = true;
const urlHost = url.split('/')[2];
window.replaceDomain = urlHost;
});
};
return validateImageUrl(`${originDomain}${imgUri}`)
.then(() => {
window.shouldReplaceDomain = false;
window.replaceDomain = '';
})
.catch(() => {
return cdnLoop();
});
};
// 替换URL
export const replaceImgDomain = (src: string) => {
if (src && window.shouldReplaceDomain && window.replaceDomain) {
return src.replace(originDomain.split('/')[2], window.replaceDomain);
}
return src;
};
或者由服务端下发可用的Domain:
getUsefulDomain().then(e => {
window.imgDomain = e.data.imgDomain || ''
})
background-image 加载异常
背景图元素没有 error 事件,本身也就无法捕获 error 事件。
此时可以利用 dispatchEvent 自定义error 事件,而它同样拥有捕获阶段,先使用前面定义的validateImageUrl 方法嗅探图片资源的情况抛出错误,然后在全局进行捕获:
const event = new Event('bgImgError');
validateImageUrl('xxx.png').catch(e => {
let ele = document.getElementById('bg-img');
if (ele) {
ele.dispatchEvent('bgImgError');
}
ele = null;
});
document.addEventListener(
'bgImgError',
e => {
e.target.style.backgroundImage = "url(data:image/png;base64,xxxxxx)";
},
true
);