使用 Intersection Observer 构建动态标题

已发表: 2022-03-10
快速总结↬您是否曾经需要构建一个 UI,其中页面上的某些组件需要在元素滚动到视口内的某个阈值时做出响应——或者可能是进出视口本身? 在 JavaScript 中,附加一个事件侦听器以不断触发滚动回调可能会占用大量性能,如果使用不当,可能会导致用户体验迟缓。 但是有一个更好的方法是使用 Intersection Observer。

Intersection Observer API 是一个 JavaScript API,它使我们能够观察元素并检测它何时通过滚动容器中的指定点——通常(但不总是)视口——触发回调函数。

Intersection Observer 可以被认为比在主线程上监听滚动事件的性能更高,因为它是异步的,并且回调只会在我们正在观察的元素达到指定阈值时触发,而不是在每次更新滚动位置时触发。 在本文中,我们将通过一个示例演示如何使用 Intersection Observer 构建一个固定的标题组件,该组件在与网页的不同部分相交时会发生变化。

基本用法

要使用 Intersection Observer,我们首先需要创建一个新的观察者,它接受两个参数:一个带有观察者选项的对象,以及当我们正在观察的元素(称为观察者目标)相交时我们想要执行的回调函数与根(滚动容器,它必须是目标元素的祖先)。

 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)

任何选项值都可以省略,因为它们将回退到它们的默认值:

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

如果没有指定根,那么它将被归类为浏览器视口。 上面的代码示例显示了rootMarginthreshold的默认值。 这些可能很难想象,因此值得解释:

rootMargin

rootMargin值有点像向根元素添加 CSS 边距——并且,就像边距一样,可以采用多个值,包括负值。 目标元素将被视为相对于边距相交。

具有正负根边距值的滚动根。 假设默认阈值为 1,橙色方块位于将其归类为“相交”的点。(大预览)

这意味着一个元素在技术上可以归类为“相交”,即使它不在视野范围内(如果我们的滚动根是视口)。

橙色方块与根相交,即使它位于可见区域之外。 (大预览)

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

threshold

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

阈值 1、0 和 0.5 分别导致在 100%、0% 和 50% 可见时触发回调。 (大预览)

使用这些选项将元素分类为可见时并不总是很容易可视化。 我构建了一个小工具来帮助掌握 Intersection Observer。

跳跃后更多! 继续往下看↓

创建标题

现在我们已经掌握了基本原理,让我们开始构建我们的动态标题。 我们将从一个分为多个部分的网页开始。 此图显示了我们将要构建的页面的完整布局:

(大预览)

我在本文末尾包含了一个演示,所以如果您热衷于取消选择代码,请随时直接跳到它。 (还有一个 Github 存储库。)

每个部分的最小高度为100vh (尽管它们可能会更长,具体取决于内容)。 我们的标题固定在页面顶部,并在用户滚动时保持原位(使用position: fixed )。 这些部分有不同的颜色背景,当它们遇到标题时,标题的颜色会发生变化以补充部分的颜色。 还有一个标记来显示用户所在的当前部分,当下一个部分到达时它会滑动。 为了让我们更容易直接获得相关代码,我已经设置了一个带有我们起点的最小演示(在我们开始使用 Intersection Observer API 之前),以防您想跟随。

标记

我们将从标题的 HTML 开始。 这将是一个带有主页链接和导航的相当简单的标题,没有什么特别花哨的,但我们将使用几个数据属性:标题本身的data-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,它分为几个部分。 为简洁起见,我只包含了与文章相关的部分,但完整的标记包含在演示中。 每个部分都包含一个指定背景颜色名称的数据属性,以及一个与标题中的锚链接之一相对应的id

 <main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>

我们将使用 CSS 定位我们的标题,以便它在用户滚动时保持固定在页面顶部:

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

我们还将为我们的部分设置一个最小高度,并使内容居中。 (此代码不是 Intersection Observer 工作所必需的,它只是用于设计。)

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

iframe 警告

