滚动到底很难?两分钟让你彻底玩转

前言

无限滚动是 Web 开发中一项重要的动能,它确保用户有友好体验的同时能够无限访问内容,在过去为了实现无限滚动,我们需要获取无限滚动组件的底部元素和视窗的交叉状态,通常会使用两种方式来实现:

  • 监听滚动事件+计算偏移量+逻辑判断,由于 scroll 事件密集发生,计算量很大,容易造成性能问题,所以需要通过防抖节流技术优化。
  • 通过轮训方式去以一定时间间隔检查无限滚动组件底部元素和视窗的交叉状态。

这两种方式都能够帮助开发者检测是否已经滚动到底,从而实现无限加载,下面是检查元素和视窗交叉状态的核心实现:

js 复制代码
const isElementInView = (
	element: HTMLElement,
  windowHeight: number,
  windowWidth: number,
) => {
	 /** 获取底部元素相对视口的位置 */
	 const {top, bottom, left, right} = element.getBoundingClientRect();
	 
	 if (left > windowWidth) {
	      return false;
   } else if (right < 0) {
	      return false;
   } else if (top > windowHeight) {
	      return false;
   } else if (bottom < 0) {
	      return false;
   }

	 return true;
}

但是这也有弊端,如果想更改无限滚动组件的布局,那开发者需要根据具体的场景去重新实现无限滚动的逻辑,比如说创建具有反向滚动的聊天消息框,那就需要检测顶部元素和视窗的交叉状态。

出现这一问题的根本原因就在于我们实现的判断元素是否在视口范围内的逻辑不能兼容所有场景,为此开发者就需要根据各个场景去写大量的兼容性代码,如果存在一个方法可以方便地让我们知道这点,那实现无限加载的方法将会大大简化,幸运的是目前大部分浏览器已经提供了这个api------IntersectionObserver

IntersectionObserver介绍

IntersectionObserver 接口提供了一种异步观察目标元素与其祖先元素或顶级文档视窗 (viewport) 交叉状态的方法。在交叉状态变化时会执行注册的回调函数(不随容器的滚动执行),回调函数可以接收到元素与视窗交叉的具体数据。

当一个 IntersectionObserver 对象被创建后,其配置无法更改,它可以监听多个目标元素,并通过队列维护回调的执行顺序。

构造函数

IntersectionObserver 构造函数接收两个参数,第一个是回调函数,在交叉状态变化时会执行,第二个是配置参数,不传时会使用默认配置。

ini 复制代码
const myObserver = new IntersectionObserver(callback, options);

回调函数

javascript 复制代码
const callback = (entries, observer) => {
    entries.forEach((entry) => {
        // 如果有交叉
        if (entry.isIntersecting) {
            //....
        }
    });
}

在回调函数 callback 中拿到 entries 和当前的 IntersectionObserver 实例, entries 是一个数组,里面每个成员都是一个IntersectionObserverEntry 对象,监听了几个元素, entries 就包含了几个成员。IntersectionObserverEntry 对象描述了目标对象与容器之间的相交信息。

配置参数

root

root 表示监听对象的祖先元素,一般会指定为滚动容器,默认使用顶级文档的视窗(一般为html)。

rootMargin

rootMargin 表示祖先元素的边距,可以有效的缩小或扩大祖先元素的判定范围从而满足计算需要。所有的偏移量均可用像素px)或百分比%)来表达,默认值为"0px 0px 0px 0px"。

thresholds

thresholds 是一个包含阈值的列表, 按升序排列, 列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会触发callback。默认值为0。

实例方法

observe

开始监听目标元素,参数为一个 DOM 元素。

disconnect

关闭观察器。

unobserve

停止监听目标元素,参数为一个 DOM 元素。

takeRecords

返回所有观察目标的 IntersectionObserverEntry 对象数组。

scss 复制代码
// 调用构造函数 生成IntersectionObserver观察器
const myObserver = new IntersectionObserver(callback, options);

// 开始监听 指定元素
myObserver.observe(element);

// 停止对目标的监听
myObserver.unobserve(element);

// 关闭观察器
myObserver.disconnect();

实战

下面会通过实现一个 React Hook 来实现无限滚动功能,首先看一下最终的使用方式和效果:

js 复制代码
function InfiniteListWithVerticalScroll() {
  const { loading, items, hasNextPage, loadMore } = useLoadItems();
  const { rootRef, nodeRef } = useInfiniteScroll({
      loading,
      hasNextPage,
      onLoadMore: loadMore,
  });

  return (
      <div className="wrapper" ref={rootRef}>
          {items.map((item, index) => {
              return (
                  <div key={index} className="item">
                      {item}
                  </div>
              );
          })}
          {(loading || hasNextPage) && (
              <div className="spin" ref={nodeRef}>
                  <Spin spinning />
              </div>
          )}
      </div>
  );
}

export const useLoadItems = () => {
  const [loading, setLoading] = useState(false);
  const [hasNextPage] = useState(true);
  const [items, setItems] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

  const loadMore = useCallback(async () => {
    setLoading(true);
    await delay(2000);
    const startIndex = items[items.length - 1];
    const newItems = [];
    for (let i = startIndex + 1; i <= startIndex + 10; i++) {
      newItems.push(i);
    }

    setItems([...items, ...newItems]);
    setLoading(false);
  }, [items]);

  return {
    loadMore,
    loading,
    hasNextPage,
    items,
  };
};

useIntersectionObserver

在实现无限滚动时我们需要实现的一个核心功能就是判断两个元素之间的交叉状态,该功能可能抽取出来作为一个独立的 hook,便于复用。

js 复制代码
const observerCache = createObserverCache();

