图片懒加载原理
# 问题
图片懒加载原理是一道经典的前端面试题,懒加载,顾名思义,它有点“懒”,不喊它,它就不出现。
如果一次性加载,会怎么样:
1.图片数量多的时候,全部加载会占用网站带宽,如果一次性发出大量的请求,会产生TCP阻塞,导致网站性能下降。
2.图片全部渲染也会增加GPU的绘制工作量,让用户浏览器性能下降。
3.用户的流量白白消耗。
# 3种实现方案
用户的滚动容器显示范围是有限的,那么只需要加载显示范围内的图片即可,这类似于列表分页。当图片所在区域出现在用户视角内时,加载该图片。如此,就可以达到懒加载的效果。
判断元素是否出现在视口,一般有3种方案:
- 滚动监听+scrollTop+offsetTop+clientHeight
- 滚动监听+getBoundingClientRect()
- intersectionObserve()
# 滚动监听+scrollTop+offsetTop+clientHeight
- scrollTop:指被滚动条卷去的部分。
- offsetTop:目标元素相对父元素的位置
- clientHeight:当前父元素容器的高度。
三个属性之间的关系如图所示,故当scrollTop+clientHeight > offsetTop,即图片在视口内,否则图片在可视区域外。
# 滚动监听+getBoundingClientRect()
Element.getBoundingClientRect()
方法返回一个DOMRect
(opens new window) 对象,其提供了元素的大小及其相对于视口 (opens new window)的位置。该对象使用
left
、top
、right
、bottom
、x
、y
、width
和height
这几个以像素为单位的只读属性描述整个矩形的位置和大小。除了width
和height
以外的属性是相对于视图窗口的左上角来计算的。
判断是否在视口内:当目标元素的top<父元素的top+父元素的clientHeight时,就位于父元素视口内。
# intersectionObserve()
intersectionObserve也称为“交叉观察器”,它可以指定观察多个节点,当目标元素的可见性发生变化时,会调用观察器的回调函数。callback
函数的参数是一个数组,每个成员都是一个IntersectionObserverEntry
(opens new window)对象。IntersectionObserverEntry对象有个intersectionRatio属性,它表示目标元素在视口中的可见比例,完全可见时为1
,完全不可见时小于等于0
。
# elment-plus源码解析
找到官网的Image组件 (opens new window),用法是只需要加入lazy
属性就可以开启懒加载功能。然后找到其源码,位于packages\components\image\src\image.vue
。
1.组件onMounted
onMounted(() => {
if (isManual.value) {
addLazyLoadListener()
} else {
loadImage()
}
})
const isManual = computed(() => {
if (props.loading === 'eager') return false
return (!supportLoading && props.loading === 'lazy') || props.lazy
})
2
3
4
5
6
7
8
9
10
11
描述:设置了lazy
属性后就开始添加懒加载监听事件(addLazyLoadListener)。
2.监听父容器的scroll事件
async function addLazyLoadListener() {
if (!isClient) return
await nextTick()
const { scrollContainer } = props
if (isElement(scrollContainer)) {
_scrollContainer.value = scrollContainer
} else if (isString(scrollContainer) && scrollContainer !== '') {
_scrollContainer.value =
document.querySelector<HTMLElement>(scrollContainer) ?? undefined
} else if (container.value) {
_scrollContainer.value = getScrollContainer(container.value)
}
if (_scrollContainer.value) {
stopScrollListener = useEventListener(
_scrollContainer,
'scroll',
lazyLoadHandler
)
setTimeout(() => handleLazyLoad(), 100)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这里处理了3件事:
2.1 找到可滚动的父容器
2.2 监听父容器scroll事件
2.3 立即触发一次handleLazyLoad
3.判断当前图片是否在视图内。如果在,加载图片,并移除懒加载监听
function handleLazyLoad() {
if (isInContainer(container.value, _scrollContainer.value)) {
loadImage()
removeLazyLoadListener()
}
}
2
3
4
5
6
export const isInContainer = (
el?: Element,
container?: Element | Window
): boolean => {
if (!isClient || !el || !container) return false
const elRect = el.getBoundingClientRect()
let containerRect: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>
if (container instanceof Element) {
containerRect = container.getBoundingClientRect()
} else {
containerRect = {
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
left: 0,
}
}
return (
elRect.top < containerRect.bottom &&
elRect.bottom > containerRect.top &&
elRect.right > containerRect.left &&
elRect.left < containerRect.right
)
}
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
可以看到,element-plus是使用了getBoundingClientRect
来判断元素是否出现在视口。
# 手写3种懒加载实现
下面依次写出3种Vue3的实现,全部代码见源码 (opens new window)。
- scrollTop
<template>
<div class="Scroll-top-wrap" @scroll="onScroll">
<div ref="itemRef" v-for="(item, index) of imgs" :key="index" class="item">
<img alt="" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { imgs } from './constant'
const itemRef = ref(null)
const onScroll = (e) => {
const { scrollTop, clientHeight } = e.target
itemRef.value.forEach((item, index) => {
if (scrollTop + clientHeight <= item.offsetTop) return
const { children } = item
if (children?.[0] && !children[0].src) {
children[0].src = imgs[index]
}
})
}
</script>
<style lang="scss" scoped>
.Scroll-top-wrap {
height: 600px;
border: 1px solid;
overflow: scroll;
.item {
min-height: 200px;
img {
display: block;
}
}
}
</style>
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
- getBoundingClientRect
import { ref } from 'vue'
import { imgs } from './constant'
const parentRef = ref(null)
const itemRef = ref(null)
const onScroll = () => {
const { top: parentTop, bottom: parentBottom } =
parentRef.value.getBoundingClientRect()
itemRef.value.forEach((item, index) => {
const { top: childTop } = item.getBoundingClientRect()
if (childTop < parentTop || childTop > parentBottom) return
const { children } = item
if (children?.[0] && !children[0].src) {
children[0].src = imgs[index]
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- intersectionObserve
<template>
<div class="Intersection-observe-wrap">
<img
ref="itemRef"
v-for="(item, index) of imgs"
:key="index"
class="item"
alt=""
:data-src="item"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { imgs } from './constant'
const itemRef = ref(null)
onMounted(() => {
const observerInstance = new IntersectionObserver((entries) => {
entries.forEach((item) => {
// console.dir(item.target)
if (item.intersectionRatio > 0) {
item.target.src = item.target.dataset.src
}
})
})
itemRef.value.forEach((item) => {
observerInstance.observe(item)
})
})
</script>
<style lang="scss" scoped>
.Intersection-observe-wrap {
height: 600px;
border: 1px solid;
overflow: scroll;
.item {
min-height: 200px;
display: block;
}
}
</style>
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
43
44
45
46
47
48