早就想好好总结一下 Intersection Observer 这个 API,苦于一直没有时间,今天就来好好总结一下这个 API。

本篇文章将介绍现代 WebAPI Intersection Observer(元素交集观察器)。

背景

交叉观察器 API(Intersection Observer API)提供了一种异步检测目标元素与祖先元素或顶级文档的视口相交情况变化的方法。
过去,要检测一个元素是否可见或者两个元素是否相交并不容易,很多解决办法不可靠或性能很差。然而,随着互联网的发展,这种需求却与日俱增,比如,下面这些情况都需要用到相交检测:

  • 在页面滚动时“懒加载”图像或其他内容。
  • 实现“无限滚动”网站,在滚动过程中加载和显示越来越多的内容,这样用户就不必翻页了。
  • 报告广告的可见度,以便计算广告收入。
  • 根据用户是否能看到结果来决定是否执行任务或动画进程。

交叉观察器 API 可令代码注册一个回调函数,当特定元素进入或退出与另一元素(或视口)的交集时,或者当两个元素之间的交集发生指定变化时,该函数就会被执行。这样,网站就不再需要在主线程上做任何事情来监视这种元素交集,浏览器也可以根据自己的需要优化交集管理。

由于该方法是异步的,所以不会有Element.getBoundingClientRect()等方法的性能问题。

基本使用

初始化对象IntersectionObserver对象。

1
const observer = new IntersectionObserver(callback, options);

可以看到该配置函数有两个配置项:callbackoptions。我们分别来探讨这两个选项有什么作用:

  1. options
    options对象有三个配置选项。

    • root:用作视口的元素,用于检查目标的可见性。必须是目标的祖先。如果未指定或为 null,则默认为浏览器视口。
    • rootMargin:根周围的边距。其值可以类似于 CSS margin 属性,例如 “10px 20px 30px 40px”(上、右、下、左)。这些值可以是百分比。在计算交叉点之前,这组值用于增大或缩小根元素边框的每一侧。默认值为全零。下面会详细讲解。
    • threshold:一个数字或一个数字数组,表示目标可见度达到多少百分比时,观察器的回调就应该执行。如果只想在能见度超过 50% 时检测,可以使用 0.5 的值。如果希望每次能见度超过 25% 时都执行回调,则需要指定数组 [0, 0.25, 0.5, 0.75, 1]。默认值为 0(这意味着只要有一个像素可见,回调就会运行)。值为 1.0 意味着在每个像素都可见之前,阈值不会被认为已通过。
    1
    2
    3
    4
    5
    const options = {
      root: document.querySelector("#scrollArea"),
      rootMargin: "0px",
      threshold: 1.0,
    };
  2. callback
    callback作为交集的回调函数,执行特定的行为。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    const callback = (entries, observer) => {
      entries.forEach((entry) => {
        // 每个条目描述一个目标元素观测点的交叉变化:
        //   entry.boundingClientRect
        //   entry.intersectionRatio
        //   entry.intersectionRect
        //   entry.isIntersecting
        //   entry.rootBounds
        //   entry.target
        //   entry.time
      });
    };
    
    // 定义回调函数
    const callback = (entries, observer) => {
      // 遍历所有的交叉记录
      for (let entry of entries) {
        // 如果目标元素和根元素有交叉
        if (entry.isIntersecting) {
          // 打印交叉比例
          console.log(entry.intersectionRatio);
          // 停止观察
          observer.unobserve(entry.target);
        }
      }
    };

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

配置项详解

root(根元素)

在跟踪元素与容器的交集之前,我们需要知道容器是什么。这个容器就是交集根,或根元素。它可以是文档中作为要观察元素的祖先的特定元素,也可以是 null,即使用文档的视口作为容器。

注意一般视口用的较多,由于大多数子元素一开始就相交,所以很少使用。判断与父元素相交。

threshold(阈值)

交叉观察器 API 使用阈值,而不是报告目标元素可见度的每一个微小变化。创建观察器时,可以提供一个或多个数值,代表目标元素可见度的百分比。然后,API 只报告超过这些阈值的可见性变化。

例如,如果希望每次目标元素的可见度向后或向前越过每个 25% 的标记时都能得到通知,可以在创建观察器时指定数组 [0, 0.25, 0.5, 0.75, 1] 作为阈值列表。

rootMargin

给根元素添加margin边距实现收缩的效果
正值矩形向外扩张,负值矩形向内收缩。

结合下面这个例子进行理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .red-box {
        width: 50px;
        height: 50px;
        background-color: red;
        position: absolute;
        left: 200px;
      }
      .outer {
        width: 100px;
        height: 100px;
        background-color: cyan;
        position: relative;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        width: 7000px;
        height: 7000px;
        padding-top: 2000px;
      }
    </style>
  </head>
  <body>
    <div class="outer"><div class="red-box"></div></div>

    <script>
      // 创建一个交叉观察器
      let observer = new IntersectionObserver(callback, {
        root: document.querySelector(".outer"), // 根元素是视口
        rootMargin: "100px -50px 100px 100px", // 根元素的边界框偏移量
        threshold: 0, // 交叉比例阈值
      });

      // 选择目标元素
      let target = document.querySelector(".red-box");

      // 开始观察目标元素
      observer.observe(target);

      // 定义回调函数
      function callback(entries, observer) {
        console.log(1);
        // 遍历所有的交叉记录
        for (let entry of entries) {
          console.log(2);
          // 如果目标元素和根元素有交叉
          if (entry.isIntersecting) {
            // 打印交叉比例
            console.log(entry.intersectionRatio);
            // 停止观察
            // observer.unobserve(entry.target);
          }
        }
      }
    </script>
  </body>
</html>

callback

  1. 页面渲染后会执行一次,无论是否相交。
  2. 每次进入和离开各执行一次。

API

实例属性

同上options选项。

实例方法

  1. disconnect:通知所有对象的监视。
    1
    observer.disconnect();
  2. observe:观察一个对象。
  3. unobserve:停止观察。
  4. takeRecords
    takeRecords 方法的用途是在停止观察之前获取所有未处理的交叉记录,以便在回调函数中处理它们。这个方法返回一个 IntersectionObserverEntry 对象数组,每个对象包含目标元素与根每次的相交信息。
    如果你使用回调来监视这些更改,则无需调用此方法。调用此方法会清除挂起的相交状态列表,因此不会运行回调。

    比较鸡肋很少用到。

IntersectionObserverEntry

属性:

  • boundingClientRect:返回目标元素的平面矩形。
  • intersectionRatio:相交多少。
  • intersectionRect:相交的部分的平面矩形。
  • isIntersecting:是否相交。
  • rootBounds:使用rootMargin计算后的平面矩阵。
  • target:目标元素。
  • time:交集状态发生改变的时候的时间戳。

基本用例

  1. 图片懒加载

    • 工具函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      import { ref } from "vue";
      const useIntersectionObserver = (
        target: HTMLElement,
        callback: (isIntersecting: boolean) => void
      ): (() => void) => {
        const observer = ref<IntersectionObserver | null>(null);
      
        const handleIntersect: IntersectionObserverCallback = (entries) => {
          // console.log('test')
          const isIntersecting = entries[0].isIntersecting;
          callback(isIntersecting);
        };
      
        const observeElement = () => {
          if (target && observer.value) {
            observer.value.observe(target);
          }
        };
      
        const unobserveElement = () => {
          if (target && observer.value) {
            observer.value.unobserve(target);
          }
        };
      
        const createObserver = () => {
          if (target) {
            observer.value = new IntersectionObserver(handleIntersect, {
              // 配置对象,指定根元素为视口,阈值为0.1
              root: null,
              threshold: 0.1,
            });
            observeElement();
          }
        };
      
        createObserver();
      
        return unobserveElement;
      };
      
      export default useIntersectionObserver;
    • 指令封装

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      export const lazyPlugin: Plugin = {
        install(app: App) {
          app.directive("img-lazy", {
            mounted(el, binding) {
              const stop = useIntersectionObserver(el, (isIntersecting) => {
                if (isIntersecting) {
                  // console.log('test')
                  el.src = binding.value;
                  stop();
                }
              });
            },
          });
        },
      };
  2. 无限滚动
    底层元素进入视口就开始加载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Infinite Scrolling Example</title>
        <style>
          .container {
            max-width: 800px;
            margin: auto;
          }
    
          .post {
            border: 1px solid gray;
            padding: 20px;
            margin: 20px;
          }
    
          .loading {
            text-align: center;
            font-size: 24px;
          }
        </style>
      </head>
      <body>
        <h1>Infinite Scrolling Example</h1>
        <!-- 创建一个容器元素,用来存放内容 -->
        <div class="container">
          <!-- 创建一个占位元素,用来触发无限滚动 -->
          <div id="sentinel"></div>
          <!-- 创建一个加载提示元素 -->
          <div class="loading">Loading...</div>
        </div>
    
        <script>
          // 获取容器元素和占位元素
          const container = document.querySelector(".container");
          const sentinel = document.querySelector("#sentinel");
    
          // 定义一个变量,用来存储当前加载的页数
          let page = 1;
    
          // 定义一个函数,用来模拟从服务器获取数据
          function getData(page) {
            // 返回一个Promise对象,用来异步处理数据
            return new Promise((resolve, reject) => {
              // 使用setTimeout模拟网络延迟
              setTimeout(() => {
                // 使用fetch API请求一个随机用户生成器的API,传入页数参数
                fetch(`https://randomuser.me/api/?page=${page}&results=10`)
                  .then((response) => response.json()) // 把响应转换为JSON格式
                  .then((data) => resolve(data.results)) // 把数据传给resolve函数
                  .catch((error) => reject(error)); // 如果出错,把错误传给reject函数
              }, 1000);
            });
          }
    
          // 定义一个函数,用来渲染数据到页面上
          function renderData(data) {
            // 遍历数据数组
            data.forEach((item) => {
              // 创建一个div元素,用来存放每个用户的信息
              const post = document.createElement("div");
              // 给div元素添加post类名
              post.classList.add("post");
              // 给div元素添加内容,使用模板字符串插入用户的姓名、邮箱和头像等信息
              post.innerHTML = `
                <h2>${item.name.first} ${item.name.last}</h2>
                <p>${item.email}</p>
                <img src="${item.picture.large}" alt="${item.name.first}">
                `;
              // 把div元素插入到容器元素的最后一个子元素之前,也就是占位元素之前
              container.insertBefore(post, sentinel);
            });
          }
    
          // 创建一个IntersectionObserver对象,传入一个回调函数和一个配置对象
          const observer = new IntersectionObserver(
            (entries) => {
              // 如果占位元素与视口相交
              if (entries[0].isIntersecting) {
                // 调用getData函数,传入当前的页数,获取数据
                getData(page)
                  .then((data) => {
                    // 调用renderData函数,渲染数据到页面上
                    renderData(data);
                    // 页数加一,准备下一次加载
                    page++;
                  })
                  .catch((error) => {
                    // 如果出错,打印错误信息
                    console.error(error);
                  });
              }
            },
            {
              // 配置对象,指定根元素为视口,阈值为1
              root: null,
              threshold: 1,
            }
          );
    
          // 调用observe方法,传入占位元素,开始观察它
          observer.observe(sentinel);
        </script>
      </body>
    </html>
    无限滚动的另一种方式就是触底加载
    • scrollHeight 是一个元素内容高度的度量,包含由于溢出导致的视图中不可见内容。也就是说,如果一个元素的内容超出了它的可视区域,那么它的 scrollHeight 就会大于它的可视区域的高度。scrollHeight 的值等于元素的最小高度,使得所有的内容都能在不使用垂直滚动条的情况下显示在视图中。scrollHeight 的计算方式和 clientHeight 相同:它包含了元素的内边距,但不包括边框、外边距或水平滚动条(如果有的话)。它也可以包括伪元素(如 ::before::after)的高度。

    • clientHeight 是元素内部的高度(单位像素),包含内边距,但不包括水平滚动条、边框和外边距。也就是说,它是元素的可视区域的高度。如果元素的内容没有超出可视区域,那么它的 clientHeight 就等于它的 scrollHeight

    • scrollTop 是获取或设置一个元素的内容垂直滚动的像素数。也就是说,它是元素的内容顶部和元素的可视区域顶部之间的距离。当元素的内容没有滚动时,它的 scrollTop 为 0。当元素的内容向下滚动时,它的 scrollTop 会增加,直到滚动到内容的底部。

    这三个属性的关系可以用下面的公式表示:

    • 当元素的内容没有滚动时,scrollTop = 0clientHeight = scrollHeight
    • 当元素的内容向下滚动时,scrollTop > 0clientHeight < scrollHeight
    • 当元素的内容滚动到底部时,scrollTop + clientHeight = scrollHeight

    这三个属性可以用来实现一些常见的功能,例如:

    • 判断一个元素是否可以滚动,可以检查它的 scrollHeight 是否大于它的 clientHeight
    • 判断一个元素是否已经滚动到底部,可以检查它的 scrollTop + clientHeight 是否等于或接近它的 scrollHeight
    • 判断一个用户是否已经阅读了一个文本,可以检查一个文本框的 scrollTop + clientHeight 是否等于它的 scrollHeight