|JS Simple|Active Menu on Scroll with Intersection Observer

在網頁需要建立一個選單,讓它根據畫面中捲動到特定位置時點亮相對應的選單項目,通常是使用綁定 scroll 事件。但這樣的方式會頻繁觸發回呼函式,導致效能低落並讓使用體驗變得遲鈍。
這次將示範用 Intersection Observer API 來實現,它比偵聽 scroll 事件更減少效能,因為只有當觀察的元素滿足指定閾值時才會觸發回調,而不是每次更新滾動位置時。
以下是這次範例 html 結構,在 menu 的 <a> 標籤設置自定義屬性 [data-tt],並確保 [data-tt] 的值與對應的 article 類名相符,用於匹配。所有需要觀察的內容區塊 <article> 標籤都添加屬性 [data-at="entry"],用來辨別觀察目標。
html
定義 menu li 的初始樣式,以及 menu li:hover 和 menu li.active 的效果。
css
.menu {
display: flex;
flex-direction: column;
justify-content: center;
--tt-spacing: min(0.12vw, 0.2rem);
}
.menu li {
padding-inline: min(1.2vw, 0.8rem);
line-height: 2.8;
font-size: var(--li-font-size);
text-transform: uppercase;
letter-spacing: var(--tt-spacing);
white-space: nowrap;
cursor: pointer;
background-color: rgba(0, 0, 0, 0);
transition: background-color 0.5s;
position: relative;
}
.menu li:hover,
.menu li.active {
background-color: rgba(255, 255, 255, 1);
transition: background-color 0.5s;
}
以下 JavaScript 用來檢測選單是否滾離頂部,並根據狀態增加或移除 sticky class。
script
document.addEventListener("DOMContentLoaded", function () {
const sidebarElement = document.querySelector("nav");
// 每次事件觸發時都重新計算 offsetTop
function updateStickyState() {
const windowHeight = window.innerHeight;
const tenPercentHeight = windowHeight * 0.15;
const sidebarOffsetTop = sidebarElement.offsetTop - tenPercentHeight;
const scrollY = window.scrollY;
if (scrollY > sidebarOffsetTop) {
sidebarElement.classList.add("sticky");
} else {
sidebarElement.classList.remove("sticky");
}
}
// 初次計算並添加事件監聽器
updateStickyState();
window.addEventListener("scroll", updateStickyState);
window.addEventListener("resize", updateStickyState);
});
監聽 nav 增加 sticky class 才觸發 IntersectionObserver 監測 [data-at="entry"],根據區塊進入視口的情況觸發對應的選單樣式。"rootMargin"、"threshold" 的設置需針對桌面版與手機版分別調整,因為兩者的視口大小與比例不同,桌面版需要較大的數值以適應更大的可見範圍,而手機版則需較小的數值以配合有限的視口空間。
由於最後一篇文章的內容過短,可能無法被觀察器觸發,因此需要使用 scroll 事件來檢查滾動狀態,並確保正確添加 active 類別。
script
document.addEventListener("DOMContentLoaded", () => {
const observerConfig = {
root: null,
rootMargin: "0px 0px -50% 0px", // 預設為電腦版的設定
threshold: 0.55,
};
function setObserverConfig() {
const windowWidth = window.innerWidth;
if (windowWidth > 768) {
// 電腦版
observerConfig.rootMargin = "0px 0px -50% 0px"; // 觸發點在視口的中間
observerConfig.threshold = 0.55;
} else {
// 手機版
observerConfig.rootMargin = "0px 0px -20% 0px"; // 觸發點稍靠近頂端
observerConfig.threshold = 0.3;
}
}
// 初始設定
setObserverConfig();
// 視窗大小改變時重新設定
window.addEventListener("resize", setObserverConfig);
const nav = document.querySelector("nav");
const menuItems = document.querySelectorAll("ul.menu li");
const elementsToObserve = document.querySelectorAll('[data-at="entry"]'); // 所有需要觀察的元素
let observer = null;
// 清除所有 li 上的 active 類別
const clearActiveClasses = () => {
menuItems.forEach((item) => item.classList.remove("active"));
};
// 設置 active 類別
const setActiveClass = (targetClass) => {
const correspondingLink = document.querySelector(
`li a[data-tt="${targetClass}"]`
);
if (correspondingLink) {
const correspondingLi = correspondingLink.parentNode;
if (!correspondingLi.classList.contains("active")) {
clearActiveClasses(); // 移除所有 active 類
correspondingLi.classList.add("active"); // 新增 active 類
}
}
};
// 初始化 IntersectionObserver
const initializeObserver = () => {
// 如果已經有一個 observer 實例,先取消觀察
if (observer) {
observer.disconnect();
}
// 創建新的 observer
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (nav.classList.contains("sticky")) {
// 當 nav 有 sticky 類別時執行
if (entry.isIntersecting) {
const targetClass = entry.target.classList[0];
setActiveClass(targetClass); // 設置 active 類別
}
}
});
}, observerConfig);
// 開始觀察需要的元素
elementsToObserve.forEach((element) => observer.observe(element));
};
// 滾動事件監聽
const onScroll = () => {
// 檢查是否滾動到頁面頂部 並且導航欄 nav 未加上 sticky
if (window.scrollY === 0 && !nav.classList.contains("sticky")) {
clearActiveClasses(); // 清除所有 active 類別
}
// 檢查是否滾動到底部
const scrollPosition = window.scrollY + window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (scrollPosition >= documentHeight) {
// 如果滾動到底部,手動觸發最後一個區塊的 active 類別
const lastElement = elementsToObserve[elementsToObserve.length - 1];
const targetClass = lastElement.classList[0];
setActiveClass(targetClass);
}
};
// 初次檢查 nav 是否有 sticky 類別
if (nav.classList.contains("sticky")) {
initializeObserver();
}
// 監聽 nav 的 class 變化
const navObserver = new MutationObserver(() => {
if (nav.classList.contains("sticky")) {
initializeObserver(); // 如果 nav 有 sticky 類別,初始化 observer
} else {
if (observer) {
observer.disconnect(); // 停止觀察
observer = null;
}
clearActiveClasses(); // 清除所有 active 類別
}
});
// 觀察 nav 的 class 屬性變化
navObserver.observe(nav, { attributes: true, attributeFilter: ["class"] });
// 監聽滾動事件
window.addEventListener("scroll", onScroll);
});
圖素來源:
AIPICT(https://aipict.com/)