[译] Intersection Observer 实现动态 header

3,753 阅读9分钟

原文:www.smashingmagazine.com/2021/07/dyn…
作者:Michelle Barker
译者:wongtsuizhen

前言

你是有没有过这样的一个 UI 需求:页面上的某些组件需要根据其所在视口滚动到某个阈值,或者从所处视口进入或移出?在JavaScript中,可以通过监听滚动条事件进而在回调函数中来处理,但这样做会影响性能,如果使用不当,就可能导致页面卡顿。但现在有个更好的实现方式 — Intersection Observer(交叉观察器)来解决这个问题。

Intersection Observer API 是一个 JavaScript API,它可以让我们监听某一个元素,并检测它何时通过可滚动容器中的指定位置(通常(但不总是)在视口中),同时触发回调函数。

可以说Intersection Observer比在主线程上监听滚动事件性能更高,因为它是异步的,并且回调只会在我们监听的元素达到指定位置时才会启动,而不是每次滚动时就更新。在这篇文章中,我们将通过一个示例说明如何使用 Intersection Observer 构建一个固定的 header,当其与网页的不同部分相交时会发生变化。

基本使用

使用Intersection Observer,我们首先需要创建一个 observer,它有两个参数:callback是我们希望在监听的元素(称为目标元素)与根元素(视口,它必须是目标元素的祖先节点)相交时执行的回调函数; options为配置对象(参数可选)。

const options = {
  root: document.querySelector('[data-scroll-root]'),
  rootMargin: '0px',
  threshold: 1.0
}

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

const observer = new IntersectionObserver(callback, options)

当我们创建了观察者实例之后,我们还需要指示它去监视一个目标元素:

const targetEl = document.querySelector('[data-target]')

observer.observe(targetEl)

options 中的选项值都是可选的,他们有如下默认值:

const options = {
  rootMargin: '0px',
  threshold: 1.0
}

rootMargin

rootMargin值有点像向根元素添加 margin, 与 margin 一样,它可以接受多个值,包括负值。目标元素将被认为是相对于边界相交的。 image.png

具有正和负边距值的滚动根。假设默认阈值为 1,橙色方块将被定位在“相交”的点上

这意味着一个元素在技术上可以被归类为“相交”,即使它不在视图中(如果滚动根是视口);

image.png

橙色的正方形与根相交,即使它在可见区域之外。

rootMargin默认为0px,但可以接受由多个值组成的字符串,就像在 CSS 中使用margin属性一样。

threshold

threshold可以由单个值或 0 到 1 之间的值数组组成。它表示必须在根边界内才能被认为相交的比例用默认值 1,当根目录中 100%的目标元素可见时,回调将触发。

image.png

threshold 为 1、0 和 0.5 分别导致在目标元素 100%、0%和 50%可见时触发回调。

当使用这些选项将元素分类为可见时并不总是容易的。所以我已经创建了一个小工具来帮助大家掌握Intersection Observer

实现动态 header

既然我们现在已经掌握了基本原理,让我们开始实现一个动态 header。我们将从一个分成几个部分的网页开始。下面这张图片显示了我们将要创建的页面的完整布局:

image.png 我在本文的末尾展示了完整的一个 demo,所以如果您想要直接查看代码,可以直接跳转到demo。(Github 仓库)

每个部分的最小高度为 100vh(它们可能更长,取决于内容)。我们的标题是固定在页面的顶部,并在用户滚动时保持在原地(使用position: fixed)。各部分有不同颜色的背景,当它们遇到标题时,标题的颜色就会改变,以适配当前 section 的颜色。还有一个标记以显示用户所处的当前部分,当下一个部分到达时,它会滑动。如果你想跟着后面继续了解的话,我会通过讲解文章开头的小 demo(点击查看)(在开始使用 Intersection Observer API 之前),来让我们更快而且直观的了解它的代码实现。

页面制作

我们将从 header 的 HTML 开始。这将是一个相当简单的页面,包含标题、链接、导航,它没有什么特别的,但我们要用几个数据属性:data-header来标记 header 本身(我们可以目标元素 JS),三个用户点击就滚动到对应模块的锚用data-link标记:

<header data-header>
  <nav class="header__nav">
    <div class="header__left-content">
      <a href="#0">Home</a>
    </div>
    <ul class="header__list">
      <li>
        <a href="#about-us" data-link>About us</a>
      </li>
      <li>
        <a href="#flavours" data-link>The flavours</a>
      </li>
      <li>
        <a href="#get-in-touch" data-link>Get in touch</a>
      </li>
    </ul>
  </nav>
</header>

接下来是页面其余部分的 HTML,我们把它们也分成几个部分。为了简洁起见,我只包含了与本文相关的部分,但是 demo 中包含了完整的内容。每个 section 都包含一个 data 属性来指定背景颜色的名称,以及一个 id,该 id 对应于标题中 a 标签的href:

<main>
  <section data-section="raspberry" id="home">
    <!--Section content-->
  </section>
  <section data-section="mint" id="about-us">
    <!--Section content-->
  </section>
  <section data-section="vanilla" id="the-flavours">
    <!--Section content-->
  </section>
  <section data-section="chocolate" id="get-in-touch">
    <!--Section content-->
  </section>
</main>

我们将用 CSS 来定位 header,这样当用户滚动时,它就会固定在页面顶部:

header {
  position: fixed;
  width: 100%;
}

我们也会给我们每个 secion 设置一个最小的高度,并将内容居中。(这段代码并不是使用Intersection Observer所必需的,它只是样式的优化。)

section {
  padding: 5rem 0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

IFRAME 中的告警

在构建这个 Codepen 演示时,我遇到了一个令人困惑的问题,我的Intersection Observer代码本应完美地工作,但却未能在交集点正确触发回调,而是在目标元素与视口边缘相交时触发。经过一番思索之后,我意识到这是因为在 Codepen 中,内容是在 iframe 中加载的,它的处理方式是不同的。(详见 MDN 文档中关于剪切和交集矩形的部分

作为一个解决方案,在演示中,我们可以将标记包装在另一个元素中,它将充当滚动容器(Intersection Observeroptions中的根元素 root),而不是我们所期望的浏览器视区:

<div class="scroller" data-scroller>
  <header data-header>
    <!--Header content-->
  </header>
  <main>
    <!--Sections-->
  </main>
</div>

如果您想要了解如何使用视口作为根,而不是 demo 中的根,可访问Github 仓库

CSS 样式

在我们的 CSS 中,我们将为我们使用的颜色定义一些自定义属性。我们还将为标题文本和背景颜色定义两个额外的自定义属性,并设置一些初始值。(我们将在后面更新这两个自定义属性)

:root {
  --mint: #5ae8d5;
  --chocolate: #573e31;
  --raspberry: #f2308e;
  --vanilla: #faf2c8;
  
  --headerText: var(--vanilla);
  --headerBg: var(--raspberry);
}

我们将在 header 中使用这些自定义属性:

header {
  background-color: var(--headerBg);
  color: var(--headerText);
}

我们还将为不同的部分设置颜色。我使用数据属性作为选择器,您也可以简单地使用类。

[data-section="raspberry"] {
  background-color: var(--raspberry);
  color: var(--vanilla);
}

[data-section="mint"]  {
  background-color: var(--mint);
  color: var(--chocolate);
}

[data-section="vanilla"] {
  background-color: var(--vanilla);
  color: var(--chocolate);
}

[data-section="chocolate"] {
  background-color: var(--chocolate);
  color: var(--vanilla);
}

当每个 section 都在视图中时,我们也可以为 header 设置一些样式:

/* Header */
[data-theme="raspberry"]  {
  --headerText: var(--raspberry);
  --headerBg: var(--vanilla);
}

[data-theme="mint"] {
  --headerText: var(--mint);
  --headerBg: var(--chocolate);
}

[data-theme="chocolate"]  {
  --headerText: var(--chocolate);
  --headerBg: var(--vanilla);
}

这里更需要使用数据属性,因为我们要在每次相交时切换 header 的data-theme属性。

创建 Observer 

现在我们已经把页面基本的 HTML 和 CSS 都写好了,我们可以创建一个观察者来监视进入视图的每个部分。我们在当向下滚动页面,某个 section 与 header 底部接触时触发一个回调,这意味着我们需要设置一个负的rootMargin,以对应 header 的高度。

const header = document.querySelector('[data-header]')
const sections = [...document.querySelectorAll('[data-section]')]
const scrollRoot = document.querySelector('[data-scroller]')

const options = {
  root: scrollRoot,
  rootMargin: `${header.offsetHeight * -1}px`,
  threshold: 0
}

我们设置了一个阈值为 0,因为我们想让它在 section 的任何部分与根边界相交时触发。

首先,我们将创建一个回调来更改 header 的data-theme值(这比添加和删除类更简单,特别是当 header 元素可能应用了其他类时)。

/* The callback that will fire on intersection */
const onIntersect = (entries) => {
  entries.forEach((entry) => {
    const theme = entry.target.dataset.section
    header.setAttribute('data-theme', theme)
  })
}

然后我们将创建观察者来监听相交的部分:

/* Create the observer */
const observer = new IntersectionObserver(onIntersect, options)

/* Set our observer to observe each section */
sections.forEach((section) => {
  observer.observe(section)
})

现在我们应该看到我们的标题颜色更新时,每个部分满足标题。

点击查看 demo

然而,您可能会注意到,当我们向下滚动时,颜色没有正确地更新颜色。事实上,标题每次都在更新前一节的颜色。但向上滚动时,它工作得很完美。因此我们需要确定滚动方向并相应地改变它的行为。

寻找滚动方向

我们将在 JS 中设置一个变量,用于滚动的方向,初始值为up,另一个变量用于最后已知的滚动位置(prevYPosition)。然后,在回调函数中,如果滚动位置大于之前的值,我们可以设置方向值为down,反之为up

let direction = 'up'
let prevYPosition = 0

const setScrollDirection = () => {
  if (scrollRoot.scrollTop > prevYPosition) {
    direction = 'down'
  } else {
    direction = 'up'
  }

  prevYPosition = scrollRoot.scrollTop
}

const onIntersect = (entries, observer) => {
  entries.forEach((entry) => {
    setScrollDirection()
          
    /* ... */
  })
}

我们还将创建一个新函数来更新 header 颜色,将 target 部分作为参数传入:

const updateColors = (target) => {
  const theme = target.dataset.section
  header.setAttribute('data-theme', theme)
}

const onIntersect = (entries) => {
  entries.forEach((entry) => {
    setScrollDirection()
    updateColors(entry.target)
  })
}

到目前为止,我们应该没有看到 header 的行为发生变化。但是现在我们知道了滚动方向,我们可以为updateColors()函数传入一个不同的目标。如果滚动方向是向上,我们将使用入口目标。如果它是向下的,我们将使用下一 section(如果有)。

const getTargetSection = (target) => {
  if (direction === 'up') return target
  
  if (target.nextElementSibling) {
    return target.nextElementSibling
  } else {
    return target
  }
}

const onIntersect = (entries) => {
  entries.forEach((entry) => {
    setScrollDirection()
    
    const target = getTargetSection(entry.target)
    updateColors(target)
  })
}

但是,还有一个问题:header 不仅会在 section 到达 header 时更新,而且会在视图底部的下一个元素进入视图时更新。这是因为我们的观察者触发了两次回调:一次是在元素进入时,一次是在元素离开时。

为了确定 header 是否应该更新,我们可以使用entry对象中的isIntersecting键。让我们创建另一个函数来返回一个布尔值,用于判断 header 颜色是否应该更新:

const shouldUpdate = (entry) => {
  if (direction === 'down' && !entry.isIntersecting) {
    return true
  }
  
  if (direction === 'up' && entry.isIntersecting) {
    return true
  }
  
  return false
}

我们将相应地更新onIntersect()函数:

const onIntersect = (entries) => {
  entries.forEach((entry) => {
    setScrollDirection()
    
    /* Do nothing if no need to update */
    if (!shouldUpdate(entry)) return
    
    const target = getTargetSection(entry.target)
    updateColors(target)
  })
}

现在我们的颜色应该正确更新了。我们可以设置一个 CSS 过渡动画,这样效果会更好一些:

header {
  transition: background-color 200ms, color 200ms;
}

(点击查看 demo)

添加动态标记

接下来,我们将在标题中添加一个标记,当我们滚动到不同的部分时,它会更新它的位置。我们可以使用伪元素来实现,这样我们就不需要在 HTML 中添加任何东西。我们将给它一些简单的 CSS 样式,将它定位在 header 的左上角,并给它一个背景颜色。我们在这里使用currentColor,因为它将接受标题文本颜色的值:

header::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  height: 0.4rem;
  background-color: currentColor;
}

我们可以为宽度使用自定义属性,默认值为 0。我们还将为transform x值使用自定义属性。当用户滚动时,我们会在回调函数中设置这些值。

header::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  height: 0.4rem;
  width: var(--markerWidth, 0);
  background-color: currentColor;
  transform: translate3d(var(--markerLeft, 0), 0, 0);
}

现在我们可以编写一个函数来更新标记在交点的宽度和位置:

const updateMarker = (target) => {
  const id = target.id
  
  /* Do nothing if no target ID */
  if (!id) return
  
  /* Find the corresponding nav link, or use the first one */
  let link = headerLinks.find((el) => {
    return el.getAttribute('href') === `#${id}`
  })
  
  link = link || headerLinks[0]
  
  /* Get the values and set the custom properties */
  const distanceFromLeft = link.getBoundingClientRect().left
  
  header.style.setProperty('--markerWidth', `${link.clientWidth}px`)
  header.style.setProperty('--markerLeft', `${distanceFromLeft}px`)
}

我们可以在更新颜色的同时调用这个函数:

const onIntersect = (entries) => {
  entries.forEach((entry) => {
    setScrollDirection()
    
    if (!shouldUpdate(entry)) return
    
    const target = getTargetSection(entry.target)
    updateColors(target)
    updateMarker(target)
  })
}

我们还需要为标记设置一个初始位置,这样它就不会突然出现。当文档被加载时,我们将调用updateMarker()函数,使用第一部分作为目标:

document.addEventListener('readystatechange', e => {
  if (e.target.readyState === 'complete') {
    updateMarker(sections[0])
  }
})

最后,让我们添加一个 CSS 过渡,以便标记从一个链接滑过标题到下一个链接。当我们转换宽度属性时,我们可以使用 will-change 来让浏览器执行优化

header::after {
  transition: transform 250ms, width 200ms, background-color 200ms;
  will-change: width;
}

平滑滚动

最后,如果当用户点击链接时,他们能平稳地向下滚动页面,而不是跳转到该部分,那就再好不过了。目前我们直接通过 CSS 来做,不需要 JS!为了获得更好的体验,那么最好尊重用户的设置偏好,如果用户在系统设置中没有开启动画减弱功能,才使用平滑滚动:

@media (prefers-reduced-motion: no-preference) {
  .scroller {
    scroll-behavior: smooth;
  }
}

完整 demo

将上述所有步骤放在一起,就得到了完整的 demo。 完整 demo 查看

浏览器支持

Intersection Observer在现代浏览器中得到广泛支持(浏览器支持)。在需要的地方,它可以为旧的浏览器填充——但我更喜欢采取渐进的增强方法。在我们的 header 例子中,为不支持的浏览器提供一个简单的、不变的版本不会对用户体验造成很大的损害。

要检测是否支持Intersection Observer,我们可以使用以下方法:

if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
  /* Code to execute if IO is supported */
} else {
  /* Code to execute if not supported */
}

资源

阅读更多关于Intersection Observer API的文档: