使用 Vue 3 + Intersection Observer 打造智慧型目錄組件:支援動態高亮與平滑滾動

作者: Calpa Liu
字數:3345
出版:April 19, 2025
分類: 前端開發 Vue.js Intersection Observer 技術文章

想提升長篇文章或技術文件的閱讀體驗?本篇教你如何使用 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 其實還能應用在延遲加載圖片、無限捲動、滾動動畫觸發等多種互動場景。如果你對這些實作也有興趣,歡迎留言或私訊我,我會在後續文章中繼續深入探索。別忘了將這篇文章分享給關心內容體驗的朋友,一起把技術作品推向更高層次!

感謝您閱讀我的文章。歡迎隨時分享你的想法。
前端開發 Vue.js Intersection Observer 技術文章
關於 Calpa

Calpa 擅長使用 TypeScriptReact.jsVue.js 建立 Responsive Website。

他積極參與開源社區,曾在 2019 年的香港開源大會上擔任講者,提供工作經驗和見解。此外,他也在 GitHub 上公開分享個人博客程式碼,已獲得超過 300 顆星星和 60 個分支的支持。

他熱愛學習新技術,並樂意分享經驗。他相信,唯有不斷學習才能跟上快速演變的技術環境。