export const useIntersectionObserver = () => {
    /** 当前被观察的元素 */
    const nodeRef = useRef<Element | null>(null);
    /** 容器元素/祖先元素 */
    const rootRef = useRef<Element | null>(null);
    /** 当前被观察元素的IntersectionObserverEntry实例 */
    const [entry, setEntry] = useState<IntersectionObserverEntry>();
    /** 观察器实例 */
    const observerRef = useRef<CachedIntersectionObserver | null>(null);

    /** 向观察器中添加被观察元素 */
    const observe = useCallback(() => {
        const node = nodeRef.current;

        if (!node) {
            setEntry(undefined);
            return;
        }

        if (!rootRef.current) {
            return;
        }

        const observer = observerCache.getObserver(rootRef.current);

        observer.observe(node, entry => {
            setEntry(entry);
        });

        observerRef.current = observer;
    }, []);
	
    /** 取消对元素的观察 */
    const unobserve = useCallback(() => {
        const currentObserver = observerRef.current;
        const node = nodeRef.current;

        if (node) {
            currentObserver?.unobserve(node);
        }

        observerRef.current = null;
    }, []);
		
    /** 定义被观察元素的refCallback函数,传给被观察元素的ref属性,函数参数为一个DOM元素 */
    const refCallback = useCallback(
        (node: Element) => {
            unobserve();
            nodeRef.current = node;
            observe();
        },
        [observe, unobserve]
    );

    /** 定义祖先元素/滚动容器元素的refCallback函数,传给祖先元素/滚动容器元素的ref属性,函数参数为一个DOM元素 */
    const rootRefCallback = useCallback(
        (rootNode: Element) => {
            unobserve();
            rootRef.current = rootNode;
            observe();
        },
        [observe, unobserve]
    );

    return {nodeRef: refCallback, rootRef: rootRefCallback, entry};
};

createObserverCache

为了避免重复的对祖先元素/滚动容器元素创建IntersectionObserver实例,我们可以对已经创建的IntersectionObserver实例进行缓存。

dart 复制代码
export interface CachedIntersectionObserver {
    observe: (
        node: Element,
        callback: (entry: IntersectionObserverEntry) => void
    ) => void;
    unobserve: (node: Element) => void;
}

export const createObserverCache = () => {
    const cachesByRoot = new Map<Element, CachedIntersectionObserver>();
	
    /** 获取祖先元素的IntersectionObserver实例,如果没有则创建一个 */
    const getObserver = (root: Element) => {
        let cacheByRoot = cachesByRoot.get(root);

        if (!cacheByRoot) {
            /** 保存祖先元素下每个被观察元素的callback函数 */
            const entryCallbacks = new Map<
            Element,
            (entry: IntersectionObserverEntry) => void
            >();
            const cacheObserverByRoot = new IntersectionObserver(
                entries => {
                    for (const entry of entries) {
                        const callback = entryCallbacks.get(entry.target);
                        callback?.(entry);
                    }
                },
                {root}
            );

            cacheByRoot = {cacheObserverByRoot, entryCallbacks};
            cachesByRoot.set(root, cacheByRoot);
        }

        return {
            observe(
                node: Element,
                callback: (entry: IntersectionObserverEntry) => void
            ) {
                cacheByRoot?.entryCallbacks.set(node, callback);
                cacheByRoot?.cacheObserverByRoot.observe(node);
            },
            unobserve(node: Element) {
                cacheByRoot?.entryCallbacks.delete(node);
                cacheByRoot?.cacheObserverByRoot.unobserve(node);
            }
        };
    };

    return {getObserver};
};

useInfiniteScroll

ini 复制代码
interface IProps {
    loading: boolean;
    hasNextPage: boolean;
    onLoadMore: () => Promise<void>;
}

export const useInfiniteScroll = (props: IProps) => {
    const {loading, hasNextPage, onLoadMore} = props;

    const {rootRef, nodeRef, entry} = useIntersectionObserver();
    const isVisible = Boolean(entry?.isIntersecting);
		
    /** 是否需要加载剩余的数据,当正在加载数据或者没有剩余数据时,不需要加载 */
    const shouldLoadMore = !loading && isVisible && hasNextPage;

    useEffect(() => {
        if (shouldLoadMore) {
            const timer = setTimeout(() => {
                onLoadMore();
            }, 100);
            return () => {
                clearTimeout(timer);
            };
        }
    }, [onLoadMore, shouldLoadMore]);

    return {rootRef, nodeRef};
};

兼容性

尽管 IntersectionObserver 是一个非常强大的工具,但在使用前需要考虑其兼容性。该 API 在现代浏览器中有较好的支持。

但是较旧版本的浏览器,如 Internet Explorer 并不支持 IntersectionObserver,为了确保在所有用户设备上都有良好的体验,开发者可以使用 IntersectionObserverpolyfill。这些 polyfill 可以在不支持原生 IntersectionObserver 的浏览器中模拟其功能,从而提供一致的用户体验。

总结

IntersectionObserver是一个现代浏览器API,用于异步地观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法。它的特点是能够提供精准的元素可见性检测,而不需要开发者手动计算和检测元素的视图位置,从而大幅减少性能开销。

IntersectionObserver的优势在于,它可以通过回调函数的方式异步处理交叉状态变化,避免了频繁的scroll事件监听和复杂的计算逻辑,从而提高了页面性能和用户体验。开发者可以使用IntersectionObserver来实现诸如无限滚动加载、懒加载图片、广告曝光率统计、视差滚动效果等多种功能。需要注意的是开发者在使用时应确保兼容性,特别是在较老的浏览器中可能需要考虑降级处理。总的来说,IntersectionObserver简化了开发者获取元素可见性状态的过程,是现代Web开发中一个非常实用的工具。

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui