1.6kB 搞定懒加载、无限滚动、精准曝光

6,266 阅读6分钟

上文提到有很多类库在用 IntersectionObserver 实现懒加载,但更精准的描述是,IntersectionObserver 提供了一种异步观察目标元素与根元素(窗口或指定父元素)的交叉状态的能力,这项能力不仅能用来做懒加载,还可以提供无限滚动,精准曝光的功能。

1. IntersectionObserver 基础介绍

不管我们使用哪个类库,都需要了解 IntersectionObserver 的基本原理,下面是一个简单的例子

import React, { useEffect } from "react";
import "./page.css";

const Page1 = (props: { handleShowTypeChange: (type: number) => void }) => {
  const { handleShowTypeChange } = props;

  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      console.log(entries[0].intersectionRatio);
    });

    const footer = document.querySelector(".footer");

    if (footer) {
      io.observe(footer);
    }

    return () => {
      io.disconnect();
    };
  }, []);

  return (
    <div className="scroll-container">
      <button className="btn" onClick={() => handleShowTypeChange(0)}>
        返回
      </button>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="footer">被观察的元素</div>
    </div>
  );
};

export default Page1;

如上例,可以了解到以下几点知识

  1. new 一个 IntersectionObserver 对象,下称 io,需传入一个函数,下称 callbackcallback 的入参 entries 代表了正在被观察的元素数组,数组的每一项都拥有属性 intersectionRatio ,代表了被观察的元素与根元素可视区域的交叉比例,。

  2. 使用 ioobserve 方法来添加你想观察的元素,可以多次调用添加多个,

  3. 使用 iodisconnect 方法来销毁观测

使用上方的代码,可以完成对元素最基本的观察。如上方 gif 操作,在控制台可得到以下结果 ,

  • 进入页面时,callback 被调用了一次:intersectionRatio 为 0
  • 滚动到可视区,再次调用:intersectionRatio > 0
  • 滚动出可视区,再次调用:intersectionRatio 为 0
  • 滚动到可视区,再次调用:intersectionRatio > 0

而懒加载,无限滚动,精准曝光是如何基于这个 api 去实现的呢,如果直接去写,当然也能实现,但是会有些繁琐,下面引入本篇文章的主角:react-intersection-observer 类库,先看看这个类库的基本介绍吧。

2. react-intersection-observer 基础介绍

这个类库在全局维护了一个 IntersectionObserver 实例(如果只有一个根元素,那全局仅有一个实例,实际上代码中维护了一个实例的 Map,此处简单表述),并提供了一个名为 useInViewhooks 方便我们了解到被观测的元素的观测状态。与上面相同的例子,他的写法如下:

import React, { useEffect } from "react";
import { useInView } from 'react-intersection-observer';
import "./page.css";

const Page2 = (props: { handleShowTypeChange: (type: number) => void }) => {
  const { handleShowTypeChange } = props;
  const { ref } = useInView({
    onChange: (inView, entry) => {
        console.log(entry.intersectionRatio);
    }
  });

  return (
    <div className="scroll-container">
      <button className="btn" onClick={() => handleShowTypeChange(0)}>
        返回
      </button>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="footer" ref={ref}>被观察的元素</div>
    </div>
  );
};

export default Page2;

如上例,使用更少的代码,就实现了相同的功能,而且带来了一些好处

  • 不用自己维护 IntersectionObserver 实例,既不用关心创建,也不用关心销毁
  • 不用控制被观察的元素到底是 entries 内的第几个,观察事件都会在相应绑定的 onChange 中进行回调

以上仅为基本使用,实战中需求是更为复杂的,所以这个类库也提供了一系列属性,方便大家的使用:

利用上面这些配置项,我们可以实现以下功能

3. 实战用例

3.1. 懒加载

import React from "react";
import { useInView } from "react-intersection-observer";
import "./page.css";

interface Props {
  width: number;
  height: number;
  src: string;
}

const LazyImage = ({ width, height, src, ...rest }: Props) => {
  const { ref, inView } = useInView({
    triggerOnce: true,
    root: document.querySelector('.scroll-container'),
    rootMargin: `0px 0px ${window.innerHeight}px 0px`,
    onChange: (inView, entry) => {
        console.log('info', inView, entry.intersectionRatio);
    }
  });

  return (
    <div
      ref={ref}
      style={{
        position: "relative",
        paddingBottom: `${(height / width) * 100}%`,
        background: "#2a4b7a",
      }}
    >
      {inView ? (
        <img
          {...rest}
          src={src}
          width={width}
          height={height}
          style={{ position: "absolute", width: "100%", height: "100%", left: 0, top: 0 }}
        />
      ) : null}
    </div>
  );
};

