想提升長篇文章或技術文件的閱讀體驗?本篇教你如何使用 Vue 3 的 Composition API 結合 Intersection Observer 打造智慧型 TOC 組件,實現動態高亮當前段落、平滑滾動跳轉、支援 h2/h3 層級與響應式設計,全面強化你的網站導航與用戶體驗。
為何需要動態目錄?
在面對長篇技術文件、部落格文章或教學內容時,用戶最常見的困擾就是迷失方向感:不知道目前閱讀到哪、接下來會看到什麼、還剩多少內容。這時,一個具備即時互動性的「動態目錄(Dynamic TOC)」就顯得格外重要。
相較於靜態目錄只能提供基礎的章節跳轉功能,動態目錄具備「上下文感知能力」,能根據使用者滾動行為即時更新當前所在的段落,並以視覺方式高亮顯示。這種即時反饋不僅提升導覽體驗,也幫助讀者建立對文章結構的整體理解。對於結構複雜、層級分明的技術內容來說,這一點尤其關鍵。
動態目錄還能顯著提升網站的專業感與使用者體驗。無論是滑鼠滾動還是觸控裝置上的滑動行為,都能維持同步更新的目錄狀態,讓用戶隨時知道自己的位置與上下文邏輯。這種體驗上的細膩設計,往往是用戶判斷網站品質與可信度的重要指標之一。
值得注意的是,傳統的實作方法通常依賴於 scroll 事件監聽,這會導致效能瓶頸:每次滾動都會觸發大量的事件處理,增加主線程壓力,進而影響畫面更新與互動流暢度。這種情況在移動裝置或低階設備上表現尤其明顯。因此,採用效能更佳、非同步觀察機制的 Intersection Observer API,成為動態目錄實作中的理想解法。
Intersection Observer API 基礎
Intersection Observer API 是一個由瀏覽器原生支援的強大功能,專門用來監測元素是否進入或離開某個容器(通常是視口)的可視區域。這個 API 能夠非同步地追蹤元素的可見性變化,因此非常適合用於懶加載圖片、無限滾動、滾動觸發動畫,甚至像本文這樣的智慧型目錄組件。它的設計理念,是為了取代過去需要頻繁監聽 scroll
事件並手動計算元素位置的做法,從而提供一個更高效、更穩定的觀察解法。
與傳統的滾動事件相比,Intersection Observer 具備四個明顯優勢。首先,它是非同步執行的,不會阻塞主線程,這對於效能敏感的應用(特別是移動裝置)非常重要。其次,它的回調只會在元素進入或離開指定的可見區域時觸發,避免了每次滾動都重複執行回調的開銷。第三,開發者可以設定交叉比例(threshold),精確定義在多少可見範圍下觸發邏輯,這比傳統判斷元素是否在視口中更靈活。最後,它的參數配置相當彈性,可以自定義觀察容器(root)、邊界(rootMargin)與觸發條件(threshold),滿足各種使用情境。
在實作上,我們會接觸到幾個重要概念。root
是用來當作觀察參考的容器,預設為瀏覽器的視口;rootMargin
則是設定根容器的內外邊界,能讓你提早或延遲觸發判斷;threshold
是一個範圍在 0 到 1 的數值,代表目標元素需有多少部分進入可視區才會觸發事件;而 isIntersecting
則是每次觀察結果中的布林值,表示該元素是否真的出現在觀察區域內。這些概念組合起來,就構成了一個高效、現代化的滾動觀察機制。
基本使用方式
要創建一個 Intersection Observer 實例,我們需要先定義一些選項:
// 創建 Intersection Observer 實例
const options = {
root: null, // 使用視口作為參考
rootMargin: '0px', // 無邊界調整
threshold: 0.1 // 當目標元素 10% 可見時觸發
};
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('元素已進入視口');
} else {
console.log('元素已離開視口');
}
});
};
const observer = new IntersectionObserver(callback, options);
// 開始觀察目標元素
const target = document.querySelector('#target-element');
observer.observe(target);
使用 Vue 3 Composition API 構建 TOC 組件
Vue 3 的 Composition API 提供了更靈活的邏輯組織方式,非常適合實現複雜的交互功能。讓我們利用 <script setup>
語法來構建我們的 TOC 組件。
基本結構
首先,讓我們創建一個 TableOfContents.vue
組件:
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
// 存放標題元素的引用
const headingElements = ref([]);
// 當前活躍的標題 ID
const activeHeadingId = ref('');
// Intersection Observer 實例
const observer = ref(null);
// 在組件掛載後初始化
onMounted(() => {
// 收集所有標題元素
collectHeadings();
// 初始化 Intersection Observer
initIntersectionObserver();
});
// 組件卸載前清理
onBeforeUnmount(() => {
// 停止觀察所有元素
disconnectObserver();
});
// 實現相關方法...
</script>
<template>
<nav class="table-of-contents">
<h2>目錄</h2>
<ul>
<li
v-for="heading in headingElements"
:key="heading.id"
:class="{ active: heading.id === activeHeadingId }"
>
<a :href="`#${heading.id}`">{{ heading.textContent }}</a>
</li>
</ul>
</nav>
</template>
<style lang="scss" scoped>
.table-of-contents {
// 樣式定義...
}
</style>
收集文章標題元素
首先,我們需要收集頁面中的所有標題元素,我們選擇文章內容區域的 h2 和 h3 元素:
const collectHeadings = () => {
// 選擇頁面中所有的 h2 和 h3 元素
const headings = Array.from(document.querySelectorAll('.article-content h2, .article-content h3'));
// 確保每個標題都有一個 ID(用於目錄項目的跳轉)
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `heading-${index}`;
}
});
// 儲存收集到的標題元素
headingElements.value = headings.map(heading => ({
id: heading.id,
textContent: heading.textContent,
level: parseInt(heading.tagName.substring(1)), // 獲取標題級別(2 表示 h2,3 表示 h3)
element: heading
}));
};
因此,我們的 headingElements
將包含所有文章標題的 ID、文本內容、層級和 DOM 元素。
初始化 Intersection Observer
接下來,我們初始化 Intersection Observer 來觀察這些標題元素:
const initIntersectionObserver = () => {
// 設定觀察選項
const options = {
root: null, // 使用視口作為參考
rootMargin: '-100px 0px -70% 0px', // 調整觀察區域,使其大約在視口頂部
threshold: [0, 0.25, 0.5, 0.75, 1] // 多個閾值,獲取更精確的交叉信息
};
// 建立回調函數
const intersectionCallback = (entries) => {
// 找到當前可見的標題
const visibleHeadings = entries
.filter(entry => entry.isIntersecting)
.map(entry => entry.target.id);
if (visibleHeadings.length > 0) {
// 如果有多個可見標題,選擇第一個作為當前活躍標題
activeHeadingId.value = visibleHeadings;
} else if (entries.length > 0) {
// 如果沒有可見標題,嘗試確定最接近視口的標題
determineActiveHeadingBasedOnPosition(entries);
}
};
// 創建 observer 實例
observer.value = new IntersectionObserver(intersectionCallback, options);
// 開始觀察所有標題元素
headingElements.value.forEach(heading => {
observer.value.observe(heading.element);
});
};
這樣,我們就成功建立了基本的動態目錄功能,能夠即時反映當前閱讀位置。
確定當前活躍標題
當沒有標題完全可見時,我們需要一個策略來確定哪個標題應該被標記為活躍:
const determineActiveHeadingBasedOnPosition = (entries) => {
// 根據元素在頁面中的位置確定活躍標題
// 簡單方案:選擇最靠近視口頂部的標題
const viewportHeight = window.innerHeight;
const viewportTop = window.scrollY;
const viewportBottom = viewportTop + viewportHeight;
let closestHeading = null;
let closestDistance = Infinity;
entries.forEach(entry => {
const rect = entry.boundingClientRect;
const elementTop = rect.top + viewportTop;
// 計算元素與視口頂部的距離
const distance = Math.abs(elementTop - viewportTop);
if (distance {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
}
};
透過這個策略,我們可以確保即使沒有標題完全可見,也能準確地確定當前活躍的標題。
停止觀察
在組件卸載前,我們需要確保停止觀察所有標題元素,以避免記憶體泄漏:
const disconnectObserver = () => {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
}
};
處理實際應用中的問題
在實際應用中,我們還需要考慮一些重要的細節和邊緣情況。
實現防抖處理
為了避免過於頻繁的更新,我們可以實現一個防抖函數:
const debounce = (func, wait) => {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
};
// 使用防抖包裝我們的回調
const debouncedIntersectionCallback = debounce((entries) => {
// 處理交叉事件...
}, 50);
為了確保在滾動過程中不會頻繁觸發,我們使用了防抖處理。
處理窗口大小變化
當窗口大小變化時,我們可能需要重新初始化觀察器:
onMounted(() => {
// ... 其他初始化代碼
// 監聽窗口大小變化
const handleResize = debounce(() => {
// 斷開並重新初始化觀察器
disconnectObserver();
initIntersectionObserver();
}, 200);
window.addEventListener('resize', handleResize);
// 記得在卸載時移除事件監聽器
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
disconnectObserver();
});
});
處理移動設備的特殊考慮
在移動設備上,我們可能需要調整一些參數來提供更好的用戶體驗:
const initIntersectionObserver = () => {
// 檢測是否為移動設備
const isMobile = window.innerWidth < 768;
const options = {
root: null,
// 在移動設備上使用更大的 margin,因為螢幕更小
rootMargin: isMobile ? '-60px 0px -60% 0px' : '-100px 0px -70% 0px',
threshold: isMobile ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1] // 在移動設備上使用更少的閾值
};
// ... 後續代碼
};
點擊目錄項目時的行為
當用戶點擊目錄項目時,我們希望能夠平滑滾動到對應的標題,並更新活躍標題:
<template>
<nav class="table-of-contents">
<h2>目錄</h2>
<ul>
<li
v-for="heading in headingElements"
:key="heading.id"
:class="{ active: heading.id === activeHeadingId }"
>
<a
:href="`#${heading.id}`"
@click.prevent="scrollToHeading(heading.id)"
>
{{ heading.textContent }}
</a>
</li>
</ul>
</nav>
</template>
<script setup>
// ... 其他代碼
const scrollToHeading = (id) => {
const element = document.getElementById(id);
if (element) {
// 設置平滑滾動
element.scrollIntoView({ behavior: 'smooth' });
// 直接更新活躍標題,而不等待 Intersection Observer 回調
activeHeadingId.value = id;
}
};
</script>
使用 Vue 的 TransitionGroup 添加動畫效果
為了增強用戶體驗,我們可以使用 Vue 的 <TransitionGroup>
組件為目錄項目添加動畫效果:
<template>
<nav class="table-of-contents">
<h2>目錄</h2>
<TransitionGroup name="toc-item" tag="ul">
<li
v-for="heading in headingElements"
:key="heading.id"
:class="{
active: heading.id === activeHeadingId,
'indent-level-2': heading.level === 2,
'indent-level-3': heading.level === 3
}"
>
<a
:href="`#${heading.id}`"
@click.prevent="scrollToHeading(heading.id)"
>
{{ heading.textContent }}
</a>
</li>
</TransitionGroup>
</nav>
</template>
<style lang="scss" scoped>
.table-of-contents {
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
padding: 1rem;
border-left: 2px solid #eaeaea;
h2 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
margin: 0.5rem 0;
transition: all 0.3s ease;
&.indent-level-2 {
margin-left: 0;
}
&.indent-level-3 {
margin-left: 1rem;
font-size: 0.95em;
}
&.active {
a {
color: #3498db;
font-weight: bold;
transform: translateX(5px);
}
}
a {
color: #666;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
&:hover {
color: #3498db;
}
}
}
}
// TransitionGroup 動畫
.toc-item-enter-active,
.toc-item-leave-active {
transition: all 0.5s ease;
}
.toc-item-enter-from,
.toc-item-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.toc-item-move {
transition: transform 0.5s ease;
}
</style>
這樣的動態目錄功能,不僅提高了用戶體驗,還提供了更好的可訪問性,讓文章結構更加清晰。
完整組件代碼
讓我們整合以上所有功能,創建一個完整的動態 TOC 組件:
<script setup>
import { ref, onMounted, onBeforeUnmount, watchEffect } from 'vue';
// 存放標題元素的引用
const headingElements = ref([]);
// 當前活躍的標題 ID
const activeHeadingId = ref('');
// Intersection Observer 實例
const observer = ref(null);
// 配置選項
const props = defineProps({
contentSelector: {
type: String,
default: '.article-content'
},
headingSelector: {
type: String,
default: 'h2, h3'
},
offset: {
type: Number,
default: 100
},
mobileBreakpoint: {
type: Number,
default: 768
}
});
// 防抖函數
const debounce = (func, wait) => {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
};
// 收集標題元素
const collectHeadings = () => {
const selector = `${props.contentSelector} ${props.headingSelector}`;
const headings = Array.from(document.querySelectorAll(selector));
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `heading-${index}`;
}
});
headingElements.value = headings.map(heading => ({
id: heading.id,
textContent: heading.textContent,
level: parseInt(heading.tagName.substring(1)),
element: heading
}));
};
// 初始化 Intersection Observer
const initIntersectionObserver = () => {
const isMobile = window.innerWidth < props.mobileBreakpoint;
const options = {
root: null,
rootMargin: isMobile
? `-${props.offset / 2}px 0px -60% 0px`
: `-${props.offset}px 0px -70% 0px`,
threshold: isMobile ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]
};
const intersectionCallback = (entries) => {
// 找到當前可見的標題
const visibleHeadings = entries
.filter(entry => entry.isIntersecting)
.map(entry => entry.target.id);
if (visibleHeadings.length > 0) {
// 如果有多個可見標題,選擇第一個
activeHeadingId.value = visibleHeadings[0];
} else if (entries.length > 0) {
// 如果沒有可見標題,確定最接近的標題
determineActiveHeadingBasedOnPosition(entries);
}
};
// 使用防抖包裝回調函數
const debouncedCallback = debounce(intersectionCallback, 50);
observer.value = new IntersectionObserver(debouncedCallback, options);
// 開始觀察所有標題元素
headingElements.value.forEach(heading => {
observer.value.observe(heading.element);
});
};
// 確定當前活躍標題
const determineActiveHeadingBasedOnPosition = (entries) => {
const viewportHeight = window.innerHeight;
const viewportTop = window.scrollY;
let closestHeading = null;
let closestDistance = Infinity;
entries.forEach(entry => {
const rect = entry.boundingClientRect;
const elementTop = rect.top + viewportTop;
const distance = Math.abs(elementTop - viewportTop);
if (distance < closestDistance) {
closestDistance = distance;
closestHeading = entry.target.id;
}
});
if (closestHeading) {
activeHeadingId.value = closestHeading;
}
};
// 滾動到特定標題
const scrollToHeading = (id) => {
const element = document.getElementById(id);
if (element) {
// 計算目標滾動位置,考慮 offset
const offsetPosition = element.offsetTop - props.offset;
// 平滑滾動到位置
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
// 直接更新活躍標題
activeHeadingId.value = id;
}
};
// 斷開觀察器連接
const disconnectObserver = () => {
if (observer.value) {
observer.value.disconnect();
observer.value = null;
}
};
// 處理窗口大小變化
const handleResize = debounce(() => {
disconnectObserver();
initIntersectionObserver();
}, 200);
// 組件掛載時初始化
onMounted(() => {
collectHeadings();
initIntersectionObserver();
// 監聽窗口大小變化
window.addEventListener('resize', handleResize);
});
// 組件卸載前清理
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
disconnectObserver();
});
</script>
<template>
<aside class="table-of-contents" v-if="headingElements.length > 0">
<h2>目錄</h2>
<TransitionGroup name="toc-item" tag="ul">
<li
v-for="heading in headingElements"
:key="heading.id"
:class="{
active: heading.id === activeHeadingId,
'indent-level-2': heading.level === 2,
'indent-level-3': heading.level === 3
}"
>
<a
:href="`#${heading.id}`"
@click.prevent="scrollToHeading(heading.id)"
>
{{ heading.textContent }}
</a>
</li>
</TransitionGroup>
</aside>
</template>
<style lang="scss" scoped>
.table-of-contents {
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
padding: 1.5rem;
border-left: 2px solid #eaeaea;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
@media (max-width: 768px) {
position: relative;
top: 0;
max-height: none;
margin-bottom: 2rem;
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
color: #333;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
margin: 0.75rem 0;
transition: all 0.3s ease;
position: relative;
&.indent-level-2 {
margin-left: 0;
}
&.indent-level-3 {
margin-left: 1.5rem;
font-size: 0.95em;
&::before {
content: '';
position: absolute;
left: -1rem;
top: 0.6em;
height: 1px;
width: 0.5rem;
background-color: #ddd;
}
}
&.active {
transform: translateX(5px);
a {
color: #3498db;
font-weight: 600;
}
&::before {
content: '';
position: absolute;
left: -1.5rem;
top: 0;
height: 100%;
width: 3px;
background-color: #3498db;
border-radius: 3px;
}
}
a {
color: #666;
text-decoration: none;
display: inline-block;
transition: all 0.25s ease;
padding: 0.2rem 0;
&:hover {
color: #3498db;
}
}
}
}
// TransitionGroup 動畫
.toc-item-enter-active,
.toc-item-leave-active {
transition: all 0.5s ease;
}
.toc-item-enter-from,
.toc-item-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.toc-item-move {
transition: transform 0.5s ease;
}
</style>
使用方法
在你的 Vue 應用中使用這個組件非常簡單:
<template>
<div class="article-layout">
<article class="article-content">
<!-- 你的文章內容,包含 h2 和 h3 標題 -->
<h1>文章標題</h1>
<p>導言段落...</p>
<h2 id="section-1">第一部分</h2>
<p>內容...</p>
<h3 id="section-1-1">子部分 1.1</h3>
<p>內容...</p>
<h2 id="section-2">第二部分</h2>
<p>內容...</p>
<!-- 更多內容... -->
</article>
<TableOfContents
contentSelector=".article-content"
headingSelector="h2, h3"
:offset="80"
/>
</div>
</template>
<script setup>
import TableOfContents from '@/components/TableOfContents.vue';
</script>
<style scoped>
.article-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
@media (max-width: 768px) {
.article-layout {
grid-template-columns: 1fr;
}
}
.article-content {
/* 文章內容樣式 */
}
</style>
結論
如果你也在為技術文章、部落格或文件平台打造更佳的閱讀體驗,Vue 3 的 Composition API 結合 Intersection Observer API,正是一個高效又現代化的解法。這種組合不僅能打造具備滾動感知、高亮當前段落與平滑跳轉的智慧型 TOC 元件,還能讓你的網站更具專業感與可讀性。
與傳統的 scroll 事件相比,Intersection Observer 採用非同步觀察機制,大幅降低效能負擔,同時提供更精準的觸發時機。搭配 Composition API 的邏輯抽象能力,不僅讓開發更模組化、可維護,也能讓整體程式碼結構更加清晰。
另外,Intersection Observer 其實還能應用在延遲加載圖片、無限捲動、滾動動畫觸發等多種互動場景。如果你對這些實作也有興趣,歡迎留言或私訊我,我會在後續文章中繼續深入探索。別忘了將這篇文章分享給關心內容體驗的朋友,一起把技術作品推向更高層次!