交叉观察器的工作原理详解

Avatar of Travis Almand
Travis Almand 发表于

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

已经有很多优秀的文章探讨了如何使用这个 API,包括来自 Phil HawksworthPreethiMateusz Rybczonek 等作者的文章,仅举几例。但我在这里的目标略有不同。今年早些时候,我有机会向达拉斯 VueJS 聚会介绍 VueJS 过渡组件,我的 第一篇文章 就是基于此发表在 CSS-Tricks 上的。在演示的问答环节中,有人问我如何根据滚动事件触发过渡——当然可以做到,但一位观众建议我研究一下 Intersection Observer。

这让我开始思考。我了解 Intersection Observer 的基本知识以及如何使用它创建一个简单的示例。但我是否知道如何不仅解释 *如何使用它*,还解释 *它是如何工作的*?它究竟为我们开发者提供了什么?作为一名“资深”开发者,我该如何向一位刚从训练营毕业,可能甚至不知道它存在的人解释它呢?

我决定我需要弄清楚。在花了一些时间进行研究、测试和实验之后,我决定分享我学到的一些知识。因此,我们现在在这里。

交叉观察器的简要说明

W3C 公共工作草案(2017 年 9 月 14 日首次草案)的摘要中,将交叉观察器 API 描述为

此规范描述了一个 API,可用于了解 DOM 元素(“目标”)相对于包含元素或顶级视口(“根”)的可见性和位置。位置是异步传递的,对于了解元素的可见性和实现 DOM 内容的预加载和延迟加载很有用。

其基本思想是,提供一种监视子元素并在其进入父元素之一的边界框时发出通知的方法。这最常用于目标元素在根元素中滚动到视图中。在引入交叉观察器之前,此类功能是通过监听滚动事件来实现的。

尽管交叉观察器是此类功能的更高效解决方案,但我并不建议我们将其视为滚动事件的替代品。相反,我建议我们将此 API 视为一个具有与滚动事件功能重叠的额外工具。在某些情况下,两者可以协同工作以解决特定问题。

一个基本示例

我知道我冒着重复其他文章中已经解释的内容的风险,但让我们来看一个交叉观察器的基本示例以及它为我们提供了什么。

观察器由四个部分组成

  1. “根”,即观察器绑定的父元素,可以是视口
  2. “目标”,即被观察的子元素,可以有多个
  3. 选项对象,它定义了观察器行为的某些方面
  4. 回调函数,每次观察到交叉点变化时都会调用该函数

一个基本示例的代码可能如下所示

const options = {
  root: document.body,
  rootMargin: '0px',
  threshold: 0
}

function callback (entries, observer) {
  console.log(observer);
  
  entries.forEach(entry => {
    console.log(entry);
  });
}

let observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);

代码中的第一部分是选项对象,它具有 rootrootMarginthreshold 属性。

root 是父元素,通常是滚动元素,包含被观察的元素。根据需要,它可以是页面上的任何单个元素。如果根本没有提供该属性或将值设置为 null,则将视口设置为根元素。

rootMargin 是一个描述可以称为根元素边距的值字符串,它会影响目标元素滚动到的结果边界框。它的行为与 CSS margin 属性非常相似。您可以使用诸如 10px 15px 20px 之类的值,这将为我们提供 10px 的上边距、15px 的左右边距和 20px 的下边距。只有边界框受到影响,元素本身不受影响。请记住,唯一允许的长度是像素和百分比值,它们可以是负数或正数。还要注意,如果根元素不是页面上的实际元素(例如视口),则 rootMargin 不起作用。

threshold 是用于确定何时应观察交叉点变化的值。可以在数组中包含多个值,以便同一个目标可以多次触发交叉点。不同的值是使用零到一的百分比,类似于 CSS 中的 opacity,因此值 0.5 将被视为 50% 等等。这些值与目标的交叉比率相关,这将在稍后解释。阈值为零时,目标元素的第一个像素与根元素相交时会触发交叉点。阈值为一时,整个目标元素在根元素内时才会触发。

代码中的第二部分是回调函数,每当观察到交叉点变化时都会调用该函数。传递了两个参数;条目存储在数组中,表示触发交叉点变化的每个目标元素。这提供了大量信息,可用于开发者可能创建的任何功能的大部分内容。第二个参数是关于观察器本身的信息,本质上是提供的 options 对象中的数据。如果目标绑定到多个观察器,这提供了一种识别哪个观察器正在运行的方法。

代码中的第三部分是观察器本身的创建以及它观察目标的位置。在创建观察器时,回调函数和 options 对象可以位于观察器外部,如所示。开发者可以在内联编写代码,但观察器非常灵活。例如,如果需要,回调和选项可以在多个观察器之间使用。然后,observe() 方法传递需要观察的目标元素。它只能接受一个目标,但可以在同一个观察器上对多个目标重复此方法。同样,非常灵活。

请注意代码中的控制台日志。以下是这些输出的内容。

观察器对象

记录传递到回调函数中的观察器数据,我们会得到类似以下内容

IntersectionObserver
  root: null
  rootMargin: "0px 0px 0px 0px"
  thresholds: Array [ 0 ]
  <prototype>: IntersectionObserverPrototype { }

…这本质上是在创建观察器时传递给观察器的 options 对象。这可以用来确定交叉点绑定的根元素。请注意,即使原始 options 对象将 rootMargin 设置为 0px,此对象也会将其报告为 0px 0px 0px 0px,这在考虑 CSS 边距规则时是预期的。然后是观察器正在运行的一系列阈值。

条目对象

记录传递到回调函数中的条目数据,我们会得到类似以下内容

IntersectionObserverEntry
  boundingClientRect: DOMRect
    bottom: 923.3999938964844, top: 771
    height: 152.39999389648438, width: 411
    left: 9, right: 420
    x: 9, y: 771
    <prototype>: DOMRectPrototype { }
  intersectionRatio: 0
  intersectionRect: DOMRect
    bottom: 0, top: 0
    height: 0, width: 0
    left: 0, right: 0
    x: 0, y: 0
    <prototype>: DOMRectPrototype { }
  isIntersecting: false
  rootBounds: null
  target: <div class="item">
  time: 522
  <prototype>: IntersectionObserverEntryPrototype { }

是的,这里有很多事情正在发生。

对于大多数开发者来说,最有可能有用的两个属性是 intersectionRatioisIntersectingisIntersecting 属性是一个布尔值,正如人们所想的那样——在交叉点变化时,目标元素与根元素相交。intersectionRatio 是当前与根元素相交的目标元素的百分比。它由零到一的百分比表示,类似于观察器选项对象中提供的阈值。

三个属性——boundingClientRectintersectionRectrootBounds——表示交叉点的三个方面的特定数据。boundingClientRect 属性提供目标元素的边界框,其中包含来自视口左上角的底部、左侧、右侧和顶部值,就像使用 Element.getBoundingClientRect() 一样。然后将目标元素的高度和宽度作为 X 和 Y 坐标提供。rootBounds 属性为根元素提供相同形式的数据。intersectionRect 提供类似的数据,但它描述的是目标元素在根元素内交叉区域形成的框,这对应于 intersectionRatio 值。传统的滚动事件需要手动执行此计算。

需要注意的一点是,所有这些表示不同元素的形状始终都是矩形。无论所涉及元素的实际形状如何,它们始终都会简化为包含该元素的最小矩形。

target 属性指的是正在观察的目标元素。在观察器包含多个目标的情况下,这是确定哪个目标元素触发了此交叉点变化的简便方法。

time 属性提供从观察者首次创建到触发此交叉更改的时间(以毫秒为单位)。这使您可以跟踪查看者发现特定目标所需的时间。即使目标在稍后时间再次滚动到视图中,此属性也会提供新的时间。这可以用来跟踪目标进入和离开根元素的时间。

虽然每当观察到交叉更改时都会向我们提供所有这些信息,但在观察者首次启动时也会向我们提供这些信息。例如,在页面加载时,页面上的观察者会立即调用回调函数并提供其正在观察的每个目标元素的当前状态。

这是一种关于页面上元素关系的大量数据,以非常高效的方式提供。

Intersection Observer 方法

Intersection Observer 有三种值得注意的方法:observe()unobserve()disconnect()

  • observe():observe 方法接收目标元素的 DOM 引用,将其添加到观察者要监视的元素列表中。一个观察者可以有多个目标元素,但此方法一次只能接受一个目标。
  • unobserve():unobserve 方法接收目标元素的 DOM 引用,将其从观察者监视的元素列表中移除。
  • disconnect():disconnect 方法导致观察者停止监视其所有目标元素。观察者本身仍然处于活动状态,但没有目标。在 disconnect() 之后,目标元素仍然可以通过 observe() 传递给观察者。

这些方法提供了监视和取消监视目标元素的能力,但无法更改创建观察者时传递给观察者的选项。如果需要不同的选项,则必须手动重新创建观察者。

性能:Intersection Observer 与滚动事件

在我探索 Intersection Observer 及其与使用滚动事件的比较时,我知道我需要进行一些性能测试。因此,使用 Puppeteer 创建了一个完全非科学的测试。为了节省时间,我只想大致了解两者之间的性能差异。因此,创建了三个简单的测试。

首先,我创建了一个包含一百个 div 的基本 HTML 文件,这些 div 有一定的高度以创建长滚动页面。在基本 http-server 处于活动状态下,我使用 Puppeteer 加载 HTML 文件,启动跟踪,强制页面以预设增量向下滚动到底部,在到达底部后停止跟踪,最后保存跟踪结果。我还使测试能够重复多次并在每次输出数据。然后我复制了基本 HTML,并在每个我想运行的测试类型的脚本标签中编写了我的 JavaScript 代码。每个测试有两个文件:一个用于 Intersection Observer,另一个用于滚动事件。

所有测试的目的是检测目标元素何时以 25% 的增量向上滚动通过视口。在每个增量处,应用一个 CSS 类,该类更改元素的背景颜色。换句话说,每个元素都应用了 DOM 更改,这些更改会导致重绘。每个测试在两台不同的机器上运行五次:我的开发 Mac,硬件方面相当新;以及我的个人 Windows 7 机器,如今可能算是平均水平。记录了脚本、渲染、绘制和系统的跟踪摘要结果,然后取平均值。再说一次,这一切都没有什么科学依据——只是一个大致的了解。

第一个测试有一个观察者或一个滚动事件,每个事件只有一个回调函数。对于观察者和滚动事件来说,这都是一个相当标准的设置。但是,在这种情况下,滚动事件需要做更多工作,因为它试图模仿观察者默认提供的 数据。完成所有这些计算后,数据将存储在类似于观察者所做的条目数组中。然后,两者之间移除和应用类的功能完全相同。我确实使用 requestAnimationFrame 对滚动事件进行了一些节流。

第二个测试有 100 个观察者或 100 个滚动事件,每种类型都有一个回调函数。每个元素都被分配了自己的观察者和事件,但回调函数是相同的。这实际上效率低下,因为每个观察者和事件的行为完全相同,但我希望进行一个简单的压力测试,而不必创建 100 个唯一的观察者和事件——尽管我见过很多使用观察者这种方式的例子。

第三个测试有 100 个观察者或 100 个滚动事件,每种类型都有 100 个回调函数。这意味着每个元素都有自己的观察者、事件和回调函数。这当然效率极低,因为所有这些都是重复的功能,存储在巨大的数组中。但这种低效正是此测试的目的。

Intersection Observer 与滚动事件压力测试

在上图中,您会看到第一列表示我们的基线,其中根本没有运行任何 JavaScript 代码。接下来的两列表示第一种类型的测试。Mac 运行得都很好,正如我预期的那样,这是一台用于开发的高端机器。Windows 机器给我们讲述了一个不同的故事。对我来说,主要关注点是红色的脚本结果。在 Mac 上,观察者的差异约为 88 毫秒,而滚动事件约为 300 毫秒。Mac 上的整体结果相当接近,但脚本在滚动事件中受到了打击。对于 Windows 机器来说,情况糟糕得多。第一个也是最简单的测试中,观察者约为 150 毫秒,而滚动事件约为 1400 毫秒。

对于第二个测试,我们开始更清楚地看到滚动测试的低效性。Mac 和 Windows 机器都运行了观察者测试,结果与之前大致相同。对于滚动事件测试,脚本变得更加繁重,以完成给定的任务。Mac 的脚本时间跃升到近一秒,而 Windows 机器则跃升到惊人的 3200 毫秒左右。

对于第三个测试,幸运的是情况没有变得更糟。结果与第二个测试大致相同。需要注意的是,在所有三个测试中,两台计算机的观察者结果都是一致的。尽管没有对观察者测试进行效率方面的努力,但 Intersection Observer 仍然以很大的优势优于滚动事件。

因此,在我自己两台机器上进行的非科学测试之后,我感觉我对滚动事件和 Intersection Observer 的性能差异有了一个不错的了解。我相信如果付出一些努力,我可以使滚动事件更高效,但这样做值得吗?在某些情况下,滚动事件的精确性是必要的,但在大多数情况下,Intersection Observer 就足够了——尤其是在它似乎在没有任何努力的情况下效率更高的情况下。

理解 intersectionRatio 属性

intersectionRatio 属性由 IntersectionObserverEntry 提供,表示在交叉更改时目标元素位于根元素边界内的百分比。我发现一开始我并不完全理解这个值的实际含义。出于某种原因,我以为它是目标元素出现情况的 0 到 100% 的直接表示,它某种程度上确实是。它与创建观察者时传递给观察者的阈值相关联。例如,它可以用来确定哪个阈值是刚刚触发的交叉更改的原因。但是,它提供的值并不总是直观的。

例如,这个演示

查看 CodePen 上 Travis Almand (@talmand) 编写的
Intersection Observer: intersectionRatio

CodePen 上。

在此演示中,观察者已将父容器分配为根元素。具有目标背景的子元素已分配为目标元素。阈值数组已使用 100 个条目创建,序列为 0、0.01、0.02、0.03,依此类推,直到 1。观察者触发目标元素在根元素内部出现或消失的每个百分比,以便每当比率至少更改 1% 时,框下方的输出文本就会更新。如果您好奇,此阈值是通过以下代码实现的

[...Array(100).keys()].map(x => x / 100) }

我不建议您以这种方式设置项目中典型用途的阈值。

最初,目标元素完全包含在根元素内,按钮上方的输出将显示比率为 1。它在第一次加载时应该为 1,但我们很快就会看到比率并不总是精确的;数字可能在 0.99 和 1 之间。这看起来确实很奇怪,但它可能发生,因此如果您创建任何与比率等于特定值的检查,请记住这一点。

单击“左”按钮将导致目标元素向左变换,使其一半位于根元素内,另一半位于根元素外。然后 intersectionRatio 应更改为 0.5 或接近该值。我们现在知道目标元素的一半与根元素相交,但我们不知道它在哪里。稍后再详细介绍。

单击“顶部”按钮的作用大致相同。它将目标元素变换到根元素的顶部,使其一半在内,另一半在外面。同样,intersectionRatio 应该在 0.5 左右。即使目标元素的位置与之前完全不同,但结果比率也是相同的。

再次点击“角”按钮会将目标元素变换到根元素的右上角。此时,目标元素只有四分之一在根元素内。intersectionRatio的值应该反映这一点,大约为0.25。点击“中心”会将目标元素变换回中心位置,并完全包含在根元素内。

如果我们点击“大”按钮,则目标元素的高度会变为高于根元素。intersectionRatio的值应该在0.8左右,偏差在几万分之一左右。这是依赖intersectionRatio的棘手部分。基于观察者给定的阈值创建代码,可能会导致永远不会触发的阈值。在这个“大”的示例中,任何基于1的阈值的代码都将无法执行。还要考虑根元素可以调整大小的情况,例如视口从纵向旋转到横向。

查找位置

那么,我们如何知道目标元素相对于根元素的位置呢?幸运的是,IntersectionObserverEntry提供了进行此计算的数据,因此我们只需要进行简单的比较。

考虑这个演示

查看 CodePen
Intersection Observer: Finding the Position
,由 Travis Almand (@talmand) 创建。
CodePen 上。

此演示的设置与之前的设置大致相同。父容器是根元素,内部具有目标背景的子元素是目标元素。阈值是一个包含 0、0.5 和 1 的数组。当你在根元素内部滚动时,目标元素将出现,其位置将在按钮上方的输出中报告。

以下是执行这些检查的代码

const output = document.querySelector('#output pre');

function io_callback (entries) {
  const ratio = entries[0].intersectionRatio;
  const boundingRect = entries[0].boundingClientRect;
  const intersectionRect = entries[0].intersectionRect;

  if (ratio === 0) {
    output.innerText = 'outside';
  } else if (ratio < 1) {
    if (boundingRect.top < intersectionRect.top) {
      output.innerText = 'on the top';
    } else {
      output.innerText = 'on the bottom';
    }
  } else {
    output.innerText = 'inside';
  }
}

我应该指出,我没有循环遍历 entries 数组,因为我知道由于只有一个目标,所以始终只有一个条目。我通过使用entries[0]来走捷径。

你会看到,比率为零表示目标元素在“外部”。小于 1 的比率表示目标元素在顶部或底部。这让我们可以查看目标元素的“顶部”是否小于intersectionRect的顶部,这实际上意味着它在页面上更高,并且被视为“在顶部”。事实上,针对根元素的“顶部”进行检查也可以实现这一点。从逻辑上讲,如果目标元素不在顶部,那么它一定在底部。如果比率恰好等于 1,则表示它在根元素“内部”。水平位置的检查相同,只是使用 left 或 right 属性。

这是使用 Intersection Observer 的效率的一部分。开发人员不需要从受限滚动事件(无论如何都会触发很多次)的各个位置请求此信息,然后计算相关数学来弄清楚所有这些。它由观察者提供,只需要一个简单的if检查。

最初,目标元素的高度高于根元素,因此它从未被报告为“内部”。点击“切换目标大小”按钮将其设置为小于根元素。现在,目标元素在上下滚动时可以位于根元素内部。

通过再次点击“切换目标大小”按钮将目标元素恢复到其原始大小,然后点击“切换根大小”按钮。这会调整根元素的大小,使其高度高于目标元素。再次,在上下滚动时,目标元素有可能在根元素“内部”。

此演示展示了关于 Intersection Observer 的两件事:如何确定目标元素相对于根元素的位置,以及调整这两个元素大小时会发生什么。对调整大小的这种反应是优于滚动事件的另一个优势——无需代码来适应调整大小事件。

创建 position: sticky 事件

CSS position 属性的“sticky” 值可能是一个有用的功能,但在 CSS 和 JavaScript 方面有一些限制。粘性元素的样式只能有一种设计,无论是在其正常状态还是在其粘性状态下。没有简单的方法知道 JavaScript 能够响应这些更改的状态。到目前为止,还没有伪类或 JavaScript 事件让我们意识到元素状态的变化。

我见过一些使用滚动事件和 Intersection Observer 来为粘性定位创建某种事件的示例。使用滚动事件的解决方案总是存在类似于将滚动事件用于其他目的的问题。使用观察者的常用解决方案是使用一个“虚拟”元素,除了作为观察者的目标之外,它几乎没有其他用途。我喜欢避免使用这样的单一用途元素,因此我决定对这个特定想法进行一些修改。

在此演示中,上下滚动以查看部分标题如何对“粘贴”到各自部分做出反应。

查看 CodePen
Intersection Observer: Position Sticky Event
,由 Travis Almand (@talmand) 创建。
CodePen 上。

这是一个检测粘性元素何时位于滚动容器顶部以便可以将类名应用于该元素的示例。这是通过在给观察者提供特定的rootMargin时利用 DOM 的一个有趣的特性来实现的。给定的值是

rootMargin: '0px 0px -100% 0px'

这将根边界的下边距推到根元素的顶部,这留下了一小部分可用于交叉检测的区域,该区域为零像素。即使从数字上讲它不存在,接触此零像素区域的目标元素也会触发交叉更改。考虑到我们可以在 DOM 中拥有高度折叠为零的元素。

