Real use case of intersection observer: Table of Contents

Intersection Observer 真實案例:自動更新目錄

前言

近期製作到內容大綱(Table of Contents)相關的功能,發現是一個很適合使用 Intersection Observer 的案例,透過監聽特定元素來自動更新目錄的狀態。

在早期可能會需要透過監聽網頁滑動事件並且透過 getBoundingClientRect 方法來計算元素有沒有離開視窗,但隨著專門偵測元素是否進入視窗的 API 出現,這樣的需求就變得更加容易實現。

如果你對於新舊做法比較有興趣可以參考這篇文章:Use Intersection Observer instead of Scroll Events - Jonathan Lau🔗,有詳細且的比較介紹。

特徵Intersection ObserverScroll 事件監聽 + getBoundingClientRect
主執行序執行於主執行序之外,不會阻礙其他事項執行於主執行序,會阻礙其他事項
效能開支有效跟蹤元素是否可見,只在必要時執行每次滾動時檢查元素位置,有可能會耗費更多資源
易用程度專門用於檢查元素是否進入視角,具備強大簡單的介面更底層索取個別元素在畫面中的資訊,可能計算需更複雜繁瑣

這也是為什麼我解題的方向會偏好從已經推出一段時間相對成熟的 Intersection Observer 開始實作,不過後續也碰上了一些意料之外的問題。

問題

製作一個可以根據特定元素內的標題生成目錄清單的功能,並且清單會根據滑動位置適當的顯示激活樣式:

  • 可自動根據目前文章中的內容生成對應的目錄連結清單並顯示在側欄
  • 當使用者滾動到對應區塊時,自動更新目錄激活的狀態
  • 當使用者點擊目錄連結時,滾動到對應的位置
<main>
<div>
<h1>TOC DEMO</h1>
<div>
<section>
<h2>Title One</h2>
<p>...</p>
</section>
<section>
<h2>Title Two</h2>
<p>...</p>
</section>
<section>
<h2>Title Three</h2>
<p>...</p>
</section>
<section>
<h2>Title Four</h2>
<p>...</p>
</section>
</div>
</div>
<aside>
<ul></ul>
</aside>
</main>

解題

由於自動更新目錄是非常常見的 UI ,每個人多少都用過所以規格上就不會特別詳細的描述,但實際探究下去會發現這個題目可以挖出非常多的細節,舉例像是:

  • 有多個嵌套標題區塊的關係如何處理?
  • 激活的狀態只能有一個嗎?如只能一個但有多個區塊都在激活狀態該如何擇一最相關的區塊激活?
  • 滾動到對應區塊的定義是什麼?離開區塊的定義是什麼?(完整或一半區塊離開或進入畫面?)
  • 連結點擊後如何滾動到對應的位置?對應的位置又是什麼?

以上意料外的問題將會直接影響到使用體驗,會出現程式執行沒有錯誤,但體驗來說不優的情況,舉例來說:點擊了目錄連結後,滾動到非預期的位置或是目錄激活的不是使用上預期的狀態。由於目錄會是動態生成的也導致邊緣案例很難被發現,因此先探討目錄預期的行為再下手實作會更好,我先將題目定義為:

  • 預期只需偵測一層標題區塊
  • 激活的狀態只能有一個
  • 最先激活的標題為主(滑動到 區塊 1 接著滑動到 區塊 2 但仍在 區塊 1 當中 ,但因為 區塊 1 先激活所以仍顯示 區塊 1 為激活)
  • 滾動到對應區塊的定義是區塊完全進入和離開畫面
  • 連結點擊後滾動到對應的位置是區塊頂部與視窗頂部 + 1rem 的留白

初步定義需求是以上。不過隨著實作的過程會發現一些難纏的邊緣案例到時候再來處理。

第一步:生成目錄

首先可以先替目錄相關的元素綁定標記(這裡我使用 data 屬性),以方便後續 JS 用 querySelector 找到對應的元素進行操作。

<main>
<div>
<h1>TOC DEMO</h1>
<div>
<section data-toc-section>
<h2>Title One</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 還是存在於畫面中所以一直佔著激活狀態。這麼做激活顯示雖然是正確的但可能激活到比較不相關的項目。真的要取一個最相關的區塊激活應該就要算區塊在畫面中的佔比,根據到達畫面特定比例觸發 Observer 的設定可以使用 threshold🔗 設定,簡單來說就是一個陣列從 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 布局的導覽列情況下可能會擋住區塊,這時候就需要預留更多的空間,以避免跳轉後區塊被導覽列遮蓋。

通常我會將頁面上方 fixed 元素的高度透過 CSS 變數來記錄(通常是導覽列)。

: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>ActiveTocs:</h3>' +
activeTocs.map((activeToc) => `<div>ID: ${activeToc.id}, ratio: ${activeToc.ratio}</div>`).join('');
}

See the Pen Basic - Real use case of intersection observer: Table of Contents by Riceball (@riecball) on CodePen.

驗收

當區塊超大時會導致更新失效,仍顯示舊的激活項目

一個極端案例特別容易發生在進出超過視角的區塊,原因是因為如果區塊內容超過視角時將無法觸發 Observer 更新,原因可以參考討論:How should larger-than-viewport-dimension targets be handled🔗

對於 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 區塊在畫面中需要出現不一樣的程度才能被激活(1px100px),但使用者應該是 認元素在視角的比例 解讀相關性才對,舉例視角 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();
}

最後新增之最高層級的激活狀態優先激活可以透過記錄一個觸發更新的 Timestamp 來判斷,並且更新整理比例的邏輯,如果比例一樣時就根據註冊的時間先後來決定激活狀態。

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;
});
}

See the Pen final - Real use case of intersection observer: Table of Contents by Riceball (@riecball) on CodePen.

總結

總結來說一個良好的 TOC 應該要能:

  1. 點擊連結滑動到適當的位置
  2. 激活樣式能反映當前滑動位置

如果你的 TOC 在設計上希望只有一個激活項目,那麼很有可能會碰上一些技術上難以克服的體驗問題,需要定義更多的判斷來完善體驗,例如:滑到最後一個區塊時到達頁面底部,無法再滑動,因此底部的區塊永遠無法被激活(牽扯回如果有多個激活什麼才是對用戶最相關的區塊?)

其實我認為到這個程度已經算是一個不錯的 TOC 了,可以再根據自己的需求修改添加自己的定義。

延伸閱讀

如果對於偵測元素是否進入視窗感興趣可以參考以下文章,使用不同方式偵測,並且也有詳細的互動教學: