先说三个情景,第一是懒加载图片,会先加载你可视化区域内的图片,当滚动条往下滑动的时候才会加载下面的;第二是瀑布流布局,随着滚动条往下滑动,会一直加载图片出来是一个无限滚动的效果;第三个是播放一个视频,当滚动条往下滑动导致视频显示不全的时候,视频会暂停,等往上滑动视频又全部显示的时候才会继续播放。这三个效果它们都跟滚动条有关系,按照常理我们要去检测滚动事件,计算滚动条的位置,然后做相应的处理,但是这样不仅效率低,而且滚动事件会不停的触发,并且实现起来比较麻烦。但是Intersection Observer API可以很好的帮我们处理这个问题。
Intersection表示交叉,指两个元素有个交叉,Observer 表示观察,简单来说就是观察两个元素有没有交叉,如果我们观察的目标元素和可视化区域有交叉了或者没有交叉了,我们就可以去触发事件。
1.懒加载图片
先搭建一下场景:放一千张图片,其中src下的是静态资源,是默认图片,默认图片可以加入缓存所以加载起来很快,真实图片放到自定义属性data-src,目前显示的是默认图片,当图片进入到可视化区域的时候加载真实图片。
<template>
<div class="intersection-observer-api">
<div class="header">
这里是IntersectionObserverAPI组件的内容。
</div>
<div class="container card-border">
<div class="item" v-for="item in 1000" :key="item">
<img src="./imgs/1.jpg" alt="" data-src="https://picsum.photos/400/600?r=1" />
</div>
</div>
</div>
</template>
整体思路是,我们去观察这个图片,如果这个图片跟我们的可视化区域有没有交叉,有交叉说明这个图片出现在我们的可视化区域里面,这个时候我们就把这个真实图片显示出来。
首先我们创建一个ob,就是观察者IntersectionObserver,这里面要传递一个回调函数,这个回调函数就是交叉的时候会运行,比如从交叉变到不交叉,或者从不交叉变到交叉,这个时候就会运行。与此同时,在调用这个回调函数的时候,还要传递一个配置,这个配置也很简单,一个是root,一个是rootMargin,一个是threshold。
-
root: 表示要观察的元素跟谁交叉,比如我们观察那个图片要跟谁交叉,一般就是它的父元素或者父元素的父元素等等(不能是子元素),如果是null表示跟可视化区域交叉。
-
rootMargin:对交叉范围进行扩张,比如下面这个图就属于图片和可视化区域进行了交叉。
而rootMargin可以把这个范围扩大,比如值为10px,就会把交叉范围往外扩大10px,如下图就算进行了交叉。当然可以收缩,值写为负值就行。
- threshold:表示交叉的阈值,填写的范围在
0~1之间,比如说0.5,就是说这个图片必须要有一半和可视化区域交叉才会触发回调函数。写0的话只要是碰上了就会执行。
const ob = new IntersectionObserver(() => {
console.log('进入视口');
}, {
root: null, // 默认值为null,表示以浏览器视口作为容器
rootMargin: '0px', // 默认值为'0px'
threshold: 0.1 // 默认值为0,表示当目标元素有10%的区域进入视口时触发回调
});
这样的话还差一个东西,就是要观察的目标,我们这里要观察的目标就是所有带有自定义属性data-src的图片。我们要先获取所有要观察的目标,然后去观察每个目标,怎么观察呢,就是给每个目标调用前面定义的ob对象的observe方法。
// 获取所有要观察的图片元素
const imgs = document.querySelectorAll('img[data-src]');
// 观察每个图片元素 怎么观察就是调用ob对象的observe方法
imgs.forEach((img) => {
ob.observe(img);
});
我们运行一下试试看。
可以看到后台已经有打印了,说明ob的回调方法已经调用了,但是为什么只调用了一次呢,明明有很多图片都交叉了,其实是这样的,它把所有的交叉结果会通过回调函数的参数entries传给你,entries是一个数组,我们去打印一下这个数组。
const ob = new IntersectionObserver((entries) => {
console.log(entries);
}, {
root: null, // 默认值为null,表示以浏览器视口作为容器
rootMargin: '0px', // 默认值为'0px'
threshold: 0.1 // 默认值为0,表示当目标元素有10%的区域进入视口时触发回调
});
可以看到这个数组有1000项,这是因为我们有一千个目标。但是我们交叉的图片只有20个,我们去看一下数组前20个元素,每一个元素都是一个Entry对象,每一个Entry对象里面就记录了这个目标跟我们的可视化区域是怎么交叉的,要注意两个属性,一个是target,表示目前观察的目标,第二个属性是isIntersecting,它表示目标是否和可视化区域进行了交叉。
可以看到前20个目标的isIntersecting为true,表示目标和可视化区域已经交叉了。
所以我们就在回调函数里面去循环这个entries参数,判断每一项的isIntersecting,就可以知道这个目标跟我们的可视化区域到底有没有交叉。如果目标交叉了我们就可以直接通过Entry对象的target属性获取到目标,然后给目标的src属性重新赋值为data-src的值。然后调用ob对象的unobserve方法停止观察该目标。
const ob = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) { // 判断元素是否进入视口
const img = entry.target; // 获取目标元素
const dataSrc = img.getAttribute('data-src'); // 获取图片的真实路径
if (dataSrc) {
img.src = dataSrc; // 设置图片的src属性,开始加载图片
img.removeAttribute('data-src'); // 移除data-src属性,避免重复加载
}
ob.unobserve(img); // 停止观察该元素,提高效率
}
});
}, {
root: null, // 默认值为null,表示以浏览器视口作为容器
rootMargin: '0px', // 默认值为'0px'
threshold: 0.1 // 默认值为0,表示当目标元素有10%的区域进入视口时触发回调
// 获取所有要观察的图片元素
const imgs = document.querySelectorAll('img[data-src]');
// 观察每个图片元素 怎么观察就是调用ob对象的observe方法
imgs.forEach((img) => {
ob.observe(img);
});
效果如下,因为不是很明显,所以我给threshold值设置为0.5了,这样能看的清楚点。
2.瀑布流布局
我们先来看一下情况。
就是当下面的loading出现的时候我们就往瀑布流布局添加图片。那这种情况下,我们应该观察哪个元素呢,上个案例我们观察的是图片和可视化区域的交叉,如果交叉了就去加载图片,而在这里,我们要观察这个loading,如果loading和可视化区域交叉了,我们就往瀑布流里面添加图片。
我先把全部代码展示出来,里面有瀑布流布局的生成代码。
<template>
<div class="intersection-observer-api">
<div class="header">
这里是IntersectionObserverAPI组件的内容。
</div>
<div class="container card-border">
<!-- Masonry 布局容器 -->
<div class="grid"></div>
<!-- 加载更多的占位符 -->
<div class="spin"></div>
</div>
</div>
</template>
<script>
export default {
name: 'IntersectionObserverAPI2',
data() {
return {
// Masonry 实例
masonry: null,
// 是否正在加载图片
isLoading: false,
}
},
mounted() {
this.initData();
const ob = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
this.loadMoreImages(10);
}
},
{
root: null,
rootMargin: '0px',
threshold: 1,
}
);
// 获取加载更多的占位符元素
const spin = document.querySelector('.spin');
// 观察该元素
ob.observe(spin);
},
methods: {
async initData() {
// 声明Masonry类 然后创建Masonry实例
await this.createMasonryInstance();
// 最开始加载10张图片
await this.loadMoreImages(10);
},
// 创建 Masonry 类实例
createMasonryInstance() {
class Masonry {
constructor(options) {
// 获取布局容器,假定该容器内部的所有元素都是图片元素
this.container = options.container;
// 获取列数
this.columnNumber = options.columnNumber || 4;
// 获取行列间隙
this.gap = options.gap || 10;
// 设置布局容器为相对定位,因为内部的所有元素将使用绝对定位
this.container.style.position = 'relative';
// 初始化列高
this.columnHeights = new Array(this.columnNumber).fill(0);
// 获取列宽
this.columnWidth = this._getColumnWidth();
this.defaultImagePath = './imgs/1.jpg';
}
/**
* 计算列宽
*/
_getColumnWidth() {
const containerWidth = this.container.clientWidth;
const totalGapWidth = (this.columnNumber - 1) * this.gap;
return (containerWidth - totalGapWidth) / this.columnNumber;
}
_onAllImageLoaded(imgElements) {
return new Promise((resolve) => {
let imgLoadCounter = 0;
if (imgLoadCounter === imgElements.length) {
resolve();
return;
}
const checkAndResolve = () => {
imgLoadCounter++;
if (imgLoadCounter === imgElements.length) {
resolve();
}
};
imgElements.forEach((img) => {
// 如果图片已经加载,直接增加计数器
if (img.complete) {
checkAndResolve();
} else {
img.onload = checkAndResolve;
img.onerror = () => {
img.onerror = null; // 避免在加载默认图片时触发错误循环
img.src = this.defaultImagePath;
};
}
});
});
}
/**
* 对图片元素进行布局
* @param {Array<HTMLImageElement>} imgElements
*/
_layout(imgElements) {
imgElements.forEach((img) => {
img.style.width = `${this.columnWidth}px`;
const columnIndex = this.columnHeights.indexOf(
Math.min(...this.columnHeights)
);
img.style.position = 'absolute';
img.style.left = `${columnIndex * (this.columnWidth + this.gap)}px`;
img.style.top = `${this.columnHeights[columnIndex]}px`;
this.columnHeights[columnIndex] += img.clientHeight + this.gap;
this.container.style.height = `${Math.max(...this.columnHeights)}px`;
});
}
/**
* 追加图片元素到容器中,并对新加入的图片完成布局
* @param {Array<HTMLImageElement>} imgElements
*/
async append(imgElements) {
await this._onAllImageLoaded(imgElements);
imgElements.forEach((img) => {
this.container.appendChild(img);
});
this._layout(imgElements);
}
}
// 创建Masonry示例
this.masonry = new Masonry({
container: document.querySelector('.grid'),
columnNumber: 4,
gap: 10,
});
},
// 获取图片函数
loadImages(number = 10) {
return new Promise((resolve) => {
setTimeout(() => {
const imgElements = [];
for (let i = 0; i < number; i++) {
const img = new Image();
const randomX = Math.floor(Math.random() * (800 - 200 + 1)) + 200;
const randomY = Math.random();
img.src = `https://picsum.photos/400/${randomX}?r=${randomY}`;
imgElements.push(img);
}
resolve(imgElements);
}, 1000);
});
},
// 把图片追加到 Masonry 布局中
async loadMoreImages(number = 10) {
if (this.isLoading) {
return;
}
// 设置loading为true,表示正在加载
this.isLoading = true;
// 获取加载的图片
const imgs = await this.loadImages(number);
// 将图片追加到masonry布局中
this.masonry.append(imgs);
// 设置loading为false,表示加载完成
this.isLoading = false;
}
}
}
</script>
<style lange='scss' scoped>
.intersection-observer-api {
padding: 10px;
box-sizing: border-box;
height: 100%;
.header {
padding: 10px;
height: 40px;
box-sizing: border-box;
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
line-height: 20px;
text-align: center;
}
.container {
background-color: #f5f5f5;
padding: 10px;
box-sizing: border-box;
height: calc(100% - 50px);
margin-top: 10px;
overflow-y: auto;
.grid {
box-sizing: border-box;
margin: 0 50px auto;
}
.grid img {
animation: scale 1s;
}
.spin {
width: 20px;
height: 20px;
margin: 0 auto;
padding: 20px;
border: 7px dashed #4b9cdb;
border-radius: 100%;
animation: loading 1.5s 0.3s cubic-bezier(0.17, 0.37, 0.43, 0.67) infinite;
margin-bottom: 100px;
}
}
}
@keyframes scale {
0% {
transform: scale(0);
opacity: 0;
}
}
@keyframes loading {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
其中我们需要关注的代码只有下面这一段。
mounted() {
this.initData();
const ob = new IntersectionObserver(
(entries) => {
// 当占位符进入视口时,加载更多图片
if (entries[0].isIntersecting) {
this.loadMoreImages(10);
}
},
{
root: null,
rootMargin: '0px',
threshold: 1,
}
// 获取加载更多的占位符元素
const spin = document.querySelector('.spin');
// 观察该元素
ob.observe(spin);
},
我们这里需要去观察这个loading,当它开始和可视化区域交叉的时候,就调用loadMoreImages方法往瀑布流布局添加图片。这里为了让效果更好的看到我把threshold设置为1,表示loading全部和可视化区域交叉的时候才会添加图片,效果如下。
因为动图太大所以抽帧了,看着可能没有那么流畅。
3.停止视频播放
看过上面两个案例以后,我们知道Intersection Observer API的使用重点是我们要观察哪个目标元素,第一个观察图片是否和可视化区域交叉,第二个观察loading是否和可视化区域交叉,那这个我们就应该观察这个视频是否和可视化区域进行交叉了,我们的目的是让视频能够完整展示的时候再播放,所以就代表这个视频和可视化区域是完全交叉的情况,也就是threshold设置为1,所以当它没有为1的时候也就是isIntersecting为false的时候我们就把视频给停止播放,等完全为1的时候也就是isIntersecting为true的时候我们再继续播放视频。
<template>
<div class="intersection-observer-api">
<div class="header">
这里是IntersectionObserverAPI组件的内容。
</div>
<div class="container card-border">
<!-- 视频元素 -->
<video src="./movie/movie.mp4" loop autoplay muted></video>
</div>
</div>
</template>
<script>
export default {
name: 'IntersectionObserverAPI2',
data() {
return {
}
},
mounted() {
const ob = new IntersectionObserver(
(entries) => {
const entry = entries[0];
const vdo = entry.target;
if (entry.isIntersecting) {
// 如果完整交叉 就播放
vdo.play();
} else {
// 没有完整交叉 就暂停
vdo.pause();
}
},
{
root: null,
rootMargin: '0px',
threshold: 1,
}
);
ob.observe(document.querySelector('video'));
},
methods: {
}
}
</script>
<style lange='scss' scoped>
.intersection-observer-api {
padding: 10px;
box-sizing: border-box;
height: 100%;
overflow-y: auto;
.header {
padding: 10px;
height: 40px;
box-sizing: border-box;
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
line-height: 20px;
text-align: center;
}
.container {
background-color: #f5f5f5;
padding: 50px 10px;
box-sizing: border-box;
margin-top: 10px;
height: 1500px;
video {
display: block;
width: 100%;
}
}
}
</style>
一样做了抽帧处理所以看着比较卡顿。