使用 Intersection Observer 构建动态标题
已发表: 2022-03-10Intersection 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 }
如果没有指定根,那么它将被归类为浏览器视口。 上面的代码示例显示了rootMargin
和threshold
的默认值。 这些可能很难想象,因此值得解释:
rootMargin
rootMargin
值有点像向根元素添加 CSS 边距——并且,就像边距一样,可以采用多个值,包括负值。 目标元素将被视为相对于边距相交。
这意味着一个元素在技术上可以归类为“相交”,即使它不在视野范围内(如果我们的滚动根是视口)。
rootMargin
默认为0px
,但可以采用由多个值组成的字符串,就像在 CSS 中使用margin
属性一样。
threshold
threshold
可以由单个值或 0 到 1 之间的值数组组成。它表示必须在根边界内才能被视为相交的元素的比例。 使用默认值 1,回调将在 100% 的目标元素在根中可见时触发。
使用这些选项将元素分类为可见时并不总是很容易可视化。 我构建了一个小工具来帮助掌握 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) })
现在,当每个部分遇到标题时,我们应该看到标题颜色更新。
但是,您可能会注意到当我们向下滚动时颜色没有正确更新。 事实上,标题每次都更新为上一节的颜色! 另一方面,向上滚动,它工作得很好。 我们需要确定滚动方向并相应地改变行为。
寻找滚动方向
我们将在我们的 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; }
添加动态标记
接下来,我们将在标题中添加一个标记,当我们滚动到不同的部分时更新它的位置。 我们可以为此使用伪元素,因此我们不需要在 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; } }
最终演示
将上述所有步骤放在一起就可以得到完整的演示。
浏览器支持
现代浏览器广泛支持 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
属性),但这里仍有很多东西需要学习。