本期内容
优化背景
技术方案
实现及效果
工程化处理
总结
用户在使用过程中有反映的h5项目中的图片存在加载慢和图片不清晰的情况。
在我们的项目中,图片占据着重要的作用,是用户获取对应信息的第一印象,图片的加载缓慢、不清晰会对用户的使用体验会产生很大的影响,所以为了提升图片相关的使用体验,考虑对前端图片做统一的优化。
对于图片优化,我们从大概4个方面进行:
下面我们分别进行对应的相关实现说明。
图片懒加载又称图片延时加载、惰性加载,即在用户需要使用图片的时候加载,这样可以减少请求,节省带宽,提高页面加载速度,相对的也能减少服务器压力。
图片懒加载通过检测图片元素是否到达用户可视区域内或者是否达到某一指定条件,而进行图片资源的请求和渲染,原理图如下:
比如我们在项目中使用的懒加载处理,控制垂直、水平方向的检测和preload的高度来控制,既减少非视图内图片资源加载的浪费,又通过预加载高度控制即将进入视图的图片提前加载,避免图片在进入视图后才进行加载的不友好体验:(也可以通过交叉检测IntersectionObserver来更方便的对目标元素进行检测,原理相同,不展开赘述)
/**
* 检测图片是否可以加载,如果可以则进行加载
* @param {*} item [图片对象]
*/
const isCanShow = item => {
const ele = item.ele;
const src = item.src;
// 图片的rect对象
const rect = ele.getBoundingClientRect();
// 页面可视区域的高度
const windowHeight = window.innerHeight;
// 页面可视区域的宽度
const windowWidth = window.innerWidth;
// 判断元素是否已经进入了可视区域
const checkInView =
typeof window !== 'undefined' &&
(rect.top <= windowHeight * options.preLoad && rect.bottom >= 0) &&
(rect.left <= windowWidth * options.preLoad && rect.right >= 0);
if (checkInView) {
var image = new Image();
image.src = src;
// 请求图片网络资源并在页面渲染;
image.onload = function () {
ele.src = src;
if (item.vnode.context.initImg) {
item.vnode.context.initImg(image);
}
};
// 图片资源加载失败的处
image.onerror = function () {
ele.src = item.error || src;
};
return true;
} else {
return false;
}
};
根据业务场景,对原图片进行裁剪和压缩,达到图片满足业务需要、用户体验和快速加载的目的。我们使用的是第三方图片云服务,所以对于图片的裁剪和压缩处理很方便;
根据第三方的图片裁剪和压缩文档,通过外部的参数控制裁剪的宽度和高度、图片质量等参数,返回裁剪后和质量控制后的图片,我们来用代码实现:
const suffix = this.src.replace(/^.*\.(\w+)$/, '$1');
if (cutWidth && cutHeight) {
imageQuery = `imageView2/1/w/${cutWidth}/h/${cutHeight}/format/${format}`;
} else if (cutWidth) {
imageQuery = `imageView2/2/w/${cutWidth}/format/${format}`;
} else if (cutHeight) {
imageQuery = `imageView2/2/h/${cutHeight}/format/${format}`;
}
// 渐进显示和图片质量控制
if (suffix === 'jpg') {
imageQuery = `${imageQuery}/interlace/${this.interlace}/q/${this.quality}`;
} else {
console.warn('图片渐进显示、质量控制只支持jpg图片格式!');
}
return `${this.src}?${imageQuery}`;
虽然实现了图片的裁剪和质量控制,但是还有不同设备访问我们同一个处理后的图片资源的清晰度问题。
因为之前开发我们大多使用的是2倍图的资源,所以如果用户使用像素比(dpr)为2的设备时,图片会比较清楚;若用户使用像素比(dpr)为3的设备时,可能一些细节就会比较模糊。
所以我们还要根据用户的设备像素比(dpr),结合cdn来做优化。通过获取设备的dpr、上述第三方图片工具,获取适应不同设备的清晰图片(最低使用2drp,其他的dpr都进行向上取整处理):
这样可以对不同设备自动使用适合的比较清晰的图片。
cdn即采用更多的缓存服务器(cdn边缘节点),布放在用户访问相对集中的地区或网络中。
当用户访问网站时,利用全局负载技术,将用户的访问指向距离最近的缓存服务器上,由缓存服务器响应用户请求。
例如:当一个西藏用户1访问我们页面中的新的图片资源后,cdn会将远端的图片资源复制一份,缓存在靠近用户的西藏电信的cdn缓存服务器节点上,这样在该区域的用户2进行访问的时候,发现附近的西藏电信的cdn缓存服务器上有该图片资源,那么就会直接由该缓存服务器进行响应,避免的拉取远程资源的时间消耗。
针对同一区域的2dpr和3dpr设备的用户,只要第一次访问同一图片之后便会在最近的cdn缓存服务器节点上缓存对应的裁剪处理过的2倍图和3倍图,保证其他该区域的用户访问的时候加速响应时间。
常用的图片格式:jpg/jpeg/png/gif/webp等;
其中webp的有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当稳定优秀,并且现在webp的支持度也是很不错的。
所以在处理图片时,通过检测当前环境是否支持webp的图片格式,支持则默认使用webp的格式,否则使用原图图片格式或者用户指定的图片格式,这样既保证了用户端的图片显示质量,还缩小了图片的体积,提升用户的使用体验。
const suffix = this.src.replace(/^.*\.(\w+)$/, '$1');
if (cutWidth && cutHeight) {
imageQuery = `imageView2/1/w/${cutWidth}/h/${cutHeight}/format/${format}`;
} else if (cutWidth) {
imageQuery = `imageView2/2/w/${cutWidth}/format/${format}`;
} else if (cutHeight) {
imageQuery = `imageView2/2/h/${cutHeight}/format/${format}`;
}
// 渐进显示和图片质量控制
if (suffix === 'jpg') {
imageQuery = `${imageQuery}/interlace/${this.interlace}/q/${this.quality}`;
} else {
console.warn('图片渐进显示、质量控制只支持jpg图片格式!');
}
return `${this.src}?${imageQuery}`;
通过封装自定义图片组件,替换原生的img标签,收拢组件优化入口,有利于后期的扩展和优化。将各种优化参数可配置化,适应不同场景和功能的需要。
<template>
<img
v-if="(cutWidth && cutHeight) || (width && height)"
ref="__imgCompontent__"
v-lazy:[{width:(cutWidth||width),height:(cutHeight||height)}]="imageSrc"
class="component__UnifyImage"
:alt="alt"
:style="imgStyle"
"imgClick" =
/>
<img
v-else
ref="__imgCompontent__"
v-lazy="imageSrc"
class="component__UnifyImage"
:alt="alt"
:style="imgStyle"
"imgClick" =
/>
</template>
<script>
import { getLazyFn } from '@wanwu/base-vue-directive-lazy';
import numToRem from '@wanwu/base-vue-filters-num-to-rem';
const lazy = getLazyFn({
loading: 'https://cdn.wanwudezhi.com/seller-admin/image/MTU1NDk2MjkxNzI2Nw==.png',
error: 'https://cdn.wanwudezhi.com/seller-admin/image/MTU1NDk2Mjk3MzczMw==.png',
});
export default {
name: 'FImage',
directives: {
lazy,
},
props: {
// 图片裁剪尺寸宽度
cutWidth: {
type: [Number, String],
default: 0,
validator: (val) => !isNaN(Number(val)),
},
// 图片裁剪尺寸高度
cutHeight: {
type: [Number, String],
default: 0,
validator: (val) => !isNaN(Number(val)),
},
// 图片裁剪尺寸宽度
width: {
type: [Number, String],
default: '',
validator: (val) => !isNaN(Number(val)),
},
// 图片裁剪尺寸高度
height: {
type: [Number, String],
default: '',
validator: (val) => !isNaN(Number(val)),
},
// 图片地址
src: {
type: String,
default: '',
},
// 图片描述文本
alt: {
type: String,
default: '',
},
// 图片格式
format: {
type: String,
default: '',
},
// 水印(使用水印后图片的基础处理imageView2无效)
watermarkString: {
type: String,
default: '',
},
// 是否支持渐进显示 取值范围:1 支持渐进显示,0不支持渐进显示(默认为0) 适用jpg目标格式
interlace: {
type: Number,
default: 1,
},
// 生成图片的质量:1-100,适用jpg目标格式
quality: {
type: Number,
default: 100,
},
},
data() {
return {
isSupportWebP: false,
baseWidth: 375, // 默认基础屏幕尺寸
styleWidth: 0,
styleHeight: 0,
remRatio: 1,
cdnUrlPattern: /^http(s?):\/\/cdn\.wanwudezhi\.com/,
};
},
computed: {
Dpr() {
const DPR = Math.max(2, window.devicePixelRatio);
return DPR ? Math.ceil(DPR) : 3;
},
imageSrc() {
if (this.getImageUrl) {
if (this.watermarkString) {
const originUrl = this.getOriginImage(this.getImageUrl);
return `${originUrl}?${this.watermarkString}`;
}
return this.getImageUrl;
} else {
return '';
}
},
imgStyle() {
return {
width: numToRem(this.width),
height: numToRem(this.height),
};
},
// 尺寸处理
getSize() {
// 若使用用户传入的尺寸,先转换为1倍,然后根据baseWidth确定是以414为标准还是375位标准;
const _width = (this.cutWidth || this.width) / 2 * (this.baseWidth / 375);
const _height = (this.cutHeight || this.height) / 2 * (this.baseWidth / 375);
// 若通过style中读取,用读取值乘以remRatio的倒数进行处理,保证和用户传入的数据保持一致;
const _styleWidth = this.styleWidth * (1 / this.remRatio);
const _styleHeight = this.styleHeight * (1 / this.remRatio);
// 以用户传入的width和height作为最高准则,若没有则兼容style的尺寸;
return {
width: Math.ceil((_width || _styleWidth) * this.Dpr),
height: Math.ceil((_height || _styleHeight) * this.Dpr),
};
},
getImageUrl() {
// 通过判断避免加载两次图片
const noSize = !this.cutWidth && !this.cutHeight &&
!this.height && !this.width &&
!this.styleWidth && !this.styleHeight;
if (noSize || !this.src) return;
// const width = parseInt(((this.cutWidth / 2 * this.remRatio) || this.styleWidth) * this.Dpr);
// const height = parseInt(((this.cutHeight / 2 * this.remRatio) || this.styleHeight) * this.Dpr);
const cutWidth = this.getSize.width;
const cutHeight = this.getSize.height;
const suffix = this.src.replace(/^.*\.(\w+)$/, '$1');
const format = (this.format === 'webp' || !this.format)
? this.isSupportWebP ? 'webp' : (suffix || 'jpg')
: this.format;
let imageQuery = '';
// // 减少不同尺寸的图片的格式;以50为间隔进行处理
// cutWidth = Math.ceil(cutWidth / 50) * 50;
// cutHeight = Math.ceil(cutHeight / 50) * 50;
// 之前处理过的图片不在处理
// const originUrl = this.getOriginImage(this.src);
if (this.src.includes('imageView2')) {
return this.src;
}
if (cutWidth && cutHeight) {
imageQuery = `imageView2/1/w/${cutWidth}/h/${cutHeight}/format/${format}`;
} else if (cutWidth) {
imageQuery = `imageView2/2/w/${cutWidth}/format/${format}`;
} else if (cutHeight) {
imageQuery = `imageView2/2/h/${cutHeight}/format/${format}`;
}
// 渐进显示和图片质量控制
if (suffix === 'jpg') {
imageQuery = `${imageQuery}/interlace/${this.interlace}/q/${this.quality}`;
} else {
console.warn('图片渐进显示、质量控制只支持jpg图片格式!');
}
return `${this.src}?${imageQuery}`;
},
},
created() {
// 避免同一个页面过多使用querySelector影响性能,通过挂载window的方式减少querySelector访问;
if (window.__remRatio__) {
this.remRatio = window.__remRatio__;
} else {
// 最大的remRatio限制为2;
this.remRatio = Math.min(window.innerWidth, 750) / this.baseWidth || 1;
window.__remRatio__ = this.remRatio;
}
// 判断浏览器是否支持webp格式(缓存挂载window上)
if (window.__supportWebP__) {
this.isSupportWebP = window.__supportWebP__;
} else {
try {
this.isSupportWebP = (document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0);
} catch (err) {
this.isSupportWebP = false;
}
window.__supportWebP__ = this.isSupportWebP;
}
},
mounted() {
(!this.cutWidth && !this.cutHeight && !this.width && !this.height) && this.getStyle();
},
methods: {
getStyle() {
const ele = this.$refs.__imgCompontent__;
const { width = 0, height = 0 } = window.getComputedStyle(ele);
this.styleWidth = Number(parseFloat(width).toFixed(2));
this.styleHeight = Number(parseFloat(height).toFixed(2));
if (!this.styleWidth && !this.styleHeight) console.error('获取图片尺寸失败!!');
},
checkIsCdnUrl(url) {
return this.cdnUrlPattern.test(url);
},
getOriginImage(url) {
if (!this.checkIsCdnUrl(url)) {
return url;
}
return (url && url.split('?')[0]) || '';
},
imgClick() {
// 点击图片的回调
this.$emit('click', this.imageSrc);
},
},
};
</script>
各参数说明:
宽度为250px的图片在dpr <= 2的设备上的使用效果:
宽度为250px的图片在2 < dpr <= 3的设备上的使用效果:
同一张图片,在不同dpr的设备上自动控制图片的分辨率,大家可以通过访问图片1和图片2来查看区别。
以项目中的申请开店页面为例,该页面都是有图片组成(关闭缓存情况下):
依据上图,该页面图片加载以根据设备进行匹配,图片质量都提高的情况下,加载资源耗时相持平并且平均耗时略有降低,页面加载白屏时间相持平,所以在cdn缓存和图片动态质量控制的作用下,既提升了图片质量和用户体验,且不影响页面的加载速度。
相关优化和组件的开发都已经完成,但是需要我们对之前的img原生标签进行修改替换,以及后续的开发需要提醒开发者使用我们新的img组件,这样才能保证我们项目中的统一规范。
初步考虑到通过webpack和eslint来做工程化处理,鉴于eslint在开发中的实时提醒功能、快捷自动修复功能以及eslint检测错误阻止代码提交功能等,最后决定使用开发eslint插件来处理。
如上图,在开发中可以实时提醒错误位置和错误原因,并可以一键到达使用说明文档。(目前因为之前的不同写法比较多,避免在整个项目的一键修复时出现未察觉的错误,所以将自动修复功能暂时关闭,后期稳定后重新开启)。
本次关于图片的优化,结合图片懒加载、裁剪压缩、cdn缓存和自动格式处理等,对h5端图片的用户体验做了优化,也针对开发者做了组件的检测和提醒,为后期的优化升级提供便利。