IntersectionObserver实现虚拟列表

147 阅读4分钟

前言

在过去为了实现 懒加载、滚动动画 等需求并不容易,我们需要获取 元素与视窗的交叉状态,这通常使用 监听滚动事件 + 计算偏移量 + 判断逻辑 的方式实现,再辅以防抖节流等优化。

现如今随着技术的发展,浏览器推出了多种 观察器,让我们有更好的方式,去便捷、高效的收集页面与元素的信息。

简介

IntersectionObserver API 提供了一种创建IntersectionObserver 对象的方法,对象用于监测目标元素与视窗(viewport)的交叉状态,并在交叉状态变化时执行回调函数,回调函数可以接收到元素与视窗交叉的具体数据。

image.png 一个 IntersectionObserver 对象可以监听多个目标元素,并通过队列维护回调的执行顺序。

IntersectionObserver 特别适用于:滚动动画、懒加载、虚拟列表等场景。

监听不随着目标元素的滚动而触发,性能消耗极低。

API

构造函数

IntersectionObserver 构造函数 接收两个参数:

  1. callback: 当元素可见比例达到指定阈值后触发的回调函数
  2. options: 配置对象(可选,不传时会使用默认配置)

IntersectionObserver 构造函数 返回观察器实例,实例携带四个方法:

  1. observe:开始监听目标元素
  2. unobserve:停止监听目标元素
  3. disconnect:关闭观察器
  4. takeRecords:返回所有观察目标的 IntersectionObserverEntry 对象数组

构造参数

- callback

回调函数,当交叉状态发生变化时(可见比例超过或者低于指定阈值)会进行调用,同时传入两个参数:

  1. entriesIntersectionObserverEntry 数组,每项都描述了目标元素与 root 的交叉状态
  2. observer:被调用的 IntersectionObserver 实例

注册的回调函数将会在主线程中被执行。所以该函数执行速度要尽可能的快。如果需要执行任何耗时的操作,请使用 Window.requestIdleCallback()

- options

配置参数,通过修改配置参数,可以改变进行监听的视窗,可以缩小或扩大交叉的判定范围,或者调整触发回调的阈值(交叉比例)。

属性说明
root所监听对象的具体祖先元素,默认使用顶级文档的视窗(一般为html)。
rootMargin计算交叉时添加到根(root)边界盒bounding box的矩形偏移量, 可以有效的缩小或扩大根的判定范围从而满足计算需要。所有的偏移量均可用像素(px)或百分比(%)来表达, 默认值为"0px 0px 0px 0px"。
threshold一个包含阈值的列表, 按升序排列, 列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会触发callback。默认值为0。

image.png

- IntersectionObserverEntry
属性说明
boundingClientRect返回包含目标元素的边界信息,返回结果与element.getBoundingClientRect() 相同
intersectionRatio返回目标元素出现在可视区的比例
intersectionRect用来描述root和目标元素的相交区域
isIntersecting返回一个布尔值,下列两种操作均会触发callback:1. 如果目标元素出现在root可视区,返回true。2. 如果从root可视区消失,返回false
rootBounds用来描述交叉区域观察者(intersection observer)中的根.
target目标元素:与根出现相交区域改变的元素 (Element)
time返回一个记录从 IntersectionObserver 的时间原点到交叉被触发的时间的时间戳

案例代码(列表序号存在混乱)

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Virtual List Example</title>
    <style>
        #container {
            height: 500px;
            overflow-y: auto;
            border: 1px solid #ccc;
        }

        .list-item {
            height: 50px;
            background-color: #f0f0f0;
            padding: 10px;
            box-sizing: border-box;
        }
    </style>
</head>

<body>
    <div id="container" style="height: 500px; overflow-y: auto;">
        <div id="list" style="position: relative; height: 50000px;">
            <!-- 动态生成的列表项 -->
        </div>
    </div>

    <script>
        const container = document.getElementById('container');
        const list = document.getElementById('list');
        const itemHeight = 50;
        const totalItems = 1000;
        const visibleItemCount = 10; // 固定显示的元素数量

        let items = [];

        function initialize() {
            for (let i = 0; i < visibleItemCount; i++) {
                const item = document.createElement('div');
                item.className = 'list-item';
                item.style.height = `${itemHeight}px`;
                list.appendChild(item);
                items.push(item);
            }

            container.addEventListener('scroll', handleScroll);
            handleScroll(); // 初始化时也更新一次
        }

        function handleScroll() {
            const scrollTop = container.scrollTop;
            const visibleTop = scrollTop;
            const visibleBottom = scrollTop + container.offsetHeight;

            const firstVisibleIndex = Math.floor(visibleTop / itemHeight);
            const lastVisibleIndex = Math.ceil(visibleBottom / itemHeight);

            for (let i = 0; i < visibleItemCount; i++) {
                const item = items[i];
                const index = firstVisibleIndex + i;
                if (index < totalItems) {
                    item.textContent = `Item ${index}`;
                    item.style.transform = `translateY(${index * itemHeight}px)`;
                } else {
                    item.textContent = '';
                    item.style.transform = `translateY(${(totalItems - 1) * itemHeight}px)`;
                }
            }
        }

        const observerOptions = {
            root: container,
            rootMargin: '0px',
            threshold: 0.1
        };

        const observer = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const index = entry.target.dataset.index;
                    loadItem(entry.target, index);
                }
            });
        }, observerOptions);

        function createListItem(index) {
            const item = document.createElement('div');
            item.className = 'list-item';
            item.style.height = `${itemHeight}px`;
            item.dataset.index = index;
            return item;
        }

        function loadItem(item, index) {
            item.textContent = `Item ${index}`;
            item.style.transform = `translateY(${index * itemHeight}px)`;
        }

        function initializeObserver() {
            for (let i = 0; i < visibleItemCount; i++) {
                const item = createListItem(i);
                list.appendChild(item);
                observer.observe(item);
            }
        }

        initialize();
        initializeObserver();
    </script>
</body>

</html>