图片惰性加载 IntersectionObserver

关于惰性加载

惰性加载又称为延迟加载、懒加载等,’lazyload’,即在长网页中延迟加载图像。当图片未到达可视区域时,不加载图片。如下图所示:

1.gif

优点:提升用户的体验,如果图片数量较大,打开页面的时候要将将页面上所有的图片全部获取加载,很可能会出现卡顿现象,影响用户体验。因此,有选择性地请求图片,这样能明显减少了服务器的压力和流量,也能够减小浏览器的负担。

实现方案

惰性加载可以说在很久之前就已经出现了,实现方案也很简单。

  • 在 HTML 文件中将需要惰性加载的图片的 src 属性置为一个相同的地址(一般设置为一张 loading 图),这样这张图只会加载一次(第二次即会读取缓存),或者干脆置为空(用户体验不好),将真实的图片地址存储在别的属性中(比如 data-src 属性)
  • 监听事件(比如 scroll 事件),判断需要惰性加载的图片是否已经在可视区域,如果是,则将 src 属性替换成 data-src 属性值

方案中,我们主要实现的功能有:判断图片和视口的位置关系,主要判断函数如下:

1
2
3
4
5
6
7
8
/**
* 比较元素位置 d2, 在d1中
*/
function _compareOffset(d1, d2) {
let left = d2.right > d1.left && d2.left < d1.right;
let top = d2.bottom > d1.top && d2.top < d1.bottom;
return left && top;
}

图片的坐标大小信息我们可以通过 getBoundingClientRect 来获得,浏览器窗口信息则为:

1
2
3
4
5
6
let w = {
t = 0,
l = 0,
r = l + window.innerWidth,
b = t + window.innerHeight
}

剩下的只要监听 scroll 事件,对所有的 img.lazy-load 元素判断就好了。

监听 scroll 存在的性能问题

当我们监听 scroll 事件的时候,页面滚动会导致该事件频繁出发,这对浏览器的性能产生很大的影响。为此我们可以加入函数节流(throttle)或函数去抖(debounce)来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const _throttle = (func, wait, mustRunDelay = Infinity) => {
let timeout = null;
let start_time;
return function() {
let context = this,
args = arguments,
curr_time = +new Date();
clearTimeout(timeout);
if(!start_time) {
start_time = curr_time;
}
if(curr_time - start_time >= mustRunDelay) {
func.apply(context, args);
}else {
timeout = setTimeout(function() {
func.apply(context, args);
}, wait);
}
}
};

wait 即判断的间隔事件,我们通常设置成 100ms 就可以不影响用户的体验。如果你想进一步提升图片加载性能我们也可以指定的将某些图片,base64 后存入 localStorage 中,等以后打开的时候,首先判断 localStorage 中是否有图片缓存,没有则去请求。这样就可以进一步加快图片的载入速度。

完整的代码,可以查看 si-img 图片优化组件

IntersectionObserver API

虽然我们使用了函数节流来降低 scroll 的频率,但是检测函数触发的频率依然很高,这之中的大多数检测都是没有意义的(并没有新的图片进入)。为了更好的解决这类问题,浏览器也提供了 IntersectionObserver API 来帮助我们判断元素是否进入可是区域。

由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做”交叉观察器”。

API

使用方式十分简单:

1
var io = new IntersectionObserver(callback, option);

上面代码中,IntersectionObserver是浏览器原生提供的构造函数,接受两个参数:callback是可见性变化时的回调函数,option是配置对象(该参数可选)。
构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。

1
2
3
4
5
6
7
8
// 开始观察
io.observe(document.getElementById('example'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();

上面代码中,observe的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。

1
2
io.observe(elementA);
io.observe(elementB);

callback

目标元素的可见性变化时,就会调用观察器的回调函数callback。
callback一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。

1
2
3
4
5
var io = new IntersectionObserver(
entries => {
console.log(entries);
}
);

callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。

IntersectionObserverEntry 对象

IntersectionObserverEntry对象提供目标元素的信息,一共有六个属性:

  • time:可见性发生变化的时间ms
  • target:被观察的目标元素,是一个 DOM 节点对象
  • rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
  • intersectionRatio:目标元素的可见比例,即intersectionRect占boundingClientRect的比例,完全可见时为1,完全不可见时小于等于0

options 参数

其中,options 整个参数对象以及它的三个属性都是可选的:

root 属性

IntersectionObserver API 的适用场景主要是这样的:一个可以滚动的元素,我们叫它根元素,它有很多后代元素,想要做的就是判断它的某个后代元素是否滚动进了自己的可视区域范围。这个 root 参数就是用来指定根元素的,默认值是 null。
如果它的值是 null,根元素就不是个真正意义上的元素了,而是这个浏览器窗口了,可以理解成 window。

threshold 属性

当目标元素和根元素相交时,用相交的面积除以目标元素的面积会得到一个 0 到 1(0% 到 100%)的数值:
IntersectionObserver API 的基本工作原理就是:当目标元素和根元素相交的面积占目标元素面积的百分比到达或跨过某些指定的临界值时就会触发回调函数。threshold 参数就是用来指定那个临界值的,默认值是 0,表示俩元素刚刚挨上就触发回调。可以指定多个临界值,用数组形式,比如 [0, 0.5, 1],表示在两个矩形开始相交,相交一半,完全相交这三个时刻都要触发一次回调函数。

rootMagin 属性

rootMargin 可以给根元素添加一个假想的 margin,从而对真实的根元素区域进行缩放。比如当 root 为 null 时设置 rootMargin: “100px”,实际的根元素矩形四条边都会被放大 100px。

利用 IntersectionObserver 实现高效的图片惰性加载

首先获取哪儿些图片需要被IntersectionObserver 观测,这里我们认为带有 .lazy-load 类的图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 获取所有要观测的图片
const images = document.querySelectorAll('.lazy-load');
const config = {
// 如果图像在Y轴上的50像素内,则开始下载。
rootMargin: '50px 0px',
threshold: 0.01
};
// 观察图片
let observer = new IntersectionObserver(onIntersection, config);
images.forEach(image => {
observer.observe(image);
});
}
```
之后我们在 `onIntersection` 中处理图片加载逻辑:
```javascript
function onIntersection(entries) {
// 查看所有观察的图片
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
// 停止观察,并加载图片
observer.unobserve(entry.target);
preloadImage(entry.target);
}
});
}

这样重写后的惰性加载插件性能将大大的提升,而且逻辑也变得十分简单。

浏览器支持

下图为该API的支持情况:
2.png
很遗憾safari还不支持该属性,你可以利用下面代码来简单实现降级方案:

1
2
3
4
5
6
7
8
9
if (!('IntersectionObserver' in window)) {
// 如果不支持,则立刻加载所有图片
Array.from(images).forEach(image => preloadImage(image));
} else {
observer = new IntersectionObserver(onIntersection, config);
images.forEach(image => {
observer.observe(image);
});
}

如果,你真的想在不支持的浏览器下体验该属性,你可以下载下面这个 polyfill。w3c/IntersectionObserver