原文: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 一样,它可以接受多个值,包括负值。目标元素将被认为是相对于边界相交的。
具有正和负边距值的滚动根。假设默认阈值为 1,橙色方块将被定位在“相交”的点上
这意味着一个元素在技术上可以被归类为“相交”,即使它不在视图中(如果滚动根是视口);
橙色的正方形与根相交,即使它在可见区域之外。
rootMargin
默认为0px
,但可以接受由多个值组成的字符串,就像在 CSS 中使用margin
属性一样。
threshold
threshold
可以由单个值或 0 到 1 之间的值数组组成。它表示必须在根边界内才能被认为相交的比例用默认值 1,当根目录中 100%的目标元素可见时,回调将触发。
threshold 为 1、0 和 0.5 分别导致在目标元素 100%、0%和 50%可见时触发回调。
当使用这些选项将元素分类为可见时并不总是容易的。所以我已经创建了一个小工具来帮助大家掌握Intersection Observer
。
实现动态 header
既然我们现在已经掌握了基本原理,让我们开始实现一个动态 header。我们将从一个分成几个部分的网页开始。下面这张图片显示了我们将要创建的页面的完整布局:
我在本文的末尾展示了完整的一个 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)
})
现在我们应该看到我们的标题颜色更新时,每个部分满足标题。
然而,您可能会注意到,当我们向下滚动时,颜色没有正确地更新颜色。事实上,标题每次都在更新前一节的颜色。但向上滚动时,它工作得很完美。因此我们需要确定滚动方向并相应地改变它的行为。
寻找滚动方向
我们将在 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;
}
添加动态标记
接下来,我们将在标题中添加一个标记,当我们滚动到不同的部分时,它会更新它的位置。我们可以使用伪元素来实现,这样我们就不需要在 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
的文档:
- MDN API
Intersection Observer
可视化工具- Timing Element Visibility with the Intersection Observer API MDN 的另一个教程,看看 IO 如何用来跟踪广告的可见性
- Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver - Denys Mishunov 撰写的这篇文章涵盖了 IO 的其他一些用途,包括资源懒加载。虽然现在没有那么必要了(多亏了 loading 属性),但这里仍然有很多东西需要学习。