百万PV商城实践系列-前端图片资源优化实战
前言
百万PV商城系列主要收录我在商城项目中的实践心得,我会从视觉体验、性能优化、架构设计等三个维度出发,一步一步为大家讲解商城项目中前端出现的问题,问题解决的思路与方案,最后再进行代码实践。让你在工作中碰到类似问题时,能够更加得心应手。
本篇是商城实践系列的第一篇文章,主要内容是对商城项目中常见场景的图片资源加载进行优化,提升视觉体验与页面性能。
背景
在日常生活中,肯定都用过一些电商公司的App
、Web
、小程序
等应用。在浏览的过程中,会有非常多琳琅满目的图片,比如常见的商品卡、商品详情、轮播图、 广告图等组件都需要使用一些后台管理配置上传的图片资源。
除此之外,这些组件所在的页面也大都是流量承担非常大的页面,如果用户的体验感官比较差,必然会影响用户的留存转化。那么,本篇文章我们就来学习一下图片资源优化的一些方案技巧,我会分别从普通图片、高保真图片、长屏渲染图这三个场景,依次给大家讲讲工作中的实战操作。如果碰到相应的优化场景,你就能够从容以对了。
普通图片优化
对于普通图片优化,我以懒加载(lazyLoad)作为主要的实施手段。懒加载说白了就是优先加载可视窗口内的图片资源,而可视窗口外的内容只有滚动后进入可视窗口内才会进行加载。
那么,我为什么会用懒加载,以及懒加载的实现方案到底是什么呢?下面,我通过一个简单的React
的图片懒加载方案的实现,来一步一步说明。
为什么使用懒加载?
一般来说,刚进入网页页面就会有大批量的图片资源加载,这会间接影响页面的加载,增加白屏加载时间,影响用户体验。因此,我们的诉求就是不在可视化窗口内的图片不尽兴加载,尽可能减少本地带宽的浪费和请求资源的数量。
那么,为什么我会推荐懒加载做为普通图片资源的主要实施手段呢?因为它有两大优点。
- 减少带宽资源消耗,减少不必要的资源加载消耗。
- 防止并发加载图片资源导致的资源加载阻塞,从而减少白屏时间。
实现简单的懒加载
那么,懒加载怎么实现呢?实现的方式有两种。
- 通过 scroll 事件来监听视窗滚动区域实现。该方法兼容性好,绝大多数浏览器和 WebView 都兼容支持。
- 通过 IntersectionObserver API 观察 DOM 是否出现在视窗内,该方法优点在于调用简单,只是部分移动端兼容没有上一种方式好。
两种形式都是在观察当前 DOM 是否出现在了可视窗口内,如果出现的话就将 data-src 中的图片地址赋值给 src,然后开始加载当前的图片。
那么,下面我们就开始着手实现一个基于 scroll 事件的懒加载示例吧。
页面布局
我们先画一个基本的页面布局出来,主要是将视窗和图片加载出来。
const ImageLazy = () => {
const [list, setList] = useState([1, 2, 3, 4, 5, 6, 7, 8]);
const ref = useRef<HTMLDivElement | null>(null);
return (
<div className="scroll-view" ref={ref}>
{list.map((id) => {
return (
<div key={id} className="scroll-item">
<img
style={{ width: "100%", height: "100%" }}
data-src={`${prefix}split-${id}.jpg`}
/>
</div>
);
})}
</div>
);
};
.scroll-item {
height: 200px;
}
.scroll-view {
height: 400px;
overflow: auto;
}
可以看效果图,在页面上只显示了两张图片,但其实所有的图片都已经加载完了。
注册 scroll 事件
为scroll-view
绑定了ref
之后,同时需要在useEffect
中对scroll
事件进行绑定和注销。
如下,我先获取当前组件所有的img
元素(真实操作最好使用指定 className),为ref.current
进行addEventListener
添加事件监听操作,然后在回调中执行对应的方法。
同时,在return
的时候,也需要将其事件移除,避免造成一些意外情况。
useEffect(() => {
const imgs = document.getElementsByTagName("img");
console.log(ref.current, "current");
ref.current?.addEventListener("scroll", () => {
console.log("listens run");
});
return ref.current?.removeEventListener("scroll", () => {
console.log("listens end");
});
}, []);
如下图,在我滚动的时候,同时执行了ScrollCallback
,控制台打印了很多执行结果,意味着我们的事件已经添加成功了。
滚动回调 & 节流函数
通过下面的分析图,我们可以看到clientHeight
是我们视窗的高度,而在ScrollView
当中每次滚动都会触发scroll
方法回调,可以拿到当前页面视窗的滚动距离scrollTop
。
那么,我们又如何判断元素是否出现在页面上呢?
通过元素的offsetTop
属性,可以知道当前元素距离顶部的偏移距离。那么,当我们拿到窗口的高度clientHeight
,滚动的距离scrollTop
,以及元素距离顶部的距离offsetTop
,就可以推断出下面一套条件公式,通过视窗高度(dom.clientHeight) + 滚动距离(dom.scrollTop) > 元素距离顶部距离(image.offsetTop)
来判断当前元素是否出现在页面可视范围内了。
将其转换为函数方法实现结果如下:
function scrollViewEvent(images: HTMLCollectionOf<HTMLImageElement>) {
// 可视化区域高度
const clientHeight = ref.current?.clientHeight || 0;
// 滚动的距离
const scrollTop = ref.current?.scrollTop || 0;
// 遍历imgs元素
for (let image of images) {
if (!image.dataset.src) continue;
// 判断src是否已经加载
if (image.src) continue;
//图片距离顶部距离
let top = image.offsetTop;
// 公式
if (clientHeight + scrollTop > top) {
// 设置图片源地址,完成目标图片加载
image.src = image.dataset.src || "";
image.removeAttribute("data-src");
}
}
}
在这里,我也通过ahook
中的useThrottleFn
做一点节流的小优化来避免频繁的进行函数回调。在500ms
内,事件只会执行一次,避免额外执行带来的性能消耗。
import { useThrottleFn } from "ahooks";
// 截流函数hook
const { run } = useThrottleFn(scrollViewEvent, {
wait: 500,
});
同时,我们将其放入到scroll
事件回调中执行。不过,在组件一开始其实是触发不了scroll
事件,因此,需要我们手动来初始化当前第一次页面中的图片数据。
useEffect(() => {
const imgs = document.getElementsByTagName("img");
console.log(ref.current, "current");
ref.current?.addEventListener("scroll", () => {
run(imgs);
});
run(imgs);
return () => {
ref.current?.removeEventListener("scroll", () => {
console.log("listens end");
});
};
}, []);
到此,我们的一个图片懒加载
基本就实现完成了,我们来看看效果吧。
对于懒加载来说,每个
item
最好设置一个高度,防止在一开始没有图片时,组件因为没有高度而导致页面元素暴露下视窗内导致懒加载失效。
高保真图片优化
对于高保真这类图片而言,很多都是由相关运营人员
配置的活动图,一般在大促期间会有很多微页面
,或者是图片链接
,都是通过图片 + 热区
的形式发布给用户浏览的。
因此,绝大多数运营诉求都是尽可能清晰展示对应的图片。那么懒加载显然并不能很好的解决问题,因此我在原先懒加载的基础上,新增了一些加载状态给用户视觉上的体验感官,目前市面上产品主要使用骨架屏
或者是渐进式加载
等方案来让图片显示过渡更加的平滑,避免加载失败或者加载图片卡顿的尴尬。
如何实现
通过下面的这张图,依旧先来梳理下实现逻辑。在图片组件中,分别有两张图进行轮流替换,当高清资源图加载完毕后,需要将骨架图
或者缩略图
隐藏,显示已经加载好的高清图。
图片组件
分析结束后, 可以跟我来实现一个简单的图片组件,通过img
中的onLoad
事件来判断需要显示的图片是否已经加载完了。通过对应的状态(status)
来控制略缩图的显示和隐藏。下面我就以渐进式加载来作为案例,参考下图,我们来实现一个简单的状态切换组件。
import React from "react";
import "./index.css";
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
thumb: string;
}
type ImageStatus = "pending" | "success" | "error";
const Image: React.FC<ImageProps> = (props) => {
const [status, setImageStatus] = React.useState<ImageStatus>("pending");
/**
* 修改图片状态
* @param status 修改状态
*/
const onChangeImageStatus = (status: ImageStatus) => {
/** TODO setTime模拟请求时间 */
setTimeout(() => setImageStatus(status), 2000);
};
return (
<>
<img
className="image image__thumb"
alt={props.alt}
src={props.thumb}
style={{ visibility: status === "success" ? "hidden" : "visible" }}
/>
<img
onLoad={() => onChangeImageStatus("success")}
onError={() => onChangeImageStatus("error")}
className="image image__source"
alt={props.alt}
src={props.src}
/>
</>
);
};
export default Image;
通过filter: blur(25px);
属性,对略缩图添加了一部分模糊效果,这样就可以避免一些马赛克图的尴尬,来达到部分毛玻璃,或者说是高斯模糊的一些小特效。
通过
onLoad
加载完毕的事件,我们做了一个简单的渐进式图片加载,那么相应的类似于骨架屏等其它的加载态也可以通过一样的状态判断来进行实现的。只是将缩略图换成了其它组件,仅此而已。
长渲染图优化
在商品详情页面,运营会配置一些商品的详细描述图文,不仅对图片的质量会比较高,同时图片也会非常长。那么很显然,我们并不可能说直接拿到图片就显示在页面上,如果用户的网速比较慢的情况下,页面上就会直接出现一个很长的白条,或者一张加载失败的错误图。这些很明显不是我们想要的结果。
那么,该怎么办呢?
我们先看下淘宝等电商平台的一个商品详情,当你点开看大图时,会发现只显示了图片的一部分,会分成很多张张大小一致的图片给我们。
依照这个思路,我们也做了相对应的切图优化,将一张长图分成多个等比例大小的多张图块,来进行一个分批渲染调优,减少单次渲染长图的压力。
切图成块
那么,下面我会从模拟后端长图切短图在将其切割好的图片依次显示在页面中进行展示。 话不多说,我们直接进入正题。
首先,结合下面的分析图,我们的切图原理其实非常简单,将一张长图分成长宽相等的小图,如果最后一张不满足切割块高度
的话直接将剩余高度给单切成图片。
那么,下面我就写一个简单的 node 代码来带大家实战一下切图的过程。
Node 切图
如下图,我会模拟一张运营上传的一张长图,然后切割成若干份右边高200
的短图(大小按照需求评估)。下面,我们就来看下实现效果的教程和代码吧。
首先,安装sharp
用于图片处理,image-size
用于图片大小信息的获取。
# shell
yarn add sharp image-size
引入对应的包后,通过image-size
获取我们需要切割的原长图的信息,比如width
和height
。
const sharp = require("sharp");
const sizeOf = require("image-size");
const currentImageInfo = sizeOf("./input.jpg");
当拿到图片的高度和宽度的时候,那么意味着我可以通过一个while
循环将切割的等份高度给计算好。
/** @name 每块大小 200px height */
const SPLIT_HEIGHT = 200;
/** @name 长图高度 */
let clientHeight = currentImageInfo.height;
/** @name 切割小图高度 */
const heights = [];
while (clientHeight > 0) {
/** @if 切图高度充足时 */
if (clientHeight >= SPLIT_HEIGHT) {
heights.push(SPLIT_HEIGHT);
clientHeight -= SPLIT_HEIGHT;
} else {
/** @else 切割高度不够时,直接切成一张,高度清0 */
heights.push(clientHeight);
clientHeight = 0;
}
}
那么,当我知道了切割的图片大小后,就可以对切割好的heights
遍历,通过裁剪偏移
切割成为真实的图片,并生成新的文件并保存起来。
下面代码中,我创建一个marginTop
偏移量,每切割一次,就会将其height
累加向下偏移,直到切割图片到最后一页结束为止。此时mariginTop
为图片的高度。
/** @name 偏移量 */
let marginTop = 0;
heights.forEach((h, index) => {
sharp("./input.jpg")
.extract({
left: 0,
top: marginTop,
width: currentImageInfo.width,
height: h,
})
.toFile(`./img/split_${index + 1}_block.jpg`)
.then((info) => {
console.log(`split_${index + 1}_block.jpg切割成功`);
})
.catch((err) => {
console.log(JSON.stringify(err), "error");
});
marginTop += h;
});
如下图,img文件夹
下多了一些零碎的图片
,然后检查一下图片是否拼接完整
,如果没有问题的话,那么我们就完成了一个简单的长图切块的需求,下一步就是放到前端进行渲染
了。
前端展示
前端拿到对应的切片后,直接拼凑在前端页面上展示,处理掉中间的缝隙或者和毛边后,和长图渲染毫无差别。我渲染时依旧是采用懒加载的形式做了简单的加载优化,整体效果如下:
看完了加载效果后,那么来看看加载的响应时间吧。
由于我切好的图片并没有做文件上的优化
,因此单张图存在体积过大
,但是丝毫不影响首屏的加载,下面可以看一张在Flow3G
下的加载时间对比。
对于单张长图意味着用户可能会看到 4s 左右的白屏
或者是骨架屏
,而切片后加载可以优先的将部分内容展现给用户。
解决毛边或者缝隙
本方案在最终实现时,可能会有部分瑕疵,具体切图方案和设备型号有关系。如果碰到问题,可以参考我的一些解决方案:
- 第一种是通过
vertical-center
设置垂直中心值来解决基线对齐问题。 - 第二种是将
img
设置成一个真实block
元素解决。 - 第三种是我常用的是通过
flex-direction
设置为column
为子元素做垂直排列解决问题。 - 第四种是通过
background
图片的方式解决问题。但这样做的话如果想使用懒加载,就需要更改部分css
样式偏移来达到可视窗口显示。
参考资源
- # Progressively Loading Images In React
- # 前端性能优化——图片篇
- # 懒加载和预加载
- # 性能优化之组件懒加载: Vue Lazy Component 介绍
- # 示例图片&Demo 地址
总结
本篇文章中,我讲了三种不同图片的优化策略。市面上已经有很多开源库
能够较为方便的实现懒加载
和渐进加载
的方式。同时,对于骨架屏,很多组件库都有对应的组件,封装起来成本也较小。
因此,如果在项目中确实涉及很多图片资源,那么文章中提到的优化方案是我比较推荐的。
- 能做成懒加载的尽量不要全量加载
- 给予用户一定的状态提示,骨架屏或者是过渡图能做尽量别拉下。
- 长图能切图尽量切图,将其拆开来优化是非常方便的。
- 能压缩的图片尽可能去进行压缩。