在构建此 Codepen 演示时,我遇到了一个令人困惑的问题,我的 Intersection Observer 代码本完美运行,但未能在交叉点的正确点触发回调,而是在目标元素与视口边缘相交时触发。 经过一番摸索后,我意识到这是因为在 Codepen 中,内容是在 iframe 中加载的,它的处理方式不同。 (有关完整的详细信息,请参阅 MDN 文档中关于裁剪和相交矩形的部分。)

作为一种解决方法,在演示中,我们可以将标记包装在另一个元素中,该元素将充当滚动容器——我们 IO 选项中的根——而不是我们可能期望的浏览器视口:

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

如果您想了解如何使用视口作为根来代替同一个演示,这包含在 Github 存储库中。

CSS

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

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

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

 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); }

当每个部分都在视图中时,我们还可以为我们的标题设置一些样式:

 /* 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); }

在这里使用数据属性有一个更强有力的例子,因为我们将在每个交叉点上切换标题的data-theme属性。

创建观察者

现在我们已经为我们的页面设置了基本的 HTML 和 CSS,我们可以创建一个观察者来观察我们进入视图的每个部分。 当我们向下滚动页面时,我们希望在某个部分与标题底部接触时触发回调。 这意味着我们需要设置一个与标题高度相对应的负根边距。

 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 ,因为我们希望它在该部分的任何部分与根边距相交时触发。

首先,我们将创建一个回调来更改标头的data-theme值。 (这比添加和删除类更直接,尤其是当我们的标题元素可能应用了其他类时。)

 /* 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) })

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

请参阅 Michelle Barker 的 Pen [Happy Face Ice Cream Parlor – Step 2](https://codepen.io/smashingmag/pen/poPgpjZ)。

参见 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Step 2。

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

寻找滚动方向

我们将在我们的 JS 中为滚动方向设置一个变量,初始值为'up' ,另一个用于最后已知的滚动位置( prevYPosition )。 然后,在回调中,如果滚动位置大于之前的值,我们可以将direction值设置为'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() /* ... */ }) }

我们还将创建一个新函数来更新标题颜色,将目标部分作为参数传递:

 const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }

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

 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) }) }

然而,还有一个问题:标题不仅会在该部分命中标题时更新,而且会在下一个元素进入视口底部的视图时更新。 这是因为我们的观察者触发了两次回调:一次是在元素进入时,另一次是在元素离开时。

要确定标头是否应该更新,我们可以使用entry对象中的isIntersecting键。 让我们创建另一个函数来返回一个布尔值,以指示标题颜色是否应该更新:

 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; } 

请参阅 Michelle Barker 的钢笔 [Happy Face Ice Cream Parlor – Step 3](https://codepen.io/smashingmag/pen/bGWEaEa)。

请参阅 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Step 3。

添加动态标记

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

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

我们可以为宽度使用自定义属性,默认值为 0。我们还将为平移 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 过渡,以便标记从一个链接滑过标题到下一个链接。 当我们转换width属性时,我们可以使用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; } }

最终演示

将上述所有步骤放在一起就可以得到完整的演示。

请参阅 Michelle Barker 的 Pen [Happy Face Ice Cream Parlor – Intersection Observer 示例](https://codepen.io/smashingmag/pen/XWRXVXQ)。

请参阅 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Intersection Observer 示例。

浏览器支持

现代浏览器广泛支持 Intersection Observer。 如有必要,它可以为旧版浏览器填充——但我更喜欢在可能的情况下采用渐进增强的方法。 就我们的标头而言,为不支持的浏览器提供一个简单、不变的版本不会对用户体验造成太大损害。

要检测是否支持 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 的信息:

  • 广泛的文档,以及来自 MDN 的一些实际示例
  • Intersection Observer 可视化工具
  • Timing Element Visibility with the Intersection Observer API – MDN 的另一个教程,介绍如何使用 IO 跟踪广告可见性
  • Denys Mishunov 的这篇文章介绍了 IO 的其他一些用途,包括延迟加载资产。 尽管现在已经不太需要了(感谢loading属性),但这里仍有很多东西需要学习。