如何懒加载图片

前言

vueuse/core 中有 useElementVisibility 以及 useIntersectionObserver 这两个 API

刚好可以写写关于图片懒加载的一些实现方法。

正文

什么是图片懒加载

可以简单的理解为只有图片在第一次进入用户可视范围时进行加载的一个技术。

为什么要图片懒加载

对于比较小型的网站,对于首页的图片资源,大部分情况下都是直接加载的。

但是当网站首页变复杂,比如超多的展示图的时候,不在用户可视区域的部分的图片其实是可以不去加载的。

因为这些图片对用户来说不可感知,完全可以在进入用户可视区域之后再发出请求进行加载。

这就是我们常说的图片懒加载,比如视频网站 Bilibili

B 站的首页对于每一个分区都会展示一部分的视频推荐。

这些视频都会有一张封面,如下:

这一个分区就有 12 张封面图了,而 B 站的分区类型是非常多的,如下:

如果直接展示,即使再怎么去压缩图片质量,图片的加载也是要耗费一定的流量的,而这些图片的加载是完全没必要的。

因为可能用户就只是打开首页,然后点击进入个人中心,或者直接点击搜索框进行视频搜索。

至少对于我来说是这样的,我个人很少下拉去查看各个分区的视频。

所以懒加载这些不在可视区域的图片是非常有必要的。

一方面,如果是个人网站,没使用 CDN 的话,可以减轻服务器的压力,这样单位时间内服务器可以对更多的用户服务,如果走 CDN 的话,可以减少流量消耗,毕竟走 CDN 的钱也不是大风刮来的,能扣就扣,你说是吧。

另一方面,可以加快网页的访问速度,现在大部分的网站都还是使用 HTTP/1.1 ,而 HTTP/1.1 虽然支持长连接以及 TCP 连接复用,但是依然受限于 TCP 数量的限制,同时请求多个图片,可能把 TCP 连接给占满了,可能会影响后续 js 文件的请求,导致网页无法正常地对用户的操作进行响应。

HTTP/2 很好地解决了 TCP 连接数受限的问题,不过这个不是本文的重点。

戳这里查看 HTTP/2 和 HTTP/1.1 的图片加载速度测试 DEMO

当然,现在 B 站的图片基本上都是 HTTP/2 的了。

如何实现懒加载

根据前面的解释,基本上我们的思路就有了。

在不可视区域的图片都使用一张占位图,这样除首屏可视图片之外的图片吗,就只要请求一个图片即可。

在用户滚动的时候,不断的判断页面上的 img 是否出现在用户的可视区域中。

如果到达了用户的可视区域,那么把 imgsrc 切换为真实的图片地址,然后浏览器就会自动的请求图片了。

当然已经经过加载的图片,再次经过用户的可视区域的时候,就不应该在执行切换 src 的逻辑了,因为这已经没有意义了。

经过这段分析,我们可以得出第一种方式。

PS:这里我们都使用 Vue 组件的方式来编写代码。

通过监听 scroll 事件来判断图片是否在可视区域

首先我们需要确认这个 Vue 组件需要什么 props

首先必须有一个真实的图片地址。

其次由于我们使用占位图片来减少图片请求,所以需要另一张占位图片的地址。

在真实图片访问失败之后,需要一张表示错误的占位图片来展示,所以还需要一张表示加载错误的占位图片地址。

这样子,我们就可以写出 props 的结构了。

1
2
3
4
5
6
7
<script setup lang="ts">
const props = defineProps<{
src: string;
defaultSrc: string;
errorSrc: string;
}>();
</script>

对于组件的方式,就一个 img ,简单。

1
2
3
<template>
<img />
</template>

这里需要绑定 imgsrc ,这里初始化一个 currentSrcref

以及需要一个 ref 来拿到 img 这个元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
// ...

const currentSrc = ref<string>('');
const imgRef = ref<HTMLImageElement | null>(null);
</script>

<template>
<img
ref="imgRef"
:src="currentSrc"
/>
</template>

在加载目标图片出现错误的时候启用错误图片的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
// ...

// 添加一个加载错误的回调
const onError = () => {
currentSrc.value = props.errorSrc;
}
</script>

<template>
<img
ref="imgRef"
:src="currentSrc"
@error="onError"
/>
</template>

默认情况下由于图片不可视,所以要启用默认的占位图片。

1
2
3
4
5
6
7
8
<script setup lang="ts">
// ...

onMounted(() => {
// 启用默认的占位图片
currentSrc.value = props.defaultSrc;
});
</script>

接着就是主要的 onScroll 回调的编写了。

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
<script setup lang="ts">
// ...

const onScroll = () => {
const rect = imgRef.value!.getBoundingClientRect();
const { top, left } = rect;
// 这里不仅判断了 top ,也判断了 left ,这样可以在横向滚动的时候也适用
if (top < window.innerHeight && left < window.innerWidth) {
currentSrc.value = props.src;
}
}