此解决方案利用这一点,通过识别粘性元素始终在其“粘性”位置位于根元素的顶部。随着滚动的继续,粘性元素最终会移出视野,并且交叉检测停止。因此,我们根据 entry 对象的isIntersecting属性添加和删除类。

以下是 HTML 代码

<section>
  <div class="sticky-container">
    <div class="sticky-content">
      <span>&sect;</span>
      <h2>Section 1</h2>
    </div>
  </div>

  {{ content here }}
  
</section>

具有类sticky-container的外部 div 是我们观察者的目标。此 div 将被设置为粘性元素并充当容器。用于根据粘性状态设置和更改元素的元素是sticky-content div 及其子元素。这确保了实际的粘性元素始终与根元素顶部收缩的rootMargin接触。

以下是 CSS 代码

.sticky-content {
  position: relative;
  transition: 0.25s;
}

.sticky-content span {
  display: inline-block;
  font-size: 20px;
  opacity: 0;
  overflow: hidden;
  transition: 0.25s;
  width: 0;
}

.sticky-content h2 {
  display: inline-block;
}
  
.sticky-container {
  position: sticky;
  top: 0;
}

.sticky-container.active .sticky-content {
  background-color: rgba(0, 0, 0, 0.8);
  color: #fff;
  padding: 10px;
}

.sticky-container.active .sticky-content span {
  opacity: 1;
  transition: 0.25s 0.5s;
  width: 20px;
}

你会看到.sticky-container在零的顶部创建了我们的粘性元素。其余部分是.sticky-content中常规状态的样式与.active .sticky-content中粘性状态的样式的混合。同样,你几乎可以在 sticky content div 内部执行任何操作。在此演示中,当粘性状态处于活动状态时,从延迟过渡中会出现一个隐藏的部分符号。如果没有辅助工具(如 Intersection Observer),这种效果将很难实现。

JavaScript 代码

const stickyContainers = document.querySelectorAll('.sticky-container');
const io_options = {
  root: document.body,
  rootMargin: '0px 0px -100% 0px',
  threshold: 0
};
const io_observer = new IntersectionObserver(io_callback, io_options);

stickyContainers.forEach(element => {
  io_observer.observe(element);
});

function io_callback (entries, observer) {
  entries.forEach(entry => {
    entry.target.classList.toggle('active', entry.isIntersecting);
  });
}

这实际上是一个非常简单的使用 Intersection Observer 执行此任务的示例。唯一奇怪的是rootMargin中的 -100% 值。请注意,这也可以重复用于其他三侧;它只需要一个新的观察者及其自己的唯一rootMargin,该rootMargin对相应侧具有 -100%。将需要更多具有自己类(例如sticky-container-topsticky-container-bottom)的唯一粘性容器。

此方法的限制是粘性元素的 top、right、bottom 或 left 属性必须始终为零。从技术上讲,你可以使用不同的值,但随后你必须计算出rootMargin的正确值。这很容易做到,但是如果发生调整大小,则不仅需要再次进行计算,而且观察者还必须停止并使用新值重新启动。最简单的方法是将 position 属性设置为零,并使用内部元素来根据自己的需要设置样式。

与滚动事件结合

正如我们在一些演示中看到的,intersectionRatio可能不精确并且有些限制。使用滚动事件可以更精确,但代价是性能效率低下。如果我们结合两者呢?

查看 CodePen
Intersection Observer: Scroll Events
,由 Travis Almand (@talmand) 创建。
CodePen 上。

在此演示中,我们创建了一个 Intersection Observer,回调函数的唯一目的是添加和删除一个事件侦听器,该侦听器侦听根元素上的滚动事件。当目标第一次进入根元素时,将创建滚动事件侦听器,然后在目标离开根元素时将其删除。随着滚动的发生,输出只是显示每个事件的时间戳以显示其实时变化——比仅使用观察者更精确。

HTML 和 CSS 的设置在此阶段相当标准,因此以下是 JavaScript 代码。

const root = document.querySelector('#root');
const target = document.querySelector('#target');
const output = document.querySelector('#output pre');
const io_options = {
  root: root,
  rootMargin: '0px',
  threshold: 0
};
let io_observer;

function scrollingEvents (e) {
  output.innerText = e.timeStamp;
}

function io_callback (entries) {
  if (entries[0].isIntersecting) {
    root.addEventListener('scroll', scrollingEvents);
  } else {
    root.removeEventListener('scroll', scrollingEvents);
    output.innerText = 0;
  }
}

io_observer = new IntersectionObserver(io_callback, io_options);
io_observer.observe(target);

这是一个相当标准的示例。请注意,我们希望阈值为零,因为如果有多个阈值,我们将同时获得多个事件侦听器。我们感兴趣的是回调函数,即使是它也是一个简单的设置:在if-else块中添加和删除事件侦听器。事件的回调函数只是更新输出中的 div。每当目标触发交叉更改并且未与根元素交叉时,我们都会将输出重置为零。

这提供了 Intersection Observer 和滚动事件的双重优势。考虑在页面中放置一个仅在实际可见的页面部分需要时才起作用的滚动动画库。库和滚动事件不会在整个页面中低效地激活。

浏览器之间的有趣差异

您可能想知道 Intersection Observer 有多少浏览器支持。实际上,相当多!

此浏览器支持数据来自 Caniuse,其中包含更多详细信息。数字表示浏览器从该版本及更高版本开始支持该功能。

桌面

ChromeFirefoxIEEdgeSafari
5855不支持1612.1

移动/平板电脑

Android ChromeAndroid FirefoxAndroidiOS Safari
12712712712.2-12.5

所有主要浏览器都已支持它一段时间了。正如您可能预料的那样,Internet Explorer 在任何级别都不支持它,但是有一个来自 W3C 的 可用 polyfill 可以解决这个问题。

在使用 Intersection Observer 尝试不同的想法时,我确实遇到了一些在 Firefox 和 Chrome 之间行为不同的示例。我不会在生产站点上使用这些示例,但其行为很有趣。

这是第一个示例

查看代码笔
Intersection Observer:动画变换
,由 Travis Almand (@talmand) 创建
CodePen 上。

目标元素通过 CSS 的 transform 属性在根元素内移动。演示有一个 CSS 动画,在水平轴上将目标元素变换进出根元素。当目标元素进入或离开根元素时,intersectionRatio 会更新。

如果您在 Firefox 中查看此演示,您应该会看到 intersectionRatio 在目标元素左右滑动时正确更新。Chrome 的行为有所不同。那里的默认行为根本不会更新 intersectionRatio 显示。似乎 Chrome 不会跟踪使用 CSS 变换的目标元素。但是,如果我们在浏览器中移动鼠标,当目标元素进出根元素时,intersectionRatio 显示确实会更新。我的猜测是 Chrome 仅在存在某种形式的用户交互时才会“激活”观察器。

这是第二个示例

查看代码笔
Intersection Observer:动画剪辑路径
,由 Travis Almand (@talmand) 创建
CodePen 上。

这次我们正在 为一个剪辑路径制作动画,该路径将正方形变形为一个圆形,并重复循环。正方形与根元素的大小相同,因此我们得到的 intersectionRatio 将始终小于 1。当 clip-path 制作动画时,Firefox 根本不会更新 intersectionRatio 显示。并且这次移动鼠标不起作用。Firefox 只是忽略了元素大小的变化。另一方面,Chrome 实际上实时更新了 intersectionRatio 显示。即使没有用户交互,也会发生这种情况。

这似乎是由于规范的一部分造成的,该部分 说明交集区域的边界(intersectionRect) 应包括裁剪目标元素。

如果容器具有溢出裁剪或 css clip-path 属性,则通过应用容器的剪辑来更新intersectionRect

因此,当目标被裁剪时,交集区域的边界将重新计算。Firefox 显然还没有实现这一点。

Intersection Observer 版本 2

那么这个 API 的未来会怎样呢?

来自 Google 的提案 将为观察器添加一个有趣的功能。即使 Intersection Observer 告诉我们目标元素何时穿过根元素的边界,但这并不一定意味着该元素实际上对用户可见。它可能具有零不透明度,或者可能被页面上的另一个元素覆盖。如果观察器可以用来确定这些事情呢?

请记住,此类功能仍处于早期阶段,不应在生产代码中使用。这是 更新的提案,其中突出显示了与规范第一个版本的差异。

如果您一直在使用 Chrome 查看本文中的演示,您可能已经在控制台中注意到了一些事情——例如 entries 对象属性,这些属性在 Firefox 中没有出现。这是一个 Firefox 在控制台中记录什么的示例

IntersectionObserver
  root: null
  rootMargin: "0px 0px 0px 0px"
  thresholds: Array [ 0 ]
  <prototype>: IntersectionObserverPrototype { }

IntersectionObserverEntry
  boundingClientRect: DOMRect { x: 9, y: 779, width: 707, ... }
  intersectionRatio: 0
  intersectionRect: DOMRect { x: 0, y: 0, width: 0, ... }
  isIntersecting: false
  rootBounds: null
  target: <div class="item">
  time: 261
  <prototype>: IntersectionObserverEntryPrototype { }

现在,这是来自 Chrome 中相同控制台代码的相同输出

IntersectionObserver
  delay: 500
  root: null
  rootMargin: "0px 0px 0px 0px"
  thresholds: [0]
  trackVisibility: true
  __proto__: IntersectionObserver

IntersectionObserverEntry
  boundingClientRect: DOMRectReadOnly {x: 9, y: 740, width: 914, height: 146, top: 740, ...}
  intersectionRatio: 0
  intersectionRect: DOMRectReadOnly {x: 0, y: 0, width: 0, height: 0, top: 0, ...}
  isIntersecting: false
  isVisible: false
  rootBounds: null
  target: div.item
  time: 355.6550000066636
  __proto__: IntersectionObserverEntry

一些属性的显示方式存在一些差异,例如 targetprototype,但它们在两个浏览器中的操作方式相同。不同之处在于 Chrome 有一些额外的属性在 Firefox 中没有出现。observer 对象有一个名为 trackVisibility 的布尔值和一个名为 delay 的数字,并且 entry 对象有一个名为 isVisible 的布尔值。这些是新提出的属性,试图确定目标元素是否实际上对用户可见。

我将简要解释这些属性,但如果您想了解更多详细信息,请阅读 这篇文章

trackVisibility 属性是在 options 对象中给观察器的布尔值。这通知浏览器承担更昂贵的任务来确定目标元素的真实可见性。

delay 属性正如您所料:它将交集更改回调延迟指定毫秒数。这有点类似于您的回调函数的代码被包装在 setTimeout 中。对于 trackVisibility 的工作,此值是必需的,并且必须至少为 100。如果没有给出适当的值,控制台将显示此错误,并且观察器将不会创建。

Uncaught DOMException: Failed to construct 'IntersectionObserver': To enable the 
'trackVisibility' option, you must also use a 'delay' option with a value of at
least 100. Visibility is more expensive to compute than the basic intersection;
enabling this option may negatively affect your page's performance.
Please make sure you really need visibility tracking before enabling the
'trackVisibility' option.

目标 entry 对象中的 isVisible 属性是报告可见性跟踪输出的布尔值。这可以用作任何代码的一部分,就像可以使用 isIntersecting 一样。

在我对这些功能的所有实验中,看到它真正起作用似乎是偶然的。例如,delay 一直有效,但 isVisible 并非总是报告 true——至少对我而言——当元素清晰可见时。有时这是设计使然,因为 规范确实允许误报。这将有助于解释不一致的结果。

我个人非常期待此功能更加完善并在所有支持 Intersection Observer 的浏览器中都能正常工作。

守望已结束

因此,我对 Intersection Observer 的研究也告一段落,这是一个我非常期待在未来项目中使用的 API。我花了相当多的夜晚进行研究、实验和构建示例,以了解它的工作原理。但结果是这篇文章,以及一些关于如何利用观察器不同功能的新想法。此外,在这一点上,我觉得在被问到时,我可以有效地解释观察器的工作原理。希望本文能帮助您做到这一点。