DOM交叉观察 API Intersection Observer

2,289 阅读8分钟

交叉观察 API Intersection Observer

以前

过去,要检测一个元素是否可见或者两个元素是否相交并不容易,很多解决办法不可靠或性能很差。然而,随着互联网的发展,这种需求却与日俱增,比如,下面这些情况都需要用到相交检测:

  • 图片懒加载——当图片滚动到可见时才进行加载
  • 内容无限滚动——也就是用户滚动到接近内容底部时直接加载更多,而无需用户操作翻页,给用户一种网页可以无限滚动的错觉
  • 检测广告的曝光情况——为了计算广告收益,需要知道广告元素的曝光情况(埋点)
  • 在用户看见某个区域时执行任务或播放动画

过去,相交检测通常要用到事件监听,并且需要频繁调用 Element.getBoundingClientRect() 方法以获取相关元素的边界信息。事件监听和调用 Element.getBoundingClientRect() 都是在主线程上运行,因此频繁触发、调用可能会造成性能问题。这种检测方法极其怪异且不优雅

Intersection Observer API 是什么

我们需要观察的元素被称为 目标元素(target),设备视窗或者其他指定的元素视口的边界框我们称它为 根元素(root),或者简称为 根 。

Intersection Observer API 翻译过来叫做 “交叉观察器”,因为判断元素是否可见(通常情况下)的本质就是判断目标元素和根元素是不是产生了 交叉区域

为什么是通常情况下,因为当我们 css 设置了 opacity: 0,visibility: hidden 或者 用其他的元素覆盖目标元素 的时候,对于视图来说是不可见的,但对于交叉观察器来说是可见的。这里可能有点抽象,大家只需记住,交叉观察器只关心 目标元素 和 根元素 是否有 交叉区域, 而不管视觉上能不能看见这个元素。当然如果设置了 display:none,那么交叉观察器就不会生效了,其实也很好理解,因为元素已经不存在了,那么也就监测不到了。

一句话总结:Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。 -- MDN

Intersection Observer API怎么用

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

首先调用浏览器原生构造函数 IntersectionObserver ,构造函数的返回值是一个 观察器实例 。

构造函数 IntersectionObserver 接收两个参数 callback 和options

为了方便理解,我们先看第二个参数 options 。一个可以用来配置观察器实例的对象,那么这个配置对象都包含哪些属性呢?

options: 配置对象(可选,不传时会使用默认配置)

构造函数接收的参数 options

  • root: 设置目标元素的根元素,也就是我们用来判断元素是否可见的区域,必须是目标元素的父级元素,如果不指定的话,则使用浏览器视窗,也就是 document。

  • rootMargin: 一个在计算交叉值时添加至根的边界中的一组偏移量,类型为字符串 (string) ,可以有效的缩小或扩大根的判定范围从而满足计算需要。语法大致和CSS 中 margin 属性等同,默认值 “0px 0px 0px 0px” ,如果有指定 root 参数,则 rootMargin 也可以使用百分比来取值。 rootMargin示意图.jpg

如图,蓝色的区域就是交叉区域, rootMargin 就是给根元素扩大或缩小了判断的区域, 如图,当视口(灰色区域)滑到下方蓝色边界的时候就接触到交叉区域了,此时就会触发一次回调函数了.本来要接触到黑色小块(target目标)才会算接触交叉区域,触发回调.

  • threshold: 介于 0 和 1 之间的数字,指示触发前应可见的百分比。也可以是一个数字数组,以创建多个触发点,也被称之为 阈值。如果构造器未传入值, 则默认值为 0 。

threshold示意图

可以理解为在交叉区域的触发点,比如threshold: [0.1, 0.2, 0.3, 0.5],当视口进入交叉区域的百分之10,20… 都会触发回调(例如红色横线)

  • trackVisibility: 一个布尔值,指示当前观察器是否将跟踪目标可见性的更改,默认为 false ,注意,此处的可见性并非指目标元素和根元素是否相交,而是指视图上是否可见(在视口内),这个我们之前就已经分析过了,如果此值设置为 false 或不设置,那么回调函数参数中 IntersectionObserverEntry 的 isVisible 属性将永远返回 false 。

  • delay: 一个数字,也就是回调函数执行的延迟时间(毫秒)。如果 trackVisibility 设置为 true,此值最小值设置为 100 ,否则会报错。

callback: 可见性发生变化时触发的回调函数

当元素可见比例超过指定阈值后,会调用一个回调函数,此回调函数接受两个参数:存放 IntersectionObserverEntry 对象的数组和观察器实例(可选)

第一个参数IntersectionObserverEntry

  • boundingClientRect: 一个对象,包含目标元素的 getBoundingClientRect() 方法的返回值。

  • intersectionRatio: 一个对象,包含目标元素与根元素交叉区域 getBoundingClientRect() 的返回值。

  • intersectionRect: 目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为 1 ,完全不可见时小于等于 0 。

  • isIntersecting: 返回一个布尔值,如果目标元素与根元素相交,则返回 true ,如果 isIntersecting 是 true,则 target 元素至少已经达到 thresholds 属性值当中规定的其中一个阈值,如果是 false,target 元素不在给定的阈值范围内可见。

  • isVisible: 这个看字面意思应该是 “是否可见” ,如果要让这个属性生效,那么在使用构造函数生成观察器实例的时候,传入的 options 参数必须配置 trackVisibility 为 true,并且 delay 设置为大于 100 ,否则该属性将永远返回 false 。

表示目标dom是否在视口区域,也就是说当前可不可以看见,能看见就是true,即使目标的透明度为0 仍然为true.

  • rootBounds: 一个对象,包含根元素的 getBoundingClientRect() 方法的返回值。

  • target:: 被观察的目标元素,是一个 DOM 节点。在观察者包含多个目标的情况下,这是确定哪个目标元素触发了此相交更改的简便方法。

  • time: 该属性提供从 首次创建观察者 到 触发此交集改变 的时间(以毫秒为单位)。通过这种方式,你可以跟踪观察器达到特定阈值所花费的时间。即使稍后将目标再次滚动到视图中,此属性也会提供新的时间。这可用于跟踪目标元素进入和离开根元素的时间,以及两个阈值触发的间隔时间。

boundingClientRect ,intersectionRatio , rootBounds 三个属性展开的内容

  • bottom: 元素下边距离页面上边的距离
  • left: 元素左边距离页面左边的距离
  • right: 元素右边距离页面左边的距离
  • top: 元素上边距离页面上边的距离
  • width: 元素的宽
  • height: 元素的高
  • x: 等同于 left,元素左边距离页面左边的距离
  • y: 等同于 top,元素上边距离页面上边的距离

用一张图来展示一下这几个属性,特别需要注意的是 right 和 bottom ,跟我们平时写 css 的 position 那个不一样

top-bottom.png

第二个参数IntersectionObserver 观察器实例对象

观察器实例上面包含如下属性

  • root
  • rootMargin
  • thresholds
  • trackVisibility
  • delay

是不是特别眼熟,没错,就是我们创建观察者实例的时候,传入的 options 对象,只不过 options 对象是可选的,观察器实例的属性就使用我们传入的 options 对象,如果没传就使用默认值,唯一不同的是,options 中 的属性 threshold 是单数,而我们实例获取到的 thresholds 是复数

值得注意的是,这里的所有属性都是 只读 的,也就是说一旦观察器被创建,则 无法 更改其配置,所以一个给定的观察者对象只能用来监听可见区域的特定变化值。

简易demo

可以自己调试玩

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .root {
      height: 400px;
      width: 400px;
      border: 1px solid black;
      overflow-y: scroll;
    }
    .root .box {
      position: relative;
      width: 400px;
      height: 900px;
    }
    .root .box .target{
      /* opacity: 0; */
      position: absolute;
      bottom: 0;
      width: 50px;
      height: 50px;
      background-color: aqua;
    }
  </style>
</head>
<body>
  <div class="root">
    <div class="box">
      <div class="target"></div>
      <div ></div>
    </div>
  </div>
  <script>
    ((doc) => {
      let n = 0
      //获取目标元素
      const target = doc.querySelector(".target")
      //获取根元素
      const root = doc.querySelector(".root")
      //回调函数
      const callback = (entries, observer) => {
        n++
        console.log(`🐴🐴~ 执行了 ${n} 次callback`);
        console.log('🐴🐴~ entries:', entries);
        console.log('🐴🐴~ observer:', observer);
      };
      //配置对象
      const options = {
        root: root,
        rootMargin: '0px 0px 100px 0px',
        threshold: [0.1, 0.3, 0.5, 0.8, 1],
        trackVisibility: true,
        delay: 100
      };
      //创建观察器
      const myObserver = new IntersectionObserver(callback, options);
      //开始监听目标元素
      myObserver.observe(target);
      console.log('🐴🐴~ myObserver:', myObserver);
    })(document)
  </script>
</body>
</html>

观察器实例方法

  1. observe
 const myObserver = new IntersectionObserver(callback, options);
 myObserver.observe(target);

接受一个目标元素作为参数。很好理解,当我们创建完观察器实例后,要手动的调用 observe 方法来通知它开始监测目标元素。

可以在同一个观察者对象中配置监听多个目标元素

target2 元素是通过代码自动监测的,而 target1 则是我们在点击了 observe 按钮之后开始监测的。通过动图可以看到,当我单击 observe 按钮后,我们的 entries 数组里面就包含了两条数据,前文中说到,可以通过 target 属性来判断是哪个目标元素

  1. unobserve
 const myObserver = new IntersectionObserver(callback, options);
 myObserver.observe(target);
 myObserver.unobserve(target)

接收一个目标元素作为参数,当我们不想监听某个元素的时候,需要手动调用 unobserve 方法来停止监听指定目标元素。通过动图可以发现,当我们点击 unobserve 按钮后,由两条数据变成了一条数据,说明 target1 已经不再接受监测了。

  1. disconnect
 const myObserver = new IntersectionObserver(callback, options);
 myObserver.disconnect()

当我们不想监测任何一个目标元素时,我们需要手动调用 disconnect 方法停止监听工作。通过动图可以看到,当我们点击 disconnect 按钮后,控制台不再输出 log ,说明监听工作已经停止,可以通过 observe 再次开启监听工作。

  1. takeRecords

返回所有观察目标的 IntersectionObserverEntry 对象数组,应用场景较少。

当观察到交互动作发生时,回调函数并不会立即执行,而是在空闲时期使用 requestIdleCallback 来异步执行回调函数,但是也提供了同步调用的 takeRecords 方法。

如果异步的回调先执行了,那么当我们调用同步的 takeRecords 方法时会返回空数组。同理,如果已经通过 takeRecords 获取了所有的观察者实例,那么回调函数就不会被执行了。

其他注意点

构造函数 IntersectionObserver 配置的回调函数都在哪些情况下被调用?

构造函数 IntersectionObserver 配置的回调函数,在以下情况发生时可能会被调用

  • Observer 第一次监听目标元素的时候。
  • 当目标(target)元素与根(root)元素发生交集的时候执行。
  • 监听的目标元素和根元素内其他元素的相交部分大小发生变化时。 无论目标元素是否与根元素相交,当我们第一次监听目标元素的时候,回调函数都会触发一次,所以不要直接在回调函数里写逻辑代码,尽量通过 isIntersecting 或者 intersectionRect 进行判断之后再执行逻辑代码

页面的可见性如何监测

页面的可见性可以通过document.visibilityState或者document.hidden获得。页面可见性的变化可以通过document.visibilitychange来监听。

可见性和交叉观察

当 css 设置了opacity: 0visibility: hidden 以及 用其他的元素覆盖目标元素 ,都不会影响交叉观察器的监测,也就是都不会影响 isIntersecting 属性的结果,但是会影响 isVisible 属性的结果, 如果元素设置了 display:none 就不会被检测了。当然影响元素可视性的属性不止上述这些,还包括positionmarginclip 等等等等..

交集的计算

所有区域均被 Intersection Observer API 当做一个 矩形 看待。如果元素是不规则的图形也将会被看成一个包含元素所有区域的最小矩形,相似的,如果元素发生的交集部分不是一个矩形,那么也会被看作是一个包含他所有交集区域的最小矩形。

我怎么知道目标元素来自视口的上方还是下方

目标元素滚动的方向也是可以判断的,原理是根元素的 entry.rootBounds.y (根容器top)是固定不变的 ,所以我们只需要计算 entry.boundingClientRect.y(目标元素的top) 与 entry.rootBounds.y 的大小,当回调函数触发的时候,我们记录下当时的位置,如果 entry.boundingClientRect.y > entry.rootBounds.y,说明是在视口下方,那么当下一次目标元素可见的时候,我们就知道目标元素时来自视口下方的,反之亦然。

let wasAbove = false;
function callback(entries, observer) {
    entries.forEach(entry => {
        const isAbove = entry.boundingClientRect.y > entry.rootBounds.y;\\
        if (entry.isIntersecting) {
            if (wasAbove) {
                // Comes from top
            }
        }
        wasAbove = isAbove;
    });
}

应用场景

1无限滚动

(该例子中,如果快速滑动页面,页面会有一种卡住的感觉,再次往下滑将无法触发无限滚动,实际上这是因为我们设置了延迟时间的缘故,只要我们把trackVisibility设为false,清空delay即可。但是这样在实际开发过程中会有性能问题,因为如果用户不停的快速往下刷,就会不断的请求,所以还要根据实际情况进行节流)

<template>
  <div>
    <div class="box">
      <div class="vbody"
           v-for='item in list'
           :key='item'>内容区域{{item}}</div>
      <div class="reference"
           ref='reference'></div>
    </div>
  </div>
</template>

<script>
export default {
  name: '',
  data () {
    return {
      list: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    }
  },
  methods: {
    intersectionObserver () {
      let n = 10
      const reference = this.$refs.reference
      // 回调函数
      const callback = (entries) => {
        console.log(entries)
        const myEntry = entries[0]
        if (myEntry.isIntersecting) {
          console.log(`🚀🚀~ 触发了无线滚动,开始模拟请求数据 ${n}`)
          n++
          this.list.push(n)
        }
      }
      // 配置对象
      const options = {
        root: null,
        rootMargin: '0px 100px 0px 0px',
        threshold: [0, 1],
        trackVisibility: true,
        delay: 100
      }
      // 观察器实例
      const myObserver = new IntersectionObserver(callback, options)
      // 开始观察
      myObserver.observe(reference)
    }
  },
  mounted () {
    this.intersectionObserver()
  }
}
</script>

<style scoped lang="less">
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.reference {
  width: 100%;
  visibility: hidden;
}
.vbody {
  width: 100%;
  height: 200px;
  // background-color: red;
  border: 2px solid darkblue;
  color: black;
  font-size: 40px;
  text-align: center;
  line-height: 200px;
  margin: 10px 0;
}
</style>

2图片预加载

利用 options 的 rootMargin属性,可以在图片即将进入可视区域的时间进行图片的加载,即避免了提前请求大量图片造成的性能问题,也避免了图片进入窗口才加载已经来不及的问题

const observer = new IntersectionObserver((entries, observer) => { 
    entries.forEach(entry => {
        if (entry.isIntersecting) { //true表示从不可视状态变为可视状态
            let img = entry.target;
            img.setAttribute('src', img.getAttribute('data-src')) 
            observer.unobserve(img); // 停止观察已开始加载的图片 
        } 
    }) 
}, {}); 
Array.from(document.querySelectorAll('img')).forEach((item) => {
  observer.observe(item)  //观察所有图片资源,开始观察item
});

3埋点上报

通常情况下,我们统计一个元素是否被用户有效的看到,并不是元素刚出现就触发埋点,而是元素进入可是区域一定比例才可以,我们可以配置 options 的 threshold 为 0.5

案例代码差别不大,原理相同,暂时不贴了,有需要可以再发。

兼容性

image.png

image.png 为什么有两张兼容性的图呢?因为 trackVisibility 和 delay 两个属性是属于 IntersectionObserver V2 的。所以小伙伴们在用的时候一定要注意兼容性。当然也有兼容解决方案,那就是 intersection-observer-polyfill

注:

本文部分内容来自字节前端 ByteFE ,作者一尾流莺(mp.weixin.qq.com/s/rslnfBfcU… 本人以学习为目的针对其内容做了整理和添加了部分个人注解。