Intersection Observer API

118 阅读10分钟

先说三个情景,第一是懒加载图片,会先加载你可视化区域内的图片,当滚动条往下滑动的时候才会加载下面的;第二是瀑布流布局,随着滚动条往下滑动,会一直加载图片出来是一个无限滚动的效果;第三个是播放一个视频,当滚动条往下滑动导致视频显示不全的时候,视频会暂停,等往上滑动视频又全部显示的时候才会继续播放。这三个效果它们都跟滚动条有关系,按照常理我们要去检测滚动事件,计算滚动条的位置,然后做相应的处理,但是这样不仅效率低,而且滚动事件会不停的触发,并且实现起来比较麻烦。但是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>

image.png

整体思路是,我们去观察这个图片,如果这个图片跟我们的可视化区域有没有交叉,有交叉说明这个图片出现在我们的可视化区域里面,这个时候我们就把这个真实图片显示出来。

image.png

首先我们创建一个ob,就是观察者IntersectionObserver,这里面要传递一个回调函数,这个回调函数就是交叉的时候会运行,比如从交叉变到不交叉,或者从不交叉变到交叉,这个时候就会运行。与此同时,在调用这个回调函数的时候,还要传递一个配置,这个配置也很简单,一个是root,一个是rootMargin,一个是threshold

  • root: 表示要观察的元素跟谁交叉,比如我们观察那个图片要跟谁交叉,一般就是它的父元素或者父元素的父元素等等(不能是子元素),如果是null表示跟可视化区域交叉。

  • rootMargin:对交叉范围进行扩张,比如下面这个图就属于图片和可视化区域进行了交叉。

image.png

rootMargin可以把这个范围扩大,比如值为10px,就会把交叉范围往外扩大10px,如下图就算进行了交叉。当然可以收缩,值写为负值就行。

image.png

  • threshold:表示交叉的阈值,填写的范围在0~1之间,比如说0.5,就是说这个图片必须要有一半和可视化区域交叉才会触发回调函数。写0的话只要是碰上了就会执行。

image.png

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);
});

我们运行一下试试看。

image.png

可以看到后台已经有打印了,说明ob的回调方法已经调用了,但是为什么只调用了一次呢,明明有很多图片都交叉了,其实是这样的,它把所有的交叉结果会通过回调函数的参数entries传给你,entries是一个数组,我们去打印一下这个数组。

const ob = new IntersectionObserver((entries) => {
    console.log(entries);
}, {
    root: null, // 默认值为null,表示以浏览器视口作为容器
    rootMargin: '0px',  // 默认值为'0px'
    threshold: 0.1 // 默认值为0,表示当目标元素有10%的区域进入视口时触发回调
});

image.png

可以看到这个数组有1000项,这是因为我们有一千个目标。但是我们交叉的图片只有20个,我们去看一下数组前20个元素,每一个元素都是一个Entry对象,每一个Entry对象里面就记录了这个目标跟我们的可视化区域是怎么交叉的,要注意两个属性,一个是target,表示目前观察的目标,第二个属性是isIntersecting,它表示目标是否和可视化区域进行了交叉。

image.png

可以看到前20个目标的isIntersectingtrue,表示目标和可视化区域已经交叉了。

所以我们就在回调函数里面去循环这个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了,这样能看的清楚点。

动画.gif

2.瀑布流布局

我们先来看一下情况。

image.png

就是当下面的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全部和可视化区域交叉的时候才会添加图片,效果如下。

因为动图太大所以抽帧了,看着可能没有那么流畅。

动画.gif

3.停止视频播放

看过上面两个案例以后,我们知道Intersection Observer API的使用重点是我们要观察哪个目标元素,第一个观察图片是否和可视化区域交叉,第二个观察loading是否和可视化区域交叉,那这个我们就应该观察这个视频是否和可视化区域进行交叉了,我们的目的是让视频能够完整展示的时候再播放,所以就代表这个视频和可视化区域是完全交叉的情况,也就是threshold设置为1,所以当它没有为1的时候也就是isIntersectingfalse的时候我们就把视频给停止播放,等完全为1的时候也就是isIntersectingtrue的时候我们再继续播放视频。

<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>

一样做了抽帧处理所以看着比较卡顿。

动画.gif