IntersectionObserver 能帮你做什么?

54 阅读6分钟

前言

在项目开发中,往往需要去判断一个元素是否被用户看见(进入可视区),而这是为了去实现某些目的,例如:

  • 优化资源加载、渲染
    • 图片懒加载
    • 无限滚动(数据分批渲染)
    • 虚拟列表
    • ...
  • 添加动画效果
    • 对于进入可视区的元素添加动画,让页面更具有 交互感
  • 判断广告曝光
    • 用于去判断页面中的广告点位是否被曝光
  • ...

之前的做法就是通过监听 scroll 事件,然后再事件 回调 中调用 目标元素getBoundingClientRect() 方法,获取其相对于 视口左上角的坐标,从而判断其是否存在于可视区中。

function isElementInViewport(element) {
  const rect = element.getBoundingClientRect();
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
 
  // 判断元素是否在可见区域的垂直范围内
  const isInVerticalViewport = rect.top <= viewportHeight && rect.bottom >= 0;
 
  // 判断元素是否在可见区域的水平范围内
  const isInHorizontalViewport = rect.left <= viewportWidth && rect.right >= 0;
 
  return isInVerticalViewport || isInHorizontalViewport;
}

window.addEventListener('scroll', () => {
    const inViewport = isElementInViewport(document.querySelector('.target-item'));
    ...
});

image.png

但上述方式存在由于 滚动事件频发 导致频繁调用元素的 getBoundingClientRect() 进而引发页面的 重排或回流,而现在通过 IntersectionObserver 可以更便捷的实现这个功能,避免造成性能问题。

IntersectionObserver

元素是否可见,实际上就是 目标元素可视区 是否产生了 交叉区,因此 IntersectionObserver 也被称为 “交叉观察器”

如下是其简单的一个使用:

const isView = ref(false);

onMounted(() => {
  const intersectionObserver = new IntersectionObserver((entries) => {
    console.log(1111);
    // 若 intersectionRatio 为 0,则目标在视野外
    isView.value = entries[0].intersectionRatio > 0;
  },{
    root: document.querySelector('.box')!,// 监听目标的祖先元素
  });
  // 开始监听
  intersectionObserver.observe(document.querySelector('.item_10')!);
});

1.gif

通过这个例子我们来简单介绍下其相关的配置 new IntersectionObserver(callback[, options]),更具体可见 此处

callback 回调函数

执行时机

通过上述例子不难发现,其回调函数的执行时机 默认只有两次

  • 目标元素 进入视口
  • 目标元素 离开视口

这也是为什么前面说使用 IntersectionObserver API 比之前在 scroll 事件 回调中判断目标元素是否进入视口性能更好的原因之一。

但实际上我们还可以通过 options.threshold 配置项来设定其更具体的触发时机,后面会提到。

接收参数

var observer = new IntersectionObserver((entries, observer) => {...});

options 配置项

var observer = new IntersectionObserver(callback, {
  root: document.querySelector('.container'),
  rootMargin: '0px 0px 0px 0px',
  threshold: [0.25, 0.5, 0.75, 1]
});
  • root

    • 传入监听 目标元素的祖先元素,会将传入的元素作为 视口元素,如果没有指定默认就为 整个页面视口
  • rootMargin

    • 顾名思义,实际上就相当于 CSSmargin 属性,可以放大或缩小 视口元素 的判定范围,默认值是 "0px 0px 0px 0px"
    • 例如,0px 0px -100px 0px 相当于 视口元素 下边缘向上 收缩 100px,此时需要 目标元素顶部 进入可视区域 >= 100px 才会触发回调
  • threshold

    • 能够控制回调函数的触发时机,实际上它指定了 目标元素视口元素 之间的 交叉比例,是 0.01.0 之间的数组
    • 例如,[0.25, 0.5, 0.75, 1] 相当于指定了 目标元素视口元素 交叉比例达到 25%、50%、75%、100% 时各触发一次回调函数

    2.gif

实例方法

应用场景

常见的图片懒加载、无限滚动、虚拟列表等这里就不再实现,我们来看一些其他的场景吧。

标题与内容的联动

很多站点都会有这样的功能,以 Vue 文档 为例,如下: 3.gif

如果你有兴趣去查看一下其实现,会发现其用的就是 IntersectionObserver

image.png

具体实现

观察上述效果,不难看出就是一个双向交互:

  • 点击标题,可以直接定位到 内容区
    • 可以通过 <a :href="#"> 锚点的方式实现
  • 浏览内容,可以自动定位 当前标题
    • 当目标内容进入视口后,获取其 关键信息,如 id 去匹配标题项即可

于是可以很容易就写出如下效果:

1.gif

<template>
  <!-- 标题区 -->
  <div class="title-box">
    <div class="active-line" :style="{ top: activeOffsetTop + 'px' }"></div>
    <a
      :class="[
        'title-item',
        `title-item-${item.id}`,
        item.active ? 'active' : '',
      ]"
      :href="`#${item.id}`"
      v-for="(item, i) in data"
      :key="item.id"
      @click="clickAction($event, item.id)"
      >{{ item.title }}</a
    >
  </div>

  <!-- 内容区 -->
  <div class="content-box">
    <div class="content-item" v-for="item in data" :data-id="item.id">
      <h3 :id="item.id">{{ item.title }}</h3>
      <div class="content-item-text" v-for="text in item.content">
        {{ text }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
...
const data = ref([
  {
    id: "1",
    title: "标题 1",
    content: ["内容 1".repeat(100)],
    active: true,
  },
  ...
]);

let intersectionObserver: IntersectionObserver;

const observe = () => {
  const root = document.querySelector(".content-box")!;
  const contents: HTMLElement[] = [] = Array.from(document.querySelectorAll(".content-item")!);

  // 实例化 IntersectionObserver
  intersectionObserver = new IntersectionObserver(
    (entries) => {
      // 针对
      entries.forEach((entry) => {
        const { target, isIntersecting } = entry;
        // 当前目标元素是否进入视口
        if (isIntersecting) {
          const id = target.dataset.id;
          clickAction(
            { target: document.querySelector(`.title-item-${id}`), mock: true },
            id
          );
        }
      });
    },
    {
      root,
      threshold: [0.5],
    }
  );

  // 开始监听每一个内容项
  contents.forEach((target) => {
    intersectionObserver.observe(target);
  });
};

onMounted(() => {
  observe();
});

onBeforeUnmount(() => {
  intersectionObserver.disconnect();
});

// 偏移量
const activeOffsetTop = ref(0);

// 标题点击事件
const clickAction = (e, id: string) => {
  data.value.forEach((v) => {
    if (id === v.id) {
      v.active = true;
      activeOffsetTop.value = e.target.offsetTop;
    } else {
      v.active = false;
    }
  });
};
</script>

存在缺点

基于 IntersectionObserver 实现很便捷,但也会存在一个缺点,如下所示:

2.gif

当点击 还有其他问题? 标题时,选中样式不会在此标题上,而是选中了它下面的那个标题 选择你的学习路径,这是为啥呢?

85B64B42.gif

这是因为 还有其他问题? 标题对应的内容很短,于是在基于 a 标签 锚点跳转的时候,将它下面标题的内容也带入了 可视区 中,此时 IntersectionObserver 的回调就会触发,所以最终选中的标题就是 选择你的学习路径

入场动画

IntersectionObserver API 的特性非常适合用来处理元素的 入场动画,很多官网的动画也是这么去实现的,那下面就实现一个简单的入场动画,如下:

3.gif

Element.animate()

这里使用 Element.animate() 方法来执行上面这个简单的动画,针对一些较为复杂的动画可以通过 @keyframes + animation 的方式定义在具体的 选择器 中,然后在目标元素进入视口时为其添加对应的 选择器 即可。

Element.animate() 方法接收两个参数:

  • keyframes,关键帧对象数组或一个关键帧对象,关键帧格式
  • options,代表动画持续时间的整数(以毫秒为单位),或者一个包含一个或多个时间属性,KeyframeEffect() 参数

看着很抽象,实际上就是把之前在 CSS 写动画的方式抽离到 JS 中了。

具体实现

<script setup lang="ts">
...

// 开启监听
const startObserve = (selector: string) => {
  const contents: HTMLElement[] = Array.from(
    document.querySelectorAll(selector)!
  );

  // 实例化 IntersectionObserver
  let intersectionObserverInstance = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      const { target, isIntersecting } = entry;
      // 目标元素进入视口
      if (isIntersecting) {
        // 执行入场动画
        startAnimate(target);
      }
    });
  });

  // 开始监听每一个内容项
  contents.forEach((target) => {
    intersectionObserverInstance.observe(target);
  });

  return intersectionObserverInstance;
};

// 开始动画
const startAnimate = (target: Element) => {
  const newspaperSpinning = [
    {
      transform: `translate(100px, 50px) scale(0.5)`,
      opacity: 0,
      background: "linear-gradient(225deg, #CC4D82, #FDCA8A)",
      color: "red",
    },
    { transform: "translate(0px, 0px) scale(1)" },
  ];

  const newspaperTiming = {
    duration: 300,// 动画执行时间
    iterations: 1,// 动画执行次数
  };

  target.animate(newspaperSpinning, newspaperTiming);
};

let intersectionObserver: IntersectionObserver;
onMounted(() => {
  intersectionObserver = startObserve(".item");
});

onBeforeUnmount(() => {
  intersectionObserver.disconnect();
});
</script>

<template>
  <div class="list">
    <div class="item" v-for="i in 20" :data-index="i">{{ i }}</div>
  </div>
</template>

兼容性

从下图可以看出各主流浏览器都有相应的实现,针对一些兼容性比较差的可以使用对应的 polyfill

image.png

最后

IntersectionObserver API 提供给我们一种更便捷的判断目标元素是否处于可视区的方式,它相对于传统滚动事件的实现方式来讲 性能更好,除此之外还允许我们自定义 thresholds 阈值 来实现 自定义回调触发时机,还可以实现 同时监听多个目标元素

除此之外,也要注意在 快速滚动 的场景下 IntersectionObserver 可能会不执行的问题,详情可见

0DDBDEC2.gif