const Page3 = (props: { handleShowTypeChange: (type: number) => void }) => {
  const { handleShowTypeChange } = props;

  return (
    <div className="scroll-container">
      <button className="btn" onClick={() => handleShowTypeChange(0)}>
        返回
      </button>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <LazyImage width={750} height={200} src={"https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e4acf97e7dc944bf8ad5719b2b42f026~tplv-k3u1fbpfcp-watermark.image?"} />
    </div>
  );
};

export default Page3;

懒加载中我们需要用到几个额外的属性:

  • triggerOnce :只触发一次

  • root:默认为文档视口(如果被观察的元素,父/祖元素中有 overflow: scroll,需要指定为该元素)

  • rootMarginrootmargin

    • 同 css 上右下左写法,需要带单位,可简写('200px 0px')

    • 正值代表观察区域增大,负值代表观察区域缩小

在图片懒加载中,因为通常不可能等到元素被滚动到了可视区域,才开始加载图片,所以需要调整 rootMargin ,可以写为,rootMargin: `0px 0px ${window.innerHeight}px 0px ,这样图片可以提前一屏进行加载。

同样懒加载不需要不可见的时候回收掉相应的 dom ,所以只需要触发一次,设置 triggerOncetrue 即可。

3.2. 无限滚动

import React, { useState } from "react";
import { useInView } from "react-intersection-observer";
import "./page.css";

const Page4 = (props: { handleShowTypeChange: (type: number) => void }) => {
  const { handleShowTypeChange } = props;
  const [datas, setDatas] = useState([1, 1, 1]);
  const { ref } = useInView({
    onChange: (inView, entry) => {
      console.log("inView", inView);
      if (inView) {
        setDatas((prevDatas) => [...prevDatas, ...new Array(3).fill(1)]);
      }
    },
  });

  return (
    <div className="scroll-container">
      <button className="btn" onClick={() => handleShowTypeChange(0)}>
        返回
      </button>
      {datas.map((item, index) => {
        return (
          <div key={index + 1} className="placeholder">
            第{index + 1}个元素
          </div>
        );
      })}
      <div className="load-more" ref={ref}></div>
    </div>
  );
};

export default Page4;

无限滚动主要依赖在 onChange 中对 inView 进行判断,我们可以添加一个高度为0的元素,名为 load-more ,当页面滚动到最下方时,该元素的 onChange 会被触发,通过对 inViewtrue 的判断后,加载后续的数据。同理,真正的无限滚动也需要提前加载(在观察内写异步请求等),也可以设置相应的 rootMargin ,让无限滚动更丝滑。

3.3. 精准曝光

import React from "react";
import { useInView } from "react-intersection-observer";
import "./page.css";

const Page5 = (props: { handleShowTypeChange: (type: number) => void }) => {
  const { handleShowTypeChange } = props;
  const { ref } = useInView({
    threshold: 0.5,
    delay: 500,
    onChange: (inView, entry) => {
      if (inView) {
        console.log("元素需要上报曝光事件", entry.intersectionRatio);
      }
    },
  });

  return (
    <div className="scroll-container">
      <button className="btn" onClick={() => handleShowTypeChange(0)}>
        返回
      </button>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="placeholder">其他元素</div>
      <div className="footer" ref={ref}>
        需要精准曝光的元素
      </div>
    </div>
  );
};

export default Page5;

精准曝光也是很常见的业务需求,通常此类需求会要求元素的露出比例和最小停留时长。

  • 对露出比例要求的原因:因为有可能元素的有效信息并未展示,只是露出了一点点头,一般业务上会要求露出比例大于一半。
  • 对停留时长要求的原因:有可能用户快速划过,比如小说看到了很啰嗦的章节快速滑动,直接看后面结果,如果不加停留时长,中间快速滑动的区域也会曝光,与实际想要的不符。

类库恰好提供了下面两个属性方便大家的使用

  • threshold: 观察元素露出比例,取值范围 0~1,默认值 0
  • delay: 延迟通知元素露出(如果延迟后元素未达标,则不会触发onChange),取值单位毫秒,非必填。

使用上面两个属性,就可以轻松实现业务需求。

3.4. 官方示例

示例,官方示例中还有很多对属性的应用,比如 threshold 传入数组,skiptrack-visibility ,大家可自行体验。

总结

以上就是对 IntersectionObserver 以及 react-intersection-observer 的介绍了,希望能对大家有所帮助,文中录制的示例完整项目可以从此处获取。