Real use case of intersection observer: Table of Contents

Intersection Observer 实战案例:自动更新目录

前言

最近在开发内容大纲(Table of Contents)相关功能时,发现这是一个很适合使用 Intersection Observer 的场景,可以通过监听特定元素来自动更新目录状态。

早期可能需要监听网页滚动事件,并通过 getBoundingClientRect 方法来计算元素是否离开视口,但随着专门检测元素是否进入视口的 API 出现,这样的需求已经更容易实现。

如果你对新旧实现方式的比较感兴趣,可以参考这篇文章:使用 Intersection Observer 代替滚动事件 - Jonathan Lau🔗,其中有详细的对比介绍。

特征Intersection ObserverScroll 事件监听 + getBoundingClientRect
主线程在主线程之外执行,不会阻塞其他任务在主线程上执行,会阻塞其他任务
性能开销高效跟踪元素是否可见,仅在必要时执行每次滚动时检查元素位置,可能消耗更多资源
易用性专门用于检查元素是否进入视口,接口简单且功能强大通过更底层的方式获取单个元素在页面中的信息,计算可能更复杂繁琐

这也是为什么我更倾向于从已经推出一段时间、相对成熟的 Intersection Observer 入手实现,不过后续也遇到了一些意料之外的问题。

问题

实现一个根据特定元素内的标题生成目录列表的功能,并且列表会根据滚动位置适当地显示激活样式:

  • 自动根据当前文章内容生成对应的目录链接列表,并显示在侧栏
  • 当用户滚动到对应区块时,自动更新目录的激活状态
  • 当用户点击目录链接时,滚动到对应位置
<main>
<div>
<h1>目录演示</h1>
<div>
<section>
<h2>标题一</h2>
<p>...</p>
</section>
<section>
<h2>标题二</h2>
<p>...</p>
</section>
<section>
<h2>标题三</h2>
<p>...</p>
</section>
<section>
<h2>标题四</h2>
<p>...</p>
</section>
</div>
</div>
<aside>
<ul></ul>
</aside>
</main>

解决思路

自动更新目录是一种非常常见的 UI,每个人多少都用过,所以通常不会特别详细地描述需求,但实际深入研究后会发现,这个问题有非常多的细节值得探讨,例如:

  • 如何处理多层嵌套标题区块之间的关系?
  • 激活项只能有一个吗?如果只能有一个,但多个区块都处于激活状态,该如何选出最相关的区块?
  • 滚动到对应区块的定义是什么?离开区块的定义又是什么?(整个区块还是一半区块离开或进入视口?)
  • 点击链接后如何滚动到对应位置?对应位置又是什么?

以上意料之外的问题会直接影响用户体验,出现程序运行没有错误,但体验不佳的情况,例如:点击目录链接后,滚动到非预期位置,或目录的激活状态不符合使用预期。由于目录是动态生成的,也导致边界情况很难被发现,因此最好先讨论目录的预期行为,再动手实现。我先将需求定义为:

  • 只需检测一层标题区块
  • 激活项只能有一个
  • 以最先激活的标题为准(滚动到 區塊 1,接着滚动到 區塊 2,但仍处于 區塊 1 當中,由于 區塊 1 先激活,因此仍显示 區塊 1 为激活项)
  • 滚动到对应区块的判断依据是区块完全进入和离开视口
  • 点击链接后,滚动到区块顶部与视口顶部之间留有 1rem 空白的位置

初步需求如上。不过,随着实现过程的推进,还会发现一些棘手的边界情况,到时候再处理。

第一步:生成目录

首先,可以给目录相关元素添加标记(这里我使用 data 属性),方便后续 JS 用 querySelector 找到对应元素并进行操作。

<main>
<div>
<h1>目录演示</h1>
<div>
<section data-toc-section>
<h2>标题一</h2>
<p>...</p>
</section>
<!-- ... -->
</div>
</div>
<aside>
<ul data-toc></ul>
</aside>
</main>

接下来创建 generateToc 函数,接收要插入目录的目标 toc 元素,以及需要检测的区块。

function generateToc(tocElement: HTMLUListElement, sectionElements: NodeListOf<HTMLElement>) {
sectionElements.forEach(function (section, index) {
// 为每个区块添加对应的 id
const sectionId = 'toc' + (index + 1);
section.setAttribute('id', sectionId);
// 根据现有区块创建「目录链接元素」
const tocLink = document.createElement('a');
tocLink.setAttribute('href', '#' + sectionId);
tocLink.setAttribute('data-id', sectionId);
const sectionTitle = section.querySelector('h2');
if (sectionTitle) {
tocLink.textContent = sectionTitle.textContent;
}
// 创建「目录项元素」
const tocItem = document.createElement('li');
tocItem.appendChild(tocLink);
// 将「目录元素」插入「toc 元素」
tocElement.appendChild(tocItem);
});
}
const tocElement = document.querySelector<HTMLUListElement>('[data-toc]');
const targetSectionElements = document.querySelectorAll<HTMLElement>('[data-toc-section]');
if (tocElement) {
generateToc(tocElement, targetSectionElements);
}

