前言
最近在开发内容大纲(Table of Contents)相关功能时,发现这是一个很适合使用 Intersection Observer 的场景,可以通过监听特定元素来自动更新目录状态。
早期可能需要监听网页滚动事件,并通过 getBoundingClientRect 方法来计算元素是否离开视口,但随着专门检测元素是否进入视口的 API 出现,这样的需求已经更容易实现。
如果你对新旧实现方式的比较感兴趣,可以参考这篇文章:使用 Intersection Observer 代替滚动事件 - Jonathan Lau,其中有详细的对比介绍。
| 特征 | Intersection Observer | Scroll 事件监听 + 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 检测只会在进入和离开时触发,中间过程不会触发回调,于是就可能出现这样的情况:「大于视口的区块之前的那个区块」在最后一次触发更新时,比例恰好高于「大于视口的区块」,导致整个区块的激活状态卡在上一个结果,直到离开时才能更新 😅。
由于「超出视口的区块无法得出所需的比例,只能在进入和离开时触发」是 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 就被过早激活了。
那么,我们应该完全以元素占据视口的比例作为激活依据吗?显然也不行!因为有些元素本身就非常小,按这个比例计算,它们永远不会被激活。最终,我采用了以下计算方式:
- 进入视口时,按元素占据视口的比例激活
- 完全进入视口后,为该元素设置最高优先级的激活状态
- 优先激活最后新增的最高优先级状态
- 离开视口时,取消最高优先级的激活状态,并按元素占据视口的比例激活
其实,只需要在 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 应该能够:
- 点击链接后滚动到合适的位置
- 激活样式能够反映当前滚动位置
如果你的 TOC 设计要求只有一个激活项,那么很可能会遇到一些技术上难以克服的体验问题,需要定义更多判断规则来完善体验。例如:滚动到最后一个区块时已经到达页面底部,无法继续滚动,因此底部区块永远无法被激活(这又回到了那个问题:如果有多个激活项,哪个才是与用户最相关的区块?)
其实,我认为做到这个程度已经算是一个不错的 TOC 了,后续可以根据自己的需求修改,并添加自己的规则。