使用 IntersectionObserver 构建交互式目录

2024-10-27 15:31 #前端

前言

前几天把博客用 Astro 重写了一遍,换了一套样式,干脆再顺手把目录也做上吧。本着怎么省事怎么来的原则,我直接从网上抄了一段 gist,但是发现它并不能很好地识别当前阅读的章节,于是我在它的基础上使用 IntersectionObserver 重写了一套识别方案。

本文将实现在页面滚动时自动高亮当前阅读章节。

前置准备

为了实现本文的目标,需要如下准备

  1. 已经在 html 中渲染出文章内容,其中 <hx> 标题元素需要有 id 属性。
  2. 已经根据文章内容生成好目录 DOM 结构,并且在页面滚动时能一直看到目录。
  3. 目录结构如下
<ol>
<li>
<a href="#section-1">第一节</a>
<ol>
<li>
<a href="#section-1-1">1-1</a>
</li>
<li>
<a href="#section-1-2">1-2</a>
</li>
</ol>
</li>
</ol>

如何找出正在阅读的章节

我们先定义一下什么是正在阅读的章节:

在当前页面顶部 50% 的地方作一条横线,从这条横线向上找,找到的第一个标题就是当前正在阅读的章节。

IntersectionObserver

既然我们的目标是在页面滚动时自动识别出正在阅读的章节,第一想法当然是通过 onscroll 事件来监听页面滚动,滚动时去获取每个标题的位置,计算出正在阅读的章节。但是 onscroll 事件会在滚动时持续触发,会在一定程度上影响性能。取而代之的方案是使用 IntersectionObserver,它只会在元素进出检测区域时触发,虽然它的兼容性不如 onscroll 方案,但是在桌面端也有 97% 的支持率,足够我的博客使用了。

使用 IntersectionObserver 大概分为 3 步:

  1. 调用 const ob = new IntersectionObserver(callback, opts)创建一个实例并指定检测区域。
  2. 调用 ob.observe(el)监视 el 元素进出检测区域。
  3. (可选)调用 ob.disconnect()来结束监视。

元素被监视后,当它进入检测区域后,callback 将被调用,入参是一个数组,包含了所有当前时刻进入或离开检测区域的元素。一般滚动时这个数组只会有一个元素,因为滚动时一般只会有一个元素同时进入检测区域;但如果点击浏览器右侧的滚动条直接跳转到文章的某个区域,那么会出现数组中包含多个元素的情况。

opts 则由这些参数组成

  • root 一个作为检测区域的元素,设为 null 则会将浏览器视口作为检测区域。
  • rootMargin 检测区域向外扩展的大小,例如
    • 0px 表示检测区域不向外扩展,注意不能省略 px
    • 0px 10px -20% 30px 表示检测区域
      • 顶部边界不扩展
      • 右边界向外扩展 10px
      • 底部边界向内缩回 20% 的高度
      • 左边界向外扩展 30px
  • threshold 一个数组,表示被检测元素进入检测区域达到多少百分比时才触发 callback,例如
    • [0]表示被检测元素一进入检测区域就触发,哪怕只进入了 1px
    • [0.5]表示被检测元素的一半进入检测区域就触发
    • [1]表示被检测元素需要完全进入检测区域才会触发
    • [0, 1]表示被检测元素一进入检测区就会触发,并且在元素完全进入检测区域后再触发一次

找出检测区域内的所有标题

因为 IntersectionObserver 触发时只会传入当前进入或离开检测区域的元素,所以我们还需要额外写点代码记录一下当前检测区域内都有哪些标题。

const visibleTitles = new Set<Element>();
const sectionHeadings = document.querySelectorAll("article h2, article h3");
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleTitles.add(entry.target);
} else {
visibleTitles.delete(entry.target);
}
}
},
{ root: null, rootMargin: "0px 0px -50% 0px", threshold: [1] },
);
for (const heading of sectionHeadings) {
observer.observe(heading);
}

找出正在阅读章节的标题

现在我们拿到了所有处于检测区内的所有标题,接下来就可以找出正在阅读章节的标题了,callback 被触发时会有这三种情况:

  1. 当前有标题在检测区域内,最靠底部的一个标题是当前阅读章节的标题
  2. 当前没有标题在检测区域内,说明传入的元素都是离开检测区的
    1. 如果这些元素里有处于检测区域上方的,那这些处于检测区域上方的标题中最下方的一个就是当前阅读章节的标题
    2. 如果这些元素全处于检测区域下方,则这些元素中最上方的那个标题之前的标题是当前阅读章节的标题
检测区
标题一
标题二
标题三
情况1
标题一
检测区
标题二
情况2-1
标题0 不存在于当前 entries 里
检测区
标题一
标题二
情况2-2

于是我们得到了这样的代码

const currentHeading = (() => {
if (visibleTitles.size > 0) {
const mostBottomTitle = visibleTitles
.values()
.map(
(v) => [v, v.getBoundingClientRect().top] satisfies [Element, number],
)
.reduce((prev, curr) => (prev[1] > curr[1] ? prev : curr))?.[0];
return mostBottomTitle;
} else {
const upperTitles = Array.from(
entries.values().filter((t) => t.boundingClientRect.top < 0),
);
if (upperTitles.length > 0) {
return upperTitles.reduce((p, c) =>
p.boundingClientRect.top > c.boundingClientRect.top ? p : c,
).target;
} else {
const mostTopTitle = entries
.values()
.reduce((p, c) =>
p.boundingClientRect.top < c.boundingClientRect.top ? p : c,
).target;
for (let i = 1; i < sectionHeadings.length; i++) {
if (sectionHeadings[i] === mostTopTitle) {
return sectionHeadings[i - 1];
}
}
return sectionHeadings[0];
}
}
})();

给目录加样式

在拿到正在阅读章节的标题后,我们就可以查找该标题对应的目录项并给它加上一个 css class,于是就实现了随滚动高亮目录中当前阅读章节的功能。

document
.querySelectorAll("a.active-toc-item")
.forEach((a) => a.classList.remove("active-toc-item"));
const tocItem = document.querySelector(`a[href="#${currentHeading.id}"]`);
if (tocItem) {
tocItem.classList.add("active-toc-item");
}

完整代码

const visibleTitles = new Set<Element>();
const sectionHeadings = document.querySelectorAll("article h2, article h3");
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleTitles.add(entry.target);
} else {
visibleTitles.delete(entry.target);
}
}
/*
* 如果当前有标题在检测区内,获取他们的包围盒,选最下方的那个作为当前章节。
* 如果没有标题在检测区内,则刚刚离开的那些标题:
* 如果有标题在上方,则最下面那个是当前章节
* 如果全在下方,则用最上方的标题的前一个标题是当前章节
*/
const currentHeading = (() => {
if (visibleTitles.size > 0) {
const mostBottomTitle = visibleTitles
.values()
.map(
(v) =>
[v, v.getBoundingClientRect().top] satisfies [Element, number],
)
.reduce((prev, curr) => (prev[1] > curr[1] ? prev : curr))?.[0];
return mostBottomTitle;
} else {
const upperTitles = Array.from(
entries.values().filter((t) => t.boundingClientRect.top < 0),
);
if (upperTitles.length > 0) {
return upperTitles.reduce((p, c) =>
p.boundingClientRect.top > c.boundingClientRect.top ? p : c,
).target;
} else {
const mostTopTitle = entries
.values()
.reduce((p, c) =>
p.boundingClientRect.top < c.boundingClientRect.top ? p : c,
).target;
for (let i = 1; i < sectionHeadings.length; i++) {
if (sectionHeadings[i] === mostTopTitle) {
return sectionHeadings[i - 1];
}
}
return sectionHeadings[0];
}
}
})();
document
.querySelectorAll("a.active-toc-item")
.forEach((a) => a.classList.remove("active-toc-item"));
const tocItem = document.querySelector(`a[href="#${currentHeading.id}"]`);
if (tocItem) {
tocItem.classList.add("active-toc-item");
}
},
{ root: null, rootMargin: "0px 0px -50% 0px", threshold: [1] },
);
for (const heading of sectionHeadings) {
observer.observe(heading);
}

参考