這次網站改版,我想在文章右邊加入目錄,並且讓目錄可以根據卷軸位置自動更新顯示,這種功能稱為滾動監聽 (Scrollspy)。 Scrollspy 技術很早以前就有了,算不上什麼新技術,所以我本來打算直接使用 Bootstrap 內建的 Scrollspy 製作。 在製作過程中,我一直遇到觸發更新事件的捲軸位置不正確的問題。似乎 HTML 結構一定要符合 Bootstrap 要求的結構才能正常運作,但網站上已經有很多文章,每篇都進行調整的工作量太大了。

因此,我花時間研究了一下 Scrollspy 原理,並決定自己開發此功能,本文將分享我的製作思路,提供給大家參考。 目前文章右邊的目錄就是使用本文分享的方式製作,但您也能點擊下方連結觀看成果:

思路一:使用 onscroll 事件

第一時間想到最簡單的方式就是使用 onscroll 事件,在事件中判斷卷軸到達哪些區塊,並更新相對應的目錄。 這種做法只要判斷寫得好,可以很精確的控制更新選單的時機。 缺點則是比較耗效能,因為 onscroll 是連續觸發的事件,每次捲動卷軸就會一直連續觸發事件。 因此,我並沒有使用這個方法方式。

思路二:使用 Intersection Observer API

Intersection Observer 採用非同步執行,不會隨著卷軸捲動一直觸發事件。 雖然官方文件說 Intersection Observer 沒辦法精確到像素,只能偵測大概範圍,但拿來做 Scrollspy 已經十分夠用了。

此外,我看很多文章都說 Intersection Observer 底層使用 window.requestIdleCallback() 來觸發 callback(),這表示只有當瀏覽器空閒下來才會執行 callback() 中的程式碼,優先層級較低,較不吃效能。 不過我並沒有找到這段話的相關證據,查看 IntersectionObserver polyfill 好像也沒有相關的程式碼,如果有人知道這段話證據在哪裡的話,歡迎留言告訴我。

基本用法

                
                    let options = {
                        root: document.querySelector('#scroll'),
                        rootMargin: '0px 0px 0px 0px',
                        threshold: 1.0,
                    };
                    let callback = (entries, observer) => {
                        for (const entry of entries) {
                            if (entry.isIntersecting) {

                            }
                        }
                    };
                    let observer = new IntersectionObserver(callback, options);
                    observer.observe(section);
                
            
  • 行 1 ~ 13
    建立 IntersectionObserver 物件時,需要兩個參數 options 和 callback。 options 包含三個參數:root、rootMargin 和 threshold,這些在下一個小節會進一步詳細說明。 callback 是當觀察區塊進入或離開目標範圍時要執行的程式碼,您可以使用 entry.isIntersecting 來判斷被觀察區塊是進入目標還是離開目標。
  • 行 14
    當 IntersectionObserver 建立後,將 section 加入觀察對象,也就是說當 section 進入或離開目標範圍時,就會觸發 callback()。

重要參數

root
指定 viewport,也就是當觀察區塊進入該區域觸發 callback()。預設值為 null,表示 viewport 為最外層的瀏覽器。
rootMargin
viewport 的偵測範圍預設與 viewport 的長寬一致,當 rootMargin 數值為正數的話,表示擴大偵測範圍,如果為負數的話,則是縮小偵測範圍。 此外,rootMargin 與 CSS 的 margin 類似,有四個數字表示上右下左的 margin 數值,也能使用百分比。
threshold
參數值為 0 ~ 1,表示被觀察區塊有多少百分比進入偵測範圍內才觸發 callback(),預設值為 0,表示只要被觀察區塊有 1 個像素進入偵測區域就觸發。 這個參數也能使用陣列,例如 [0.25, 0.5, 0.75, 1] 表示當被觀察區塊 25%、50%、75%、100% 進入都觸發 callback()。

實作

了解原理之後,現在可以開始動手實作了。 首先,我們需要先製作一個導覽列及內容區塊。 在下面的範例中,我們假設有四個內容區塊,所以導覽列中也要有四個連結,HTML 大致如下。

            
                <ul class="nav nav-pills nav-fill">
                    <li class="nav-item"><a href="#section1" class="nav-link">區塊一</a></li>
                    <li class="nav-item"><a href="#section2" class="nav-link">區塊二</a></li>
                    <li class="nav-item"><a href="#section3" class="nav-link">區塊三</a></li>
                    <li class="nav-item"><a href="#section4" class="nav-link">區塊四</a></li>
                </ul>
                <div class="scrollspy">
                    <div id="section1" class="section">區塊一</div>
                    <div id="section2" class="section">區塊二</div>
                    <div id="section3" class="section">區塊三</div>
                    <div id="section4" class="section">區塊四</div>
                </div>
            
        

接著,使用 CSS 稍微美化一下,這邊只是稍微美化一下而已,並且將每個區塊高度設定為 100%,以方便展示。 當然,您可以根據自己的設計需求進行更進一步的調整。

            
                .nav {
                    background: #CCCCCC;
                }
                .nav .nav-item .nav-link {
                    border-radius: 0px;
                }
                .nav .nav-item .nav-link.active {
                    background: #0D6EFD;
                }
                .scrollspy {
                    height: calc(100vh - 520px);
                    border: 1px solid #CCCCCC;
                    overflow: auto;
                    scroll-behavior: smooth;
                }
                .scrollspy .section {
                    padding: 15px;
                    height: 100%;
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    justify-content: center;
                    font-size: 2rem;
                }
                .scrollspy .section:nth-child(odd) {
                    background-color: pink;
                }
                .scrollspy .section:nth-child(even) {
                    background-color: yellow;
                }
            
        

最後是 JavaScript 的部分。

            
                window.addEventListener('load', (event) => {
                    const content = document.querySelector('.scrollspy');
                    const directory = document.querySelectorAll('.nav li a');
                    const navLinks = [];
                    const options = {
                        root: content,
                        rootMargin: '-5% 0px -95% 0px',
                        threshold: 0
                    };
                    const callback = (entries) => {
                        for (const entry of entries) {
                            const id = entry.target.id
                            if (entry.isIntersecting) {
                                navLinks[id].classList.add('active');
                            } else {
                                navLinks[id].classList.remove('active');
                            }
                        }
                    };
                    const observer = new IntersectionObserver(callback, options);
                    directory.forEach((elem) => {
                        const id = elem.getAttribute('href').substr(1);
                        const section = content.querySelector('#' + id);
                        observer.observe(section);
                        navLinks[id] = elem;
                    })
                })
            
        
  • 行 2
    取得 scrollspy 區塊,這個區塊是所有被觀察區塊的外層,具有捲軸,會在行 6 的 root 參數設定為 viewport。
  • 行 3
    取得導覽列中的連結,後續會根據連結的 href 值取得相對應的被觀察者。
  • 行 5 ~ 9
    建立 options,將 content 設定為 viewport,rootMargin 設定為 -5% 0px -95% 0px,讓每個區塊到達上方約 5% 的位置才觸發 callback()。
  • 行 10 ~ 19
    建立 callback,當觀察區塊進入 viewport 時,將相對應的導覽列連結加入 active Class。反之,則移除 active Class。
  • 行 20
    建立 IntersectionObserver 物件。
  • 行 20 ~ 26
    根據 directory 中連結的 href 值取得相對應的 section 元素,並將 section 加入觀察對象。同時將連結物件,使用 id 作為索引值加入 navLinks 中,這是為了方便在 callback() 操作。

結語

到這邊就全部完成了,相信應該不會太難。 我覺得最麻煩的是當版面很複雜的時候,要調整 rootMargin 觸發 callback() 時,需要花點時間嘗試不同的參數,才能有最好的效果。 如果實作上有遇到什麼困難,或者您覺得有什麼更好的做法,都歡迎留言討論喔。