onMounted(() => {
// ...
// 执行一次判断,不然已经在可视区域内的图片指向了 defaultSrc ,而不是 src
onScroll();
// 监听
window.addEventListener("scroll", onScroll);
});

onBeforeUnmount(() => {
// 取消监听
window.removeEventListener("scroll", onScroll);
});
</script>

这里放下完整的代码。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps<{
src: string;
defaultSrc: string;
errorSrc: string;
}>();

const currentSrc = ref<string>("");
const imgRef = ref<HTMLImageElement | null>(null);

const onError = () => {
currentSrc.value = props.errorSrc;
};

const onScroll = () => {
const rect = imgRef.value!.getBoundingClientRect();
const { top, left } = rect;
if (top < window.innerHeight && left < window.innerWidth) {
currentSrc.value = props.src;
}
};

onMounted(() => {
currentSrc.value = props.defaultSrc;
onScroll();
window.addEventListener("scroll", onScroll);
});

onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
});
</script>

<template>
<img
ref="imgRef"
:src="currentSrc"
@error="currentSrc === errorSrc ?? onError"
/>
</template>

戳这里查看 demo

可以看到,这里的核心的 APIgetBoundingClientRect ,它能够获取元素当前的大小及其相对于视口的位置。

Element.getBoundingClientRect() - MDN

返回的 topbottomleftright 这四个属性代表的意义如下图:

这里要注意 onMounted 里面执行一次 onScroll ,原因是,如果我们不在 onMounted 里面执行一次 onScroll 的话,就会出现图片指向了默认图片,然后一滑动就指向真正的图片了。

戳这里查看 demo

当然,我们这个代码还有一些瑕疵,比如当图片已经进入过一次可视区域进行加载之后, scroll 事件的绑定函数就应该取消掉了。

也就是需要在 onScrollif 条件内加上 window.removeEventListner('scroll', onScroll)

以及我们现在对于 if 的判断是通过 topleft 来判定的。

我们可以想象一下,如果刚好有这么一张图片,刚打开网页的时候不渲染(理解为 v-if="false" ),往下拖动到这张图片进入不可视范围的时候,图片开始渲染。

这时候我们肯定希望它指向默认的占位图片,但实际上它已经指向了真正的图片地址了。

也就是说,我们不仅要判断 topleft, 也要判断 rightbottom

topleft 判断了元素从下方进入可视区域和从右方进入可视区域。

bottomright 判断了元素了从上方进入可视区域和从左方进入可视区域。

这时候 if 判断就要修改为如下了:

1
2
3
4
5
const rect = imgRef.value!.getBoundingClientRect();
const { top, left, bottom, right } = rect;
if (top < window.innerHeight && left < window.innerWidth && bottom > 0 && right > 0) {
currentSrc.value = props.src;
}

vueuse/core 中,正好为我们封装了这样的一个的函数 useElementVisibility

useElementVisibility - vueuse

在源码中,我们也可以看到判断是否在可见区域的逻辑。

1
2
3
4
5
6
elementIsVisible.value = (
rect.top <= (window.innerHeight || document.documentElement.clientHeight)
&& rect.left <= (window.innerWidth || document.documentElement.clientWidth)
&& rect.bottom >= 0
&& rect.right >= 0
)

可以看到,和我们上面的逻辑基本一样。

不过,我们发现存在 window.innerHeight || document.documentElement.clientHeight 这样的写法。

window.innerHeight 返回了浏览器窗口的视口高度,如果有水平滚动条,也包括滚动条高度。

document.documentElement.clientHeight 返回元素内部的高度,包含内边距,但不包括水平滚动条、边框和外边距。

其中 document.documentElement 可以简单理解为 html 元素即可。

看起来像是为了兼容 IE 的写法, 可是 vue2 已经不支持 IE8 及以下的版本了。

查了下 Can I Use 发现 innerWidth 这个属性 IE8 以下不支持。

似乎没有什么必要啊…难道是滚动条的问题吗? emmm,不懂…

通过 IntersectionObserver 来观察图片是否在可视区域

IntersectionObserver 是一个构造器,可以通过创建该类型的对象,可以对元素进行异步地观察,当可见部分超过了预先设置的阈值之后,调用创建该对象时传入的回调函数。

Intersection Observer - MDN

简单点理解就是浏览器原生实现了 onScroll 这个函数的逻辑。

那么我们的代码就可以变为如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps<{
src: string;
defaultSrc: string;
errorSrc: string;
}>();

const currentSrc = ref<string>("");
const imgRef = ref<HTMLImageElement | null>(null);
let observer: IntersectionObserver | null = null;

const onError = () => {
currentSrc.value = props.errorSrc;
};

onMounted(() => {
currentSrc.value = props.defaultSrc;
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
currentSrc.value = props.src;
observer!.unobserve(imgRef.value!)
}
});
observer.observe(imgRef.value!);
});

onBeforeUnmount(() => {
observer!.unobserve(imgRef.value!);
});
</script>

<template>
<img
ref="imgRef"
:src="currentSrc"
@error="currentSrc === errorSrc ?? onError"
/>
</template>

看起来相当的简单!

戳这里查看 demo

这里需要注意,IntersectionObserver 支持对指定容器进行观察,默认情况下对整个 document 进行观察

IntersectionObserver 使用了 threshold 阈值这个概念,简单理解为就是目标元素与容器元素相交部分的大小占目标元素的大小的比重,值为 0 ~ 1 ,默认情况下为 0 ,即边框相交就直接调用回调函数。

IntersectionObserver 使用了 roomMargin 来扩大或者缩小检测的边框,该值默认为 0px 0px 0px 0px ,即边框就是对应的 widthheight

举例来说,如果将 roomMargin 设置为 0 0 100px 0 的话,意味着检测的边框向“外”扩大了,即图片会更早地被加载。

相反,如果将 roomMargin 设置为 0 0 -100px 0 的话,意味着检测的边框向“内”收缩了,即图片会更晚地被加载。

如果还是对相关的参数不是很了解,可以直接去 MDN 的相关页面查看解释:

Intersection Observer API - MDN

中文页后半部分还没完全翻译好,凑合着看。

除了第二个参数,我们还在第一个参数的回调中使用数组解构的语法解构出了一个 entry 对象,使用了它的 isIntersecting 属性,这个属性返回 true 意味着从非相交转到相交,返回 false 则相反。

当然除了 isIntersecting 属性,也有其他一些属性,可以获取相关的布局信息,不过这里用不到,就不详细展开了,可以去 MDN 上查看:

IntersectionObserverEntry - MDN

vueuse/core 中,也为我们封装了这样的一个的函数 useIntersectionObserver

useIntersectionObserver - vueuse

其中对是否支持 IntersectionObserver 的判断为 const isSupported = window && 'IntersectionObserver' in window

所以根据这个就可以做一些降级处理,从 IntersectionObserver 降级到 getBoundingClientRect

这两种懒加载方法的区别

上面我们讲了两种方法

  • 监听 scroll 事件,通过 getBoundingClientRect 来获取位置,从而进行计算和判断。
  • 使用原生的 IntersectionObserver 创建对象。

兼容性

兼容性上讲,getBoundingClientRect 的兼容性是完胜 IntersectionObserver

IntersectionObserver 的兼容性如下:

getBoundingClientRect 兼容性如下:

兼容 IE 这点 IntersectionObserver 完败哈哈哈哈。

IntersectionObserver 的兼容性还是相当不错的,所以如果项目对兼容性要求不高的话,大胆地上吧。

哦对了,回调内的 IntersectionObserverEntry 对象的属性现在还是实验性质的,未来可能还会发生变化,不过就单单 isIntersecting 这个属性,我觉得应该是不会出现什么改动的,嗯,大概…

性能

首先,我们知道 getBoundingClientRect 这个方法是会立即清空(执行)浏览器的重排缓冲队列,如果网站的动画很多的话,可能会增加重排的次数,造成网站卡顿。

而且调用 getBoundingClientRect 是一个同步的过程,如果操作时间过长,意味着主线程被长时间的占用,可能会影响到用户的其他交互,并且由于 scroll 的调用频率是非常高的,在 scroll 事件中调用 getBoundingClientRect ,在老人机上就完全有可能造成相当严重的卡顿,所以我们可以在这个加个节流,一定程度上来缓解 scroll 调用频率过高问题。

新的 IntersectionObserver 实现是异步的,这意味着不需要在 scroll 事件中进行大量的计算,类似于 requestIdleCallback ,它的优先级是比较低的,这可以防止长时间占用主线程造成的页面无响应。

这里贴一个 IntersectionObserverpolyfill

polyfill/intersection-observer.js - w3c

在实现的第 369370 可以看出,降级之后还是通过监听 scrollresize 事件来检测是否相交的。

143 行,使用了节流函数 throttle 来包住 _checkForIntersections

so,直接上 IntersectionObserver 就完事了,大佬都把降级写好了,拿来引入即可。

后记

如果你看到这里了,那么对于第一个通过 scroll 实现的懒加载还是有一个问题。

那就是没监听 resize 事件,因为窗口大小的变化也会改变元素的可见状态。

当然,检测可见这种技术不仅可以用在图片懒加载,还可以用在很多地方,比如:

  • 视频进入可视窗口自动播放
  • 监听一个占位元素来实现下拉到最后自动刷新
  • 检测用户对某个区域的停留时长,比如广告,消息区域等等
  • 在元素可见之后再执行该元素的动画

虽然它真的很厉害,但是我到现在还没在项目中用到它…