这样就实现了自动检测当前文章内容并生成对应目录的功能,同时利用 <a> 标签配合 # 跳转的特性,可以在点击链接时通过 target 跳转到特定的 id。

第二步:监听区块进入与离开视口,并适当地激活目录项

先简单定义一个 activeTocs,用来记录当前激活的区块:

let activeTocs: string[] = [];

生成 TOC 后,就可以为每个区块添加 Intersection Observer,initIntersectionObserverForToc 通过监听区块进入与离开视口的事件来更新目录的激活状态。

/**
* 检测区块进入或离开视口
* 1. 在视口内,将区块 id 添加到 activeTocIds
* 2. 在视口外,将区块 id 从 activeTocIds 移除
*/
function initIntersectionObserverForToc(sectionElements: NodeListOf<HTMLElement>) {
sectionElements.forEach((section) =>
new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const targetSectionId = entry.target.getAttribute('id');
if (!targetSectionId) return;
if (entry.isIntersecting) {
activeTocIds.push(targetSectionId);
} else {
const targetSectionIdIndex = activeTocIds.indexOf(targetSectionId);
const isTargetSectionId = targetSectionIdIndex !== -1;
if (isTargetSectionId) {
activeTocIds.splice(targetSectionIdIndex, 1);
}
}
updateActiveTocItem();
});
}).observe(section),
);
}
/**
* 更新目录 DOM 的激活状态
*/
function updateActiveTocItem() {
document.querySelectorAll('[data-toc] a').forEach((tocItem) => {
tocItem.classList.remove('active');
});
if (activeTocIds.length > 0) {
const firstActiveId = activeTocIds[0];
document.querySelectorAll('[data-toc] a').forEach((tocItem) => {
if (tocItem.getAttribute('href') === `#${firstActiveId}`) {
tocItem.classList.add('active');
}
});
}
}
const tocElement = document.querySelector<HTMLUListElement>('[data-toc]');
const targetSectionElements = document.querySelectorAll<HTMLElement>('[data-toc-section]');
if (tocElement) {
generateToc(tocElement, targetSectionElements);
initIntersectionObserverForToc(targetSectionElements);
}

第三步:更精细地检测激活区块

目前的逻辑是:当多个区块被激活时,优先显示最先被激活的区块,毕竟只能显示一个激活状态。

可能会遇到这样的情况:先滚动进入 A,再进入 B,但滚动了一段距离后,B 仍在视口中,因此一直占据激活状态。虽然按现有逻辑这样显示是正确的,但激活的可能是相关性较低的目录项。如果要选出最相关的区块来激活,就需要计算区块在视口中的可见比例。可以通过 threshold🔗 设置在达到特定可见比例时触发 Observer,简单来说,就是一个取值范围为 0 ~ 1 的数组,当达到特定比例时就会触发 observer 的回调函数:

接下来添加这项配置,并创建新函数 updateActiveTocWithRatio 来更新目录的激活状态。

const targetSectionId = entry.target.getAttribute('id');
new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (isSectionVisible) {
// 将每个区块的可见比例更新到 activeTocIds
updateActiveTocWithRatio(targetSectionId, entry.intersectionRatio);
} else {
// ...
}
});
},
{
threshold: [0, 0.1, 0.3, 0.5, 0.7, 0.9, 1], // 设置 0% 到 100% 的 threshold
},
).observe(section);

现在,每次区块进入视口并达到特定的 threshold 时,就会触发以下函数。我们可以判断它是否已经存在于 activeTocIds 中:如果存在,就更新比例;如果不存在,就新增一条区块记录,并让可见比例更高的区块优先显示激活状态。

function updateActiveTocWithRatio(sectionId, ratio) {
const existingItemIndex = activeTocIds.findIndex((item) => item.id === sectionId);
// 1. 已有区块记录 = 更新该区块的比例
// 2. 没有区块记录 = 新增该区块记录
if (existingItemIndex === -1) {
activeTocIds.push({ id: sectionId, ratio });
} else {
activeTocIds[existingItemIndex].ratio = ratio;
}
// 将比例最高的区块放到最前面
activeTocIds.sort((a, b) => b.ratio - a.ratio);
updateActiveTocItem();
}

样式:添加区块跳转动画,并预留与视口边缘的间距

跳转动画可以简单地通过 CSS scroll-behavior 属性实现,也可以额外检测用户是否启用了减少动态效果的偏好设置,以便适当地关闭跳转动画。

html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}

虽然通过 # 跳转可以让页面自动跳到对应区块,但这样会让区块顶部与视口顶部紧贴。要调整跳转位置,可以考虑使用 CSS scroll-margin-top🔗 属性,让滚动目标与视口顶部保持一定距离。

.section {
scroll-margin-top: 1rem;
}

需要特别留意,如果网页有采用 fixed 布局的导航栏,区块可能会被挡住。这时就需要预留更多空间,避免跳转后区块被导航栏遮盖。

通常我会通过 CSS 变量记录页面顶部 fixed 元素的高度(通常是导航栏)。

:root {
--nav-height: 80px;
}
.section {
scroll-margin-top: calc(var(--nav-height) + 1rem);
}

同时也要留意不同屏幕尺寸下,跳转目标所需留白的变化(例如,不同尺寸下导航栏高度发生变化,就需要为各个标题调整留白)。如果导航栏有折叠和展开状态,甚至需要编写 JS 来修改各区块的 scroll-margin-top 值。

调试:观察数据变化

function createDebugDisplay() {
const debugDisplay = document.createElement('div');
debugDisplay.id = 'debug-display';
debugDisplay.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
border-radius: 5px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
`;
document.body.insertBefore(debugDisplay, document.body.firstChild);
return debugDisplay;
}
function updateDebugDisplay() {
const debugDisplay = document.getElementById('debug-display') || createDebugDisplay();
debugDisplay.innerHTML =
'<h3>激活的目录项:</h3>' +
activeTocs.map((activeToc) => `<div>ID: ${activeToc.id},比例:${activeToc.ratio}</div>`).join('');
}

查看示例 基础版 - Intersection Observer 实际应用:目录,作者 Riceball (@riecball) ,发布于 CodePen。

验收

区块过大时会导致更新失效,仍然显示旧的激活项

一种极端情况特别容易发生在进入或离开超出视口大小的区块时:如果区块内容超出视口,就可能无法触发 Observer 更新。原因可以参考这段讨论:应如何处理尺寸大于视口的目标元素🔗。

一直以来,我对 Intersection Observer 的理解以及看过的示例,都是一些简单的小元素出现在视口中,可以设置 threshold,在元素显示达到一定比例后触发回调函数,但却没有想过如果元素大于视口会发生什么:滚动到中途,可见比例可能就无法继续增加了?毕竟视口的尺寸是有限的。

模拟后会发现,当区块内容超出视口时,该区块的 Observer 检测只会在进入和离开时触发,中间过程不会触发回调,于是就可能出现这样的情况:「大于视口的区块之前的那个区块」在最后一次触发更新时,比例恰好高于「大于视口的区块」,导致整个区块的激活状态卡在上一个结果,直到离开时才能更新 😅。

大于视口的区块只会在进入和离开时触发,触发时机不同,可能被其他区块离开时的 ratio 覆盖
大于视口的区块只会在进入和离开时触发,触发时机不同,可能被其他区块离开时的 ratio 覆盖

由于「超出视口的区块无法得出所需的比例,只能在进入和离开时触发」是 Intersection Observer 的预期行为,折腾了一大圈才发现,还是得改用更灵活的老方法:在每次滚动时获取区块元素的信息,计算区块已显示的比例:

if (tocElement) {
generateToc(tocElement, targetSectionElements);
window.addEventListener('scroll', updateVisibleSections);
window.addEventListener('resize', updateVisibleSections);
updateVisibleSections();
}
function updateVisibleSections() {
const windowHeight = window.innerHeight;
targetSectionElements.forEach((section) => {
const rect = section.getBoundingClientRect();
const sectionId = section.getAttribute('id');
if (!sectionId) return;
// 视口内可见高度 = 区块底部相对视口顶部的距离(不超过视口高度)- 区块顶部相对视口顶部的距离(不小于 0)
const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0);
// 视口内可见高度 / 元素高度 = 元素的可见比例
const elementInViewportRatio = visibleHeight / rect.height;
if (elementInViewportRatio > 0) {
updateActiveTocWithRatio(sectionId, elementInViewportRatio);
} else {
const targetSectionIdIndex = activeTocs.findIndex((toc) => toc.id === sectionId);
if (targetSectionIdIndex !== -1) {
activeTocs.splice(targetSectionIdIndex, 1);
}
}
});
updateActiveTocItem();
}

最后,可以考虑为事件处理添加节流,并记得移除事件监听器,这样就可以避免不必要的计算,提高性能。

思考:区块的显示比例不代表它是最相关的区块

各区块根据自身大小计算出的比例并不等价
各区块根据自身大小计算出的比例并不等价

Intersection Observer 的 threshold 只在元素显示达到特定比例时触发,那么各区块之间的比例等价吗?例如,如果 A 区块高 10px,经过它的 10% 只需要滚动 1px;但如果 B 区块高 1000px,经过它的 10% 则需要滚动 100px。

也就是说,A 和 B 区块需要在视口中显示不同的高度才会被激活(1px、100px),但用户应该是根据元素占据视口的比例来判断相关性的。例如,视口高度为 1000px,那么显示高度超过视口 10% 的区块才算相关,也就是 100px。在这种情况下,A 就被过早激活了。

那么,我们应该完全以元素占据视口的比例作为激活依据吗?显然也不行!因为有些元素本身就非常小,按这个比例计算,它们永远不会被激活。最终,我采用了以下计算方式:

  1. 进入视口时,按元素占据视口的比例激活
  2. 完全进入视口后,为该元素设置最高优先级的激活状态
  3. 优先激活最后新增的最高优先级状态
  4. 离开视口时,取消最高优先级的激活状态,并按元素占据视口的比例激活

其实,只需要在 updateVisibleSections 中增加一点逻辑即可:

function updateVisibleSections() {
const windowHeight = window.innerHeight;
targetSectionElements.forEach((section) => {
const rect = section.getBoundingClientRect();
const sectionId = section.getAttribute('id');
if (!sectionId) return;
const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0);
const elementInViewportRatio = visibleHeight / windowHeight;
const elementDisplayedRatio = visibleHeight / rect.height;
if (elementInViewportRatio > 0) {
if (elementDisplayedRatio === 1) {
updateActiveTocWithRatio(sectionId, 1);
} else {
updateActiveTocWithRatio(sectionId, elementInViewportRatio);
}
} else {
const targetSectionIdIndex = activeTocs.findIndex((toc) => toc.id === sectionId);
if (targetSectionIdIndex !== -1) {
activeTocs.splice(targetSectionIdIndex, 1);
}
}
});
updateActiveTocItem();
}

要优先激活最后新增的最高优先级状态,可以记录一个触发更新的时间戳,并调整比例排序逻辑:当比例相同时,根据注册时间的先后来决定激活状态。

function updateActiveTocWithRatio(sectionId: string, ratio: number) {
const existingItemIndex = activeTocs.findIndex((item) => item.id === sectionId);
if (existingItemIndex === -1) {
activeTocs.push({ id: sectionId, ratio, timeStamp: Date.now() });
} else {
activeTocs[existingItemIndex].ratio = ratio;
}
activeTocs.sort((a, b) => {
if (a.ratio === b.ratio) {
return b.timeStamp - a.timeStamp;
}
return b.ratio - a.ratio;
});
updateActiveTocItem();
}

思考:点击目录项并滚动到对应位置后,激活的未必是对应区块

目前,激活状态根据区块在视口中的比例决定,比例相同时才根据注册时间决定。但这样仍可能出现:点击并滚动到目标位置后,触发了新的激活状态,让用户感到困惑——明明点击了 A,滚动后却激活了后面的 C。 我的做法是额外设置一个变量:forceActiveTocId,并在生成时

// 初始化 TocLink 时绑定点击事件,确保点击时注册 `forceActiveTocId`
tocLink.addEventListener('click', (e) => {
forceActiveTocId = sectionId;
const activeToc = activeTocs.find((item) => item.id === sectionId);
if (activeToc) {
activeToc.timeStamp = Date.now();
}
activeTocs = reorderActiveToc(activeTocs);
updateActiveTocItem();
updateDebugDisplay();
});
// 提取独立函数,专门处理 TOC 排序
function reorderActiveToc(activeTocs: activeToc[]) {
return [...activeTocs].sort((a, b) => {
// 如果 a 是强制激活项,将其排在前面
if (a.id === forceActiveTocId) return -1;
if (a.ratio === b.ratio) {
return b.timeStamp - a.timeStamp;
}
return b.ratio - a.ratio;
});
}

查看示例 最终版 - Intersection Observer 实际应用:目录,作者 Riceball (@riecball) ,发布于 CodePen。

总结

总的来说,一个良好的 TOC 应该能够:

  1. 点击链接后滚动到合适的位置
  2. 激活样式能够反映当前滚动位置

如果你的 TOC 设计要求只有一个激活项,那么很可能会遇到一些技术上难以克服的体验问题,需要定义更多判断规则来完善体验。例如:滚动到最后一个区块时已经到达页面底部,无法继续滚动,因此底部区块永远无法被激活(这又回到了那个问题:如果有多个激活项,哪个才是与用户最相关的区块?)

其实,我认为做到这个程度已经算是一个不错的 TOC 了,后续可以根据自己的需求修改,并添加自己的规则。