使用 Intersection Observer 構建動態標題

已發表: 2022-03-10
快速總結↬您是否曾經需要構建一個 UI,其中頁面上的某些組件需要在元素滾動到視口內的某個閾值時做出響應——或者可能是進出視口本身? 在 JavaScript 中,附加一個事件偵聽器以不斷觸發滾動回調可能會佔用大量性能,如果使用不當,可能會導致用戶體驗遲緩。 但是有一個更好的方法是使用 Intersection Observer。

Intersection Observer API 是一個 JavaScript API,它使我們能夠觀察元素並檢測它何時通過滾動容器中的指定點——通常(但不總是)視口——觸發回調函數。

Intersection Observer 可以被認為比在主線程上監聽滾動事件的性能更高,因為它是異步的,並且回調只會在我們正在觀察的元素達到指定閾值時觸發,而不是在每次更新滾動位置時觸發。 在本文中,我們將通過一個示例演示如何使用 Intersection Observer 構建一個固定的標題組件,該組件在與網頁的不同部分相交時會發生變化。

基本用法

要使用 Intersection Observer,我們首先需要創建一個新的觀察者,它接受兩個參數:一個帶有觀察者選項的對象,以及當我們正在觀察的元素(稱為觀察者目標)相交時我們想要執行的回調函數與根(滾動容器,它必須是目標元素的祖先)。

 const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0 } const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry)) } const observer = new IntersectionObserver(callback, options)

當我們創建了我們的觀察者後,我們需要指示它觀察一個目標元素:

 const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)

任何選項值都可以省略,因為它們將回退到它們的默認值:

 const options = { rootMargin: '0px', threshold: 1.0 }

如果沒有指定根,那麼它將被歸類為瀏覽器視口。 上面的代碼示例顯示了rootMarginthreshold的默認值。 這些可能很難想像,因此值得解釋:

rootMargin

rootMargin值有點像向根元素添加 CSS 邊距——並且,就像邊距一樣,可以採用多個值,包括負值。 目標元素將被視為相對於邊距相交。

具有正負根邊距值的滾動根。 假設默認閾值為 1,橙色方塊位於將其歸類為“相交”的點。(大預覽)

這意味著一個元素在技術上可以歸類為“相交”,即使它不在視野範圍內(如果我們的滾動根是視口)。

橙色方塊與根相交,即使它位於可見區域之外。 (大預覽)

rootMargin默認為0px ,但可以採用由多個值組成的字符串,就像在 CSS 中使用margin屬性一樣。

threshold

threshold可以由單個值或 0 到 1 之間的值數組組成。它表示必須在根邊界內才能被視為相交的元素的比例。 使用默認值 1,回調將在 100% 的目標元素在根中可見時觸發。

閾值 1、0 和 0.5 分別導致在 100%、0% 和 50% 可見時觸發回調。 (大預覽)

使用這些選項將元素分類為可見時並不總是很容易可視化。 我構建了一個小工具來幫助掌握 Intersection Observer。

跳躍後更多! 繼續往下看↓

創建標題

現在我們已經掌握了基本原理,讓我們開始構建我們的動態標題。 我們將從一個分為多個部分的網頁開始。 此圖顯示了我們將要構建的頁面的完整佈局:

(大預覽)

我在本文末尾包含了一個演示,所以如果您熱衷於取消選擇代碼,請隨時直接跳到它。 (還有一個 Github 存儲庫。)

每個部分的最小高度為100vh (儘管它們可能會更長,具體取決於內容)。 我們的標題固定在頁面頂部,並在用戶滾動時保持原位(使用position: fixed )。 這些部分有不同的顏色背景,當它們遇到標題時,標題的顏色會發生變化以補充部分的顏色。 還有一個標記來顯示用戶所在的當前部分,當下一個部分到達時它會滑動。 為了讓我們更容易直接獲得相關代碼,我已經設置了一個最小的演示,以我們的起點(在我們開始使用 Intersection Observer API 之前),以防你想跟隨。

標記

我們將從標題的 HTML 開始。 這將是一個帶有主頁鏈接和導航的相當簡單的標題,沒有什麼特別花哨的,但我們將使用幾個數據屬性:標題本身的data-header (因此我們可以使用 JS 定位元素) ,以及三個帶有屬性data-link的錨鏈接,單擊時會滾動用戶到相關部分:

 <header data-header> <nav class="header__nav"> <div class="header__left-content"> <a href="#0">Home</a> </div> <ul class="header__list"> <li> <a href="#about-us" data-link>About us</a> </li> <li> <a href="#flavours" data-link>The flavours</a> </li> <li> <a href="#get-in-touch" data-link>Get in touch</a> </li> </ul> </nav> </header>

接下來是我們頁面其餘部分的 HTML,它分為幾個部分。 為簡潔起見,我只包含了與文章相關的部分,但完整的標記包含在演示中。 每個部分都包含一個指定背景顏色名稱的數據屬性,以及一個與標題中的錨鏈接之一相對應的id

 <main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>

我們將使用 CSS 定位我們的標題,以便它在用戶滾動時保持固定在頁面頂部:

 header { position: fixed; width: 100%; }

我們還將為我們的部分設置一個最小高度,並使內容居中。 (此代碼不是 Intersection Observer 工作所必需的,它只是用於設計。)

 section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }

iframe 警告

在構建此 Codepen 演示時,我遇到了一個令人困惑的問題,我的 Intersection Observer 代碼本完美運行,但未能在交叉點的正確點觸發回調,而是在目標元素與視口邊緣相交時觸發。 經過一番摸索後,我意識到這是因為在 Codepen 中,內容是在 iframe 中加載的,它的處理方式不同。 (有關完整的詳細信息,請參閱 MDN 文檔中關於裁剪和相交矩形的部分。)

作為一種解決方法,在演示中,我們可以將我們的標記包裝在另一個元素中,它將充當滾動容器——我們 IO 選項中的根——而不是我們可能期望的瀏覽器視口:

 <div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>

如果您想了解如何使用視口作為根來代替同一個演示,這包含在 Github 存儲庫中。

CSS

在我們的 CSS 中,我們將為我們使用的顏色定義一些自定義屬性。 我們還將為標題文本和背景顏色定義兩個額外的自定義屬性,並設置一些初始值。 (稍後我們將為不同部分更新這兩個自定義屬性。)

 :root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }

我們將在標題中使用這些自定義屬性:

 header { background-color: var(--headerBg); color: var(--headerText); }

我們還將為不同部分設置顏色。 我將數據屬性用作選擇器,但如果您願意,也可以輕鬆地使用類。

 [data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla); } [data-section="mint"] { background-color: var(--mint); color: var(--chocolate); } [data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate); } [data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla); }

當每個部分都在視圖中時,我們還可以為我們的標題設置一些樣式:

 /* Header */ [data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla); } [data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate); } [data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla); }

在這裡使用數據屬性有一個更強有力的例子,因為我們將在每個交叉點上切換標題的data-theme屬性。

創建觀察者

現在我們已經為我們的頁面設置了基本的 HTML 和 CSS,我們可以創建一個觀察者來觀察我們進入視圖的每個部分。 當我們向下滾動頁面時,我們希望在某個部分與標題底部接觸時觸發回調。 這意味著我們需要設置一個與標題高度相對應的負根邊距。

 const header = document.querySelector('[data-header]') const sections = [...document.querySelectorAll('[data-section]')] const scrollRoot = document.querySelector('[data-scroller]') const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0 }

我們將閾值設置為0 ,因為我們希望它在該部分的任何部分與根邊距相交時觸發。

首先,我們將創建一個回調來更改標頭的data-theme值。 (這比添加和刪除類更直接,尤其是當我們的標題元素可能應用了其他類時。)

 /* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }

然後我們將創建觀察者來觀察相交的部分:

 /* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })

現在,當每個部分遇到標題時,我們應該看到標題顏色更新。

請參閱 Michelle Barker 的 Pen [Happy Face Ice Cream Parlor – Step 2](https://codepen.io/smashingmag/pen/poPgpjZ)。

參見 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Step 2。

但是,您可能會注意到當我們向下滾動時顏色沒有正確更新。 事實上,標題每次都更新為上一節的顏色! 另一方面,向上滾動,它工作得很好。 我們需要確定滾動方向並相應地改變行為。

尋找滾動方向

我們將在 JS 中為滾動方向設置一個變量,初始值為'up' ,另一個用於最後已知的滾動位置( prevYPosition )。 然後,在回調中,如果滾動位置大於之前的值,我們可以將direction值設置為'down' ,反之則設置為'up'

 let direction = 'up' let prevYPosition = 0 const setScrollDirection = () => { if (scrollRoot.scrollTop > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop } const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ }) }

我們還將創建一個新函數來更新標題顏色,將目標部分作為參數傳遞:

 const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }

到目前為止,我們應該看到標題的行為沒有任何變化。 但是現在我們知道了滾動方向,我們可以為我們的updateColors()函數傳入一個不同的目標。 如果滾動方向是向上的,我們將使用入口目標。 如果它失敗了,我們將使用下一部分(如果有的話)。

 const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target } } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) }) }

然而,還有一個問題:標題不僅會在該部分到達標題時更新,而且會在下一個元素在視口底部出現時更新。 這是因為我們的觀察者觸發了兩次回調:一次是在元素進入時,另一次是在元素離開時。

要確定標頭是否應該更新,我們可以使用entry對像中的isIntersecting鍵。 讓我們創建另一個函數來返回一個布爾值,以指示標題顏色是否應該更新:

 const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }

我們將相應地更新我們的onIntersect()函數:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Do nothing if no need to update */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) }) }

現在我們的顏色應該正確更新了。 我們可以設置一個 CSS 過渡,讓效果更好一點:

 header { transition: background-color 200ms, color 200ms; } 

請參閱 Michelle Barker 的鋼筆 [Happy Face Ice Cream Parlor – Step 3](https://codepen.io/smashingmag/pen/bGWEaEa)。

請參閱 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Step 3。

添加動態標記

接下來,我們將在標題中添加一個標記,當我們滾動到不同的部分時更新它的位置。 我們可以為此使用偽元素,因此我們不需要在 HTML 中添加任何內容。 我們將給它一些簡單的 CSS 樣式以將其定位在標題的左上角,並給它一個背景顏色。 我們為此使用currentColor ,因為它將採用標題文本顏色的值:

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }

我們可以為寬度使用自定義屬性,默認值為 0。我們還將為平移 x 值使用自定義屬性。 當用戶滾動時,我們將在回調函數中設置這些值。

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0); }

現在我們可以編寫一個函數來更新交叉點處標記的寬度和位置:

 const updateMarker = (target) => { const id = target.id /* Do nothing if no target ID */ if (!id) return /* Find the corresponding nav link, or use the first one */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Get the values and set the custom properties */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`) }

我們可以在更新顏色的同時調用該函數:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }

我們還需要為標記設置一個初始位置,這樣它就不會突然出現。 加載文檔後,我們將調用updateMarker()函數,使用第一部分作為目標:

 document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })

最後,讓我們添加一個 CSS 過渡,以便標記從一個鏈接滑過標題到下一個鏈接。 當我們轉換width屬性時,我們可以使用will-change使瀏覽器能夠執行優化。

 header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }

平滑滾動

最後,如果用戶單擊鏈接時,他們會平滑地向下滾動頁面,而不是跳轉到該部分,這將是很好的選擇。 這些天我們可以在我們的 CSS 中做這件事,不需要 JS! 為了獲得更易於訪問的體驗,尊重用戶的運動偏好是一個好主意,如果他們沒有在系統設置中指定減少運動的偏好,則僅實施平滑滾動:

 @media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }

最終演示

將上述所有步驟放在一起就可以得到完整的演示。

請參閱 Michelle Barker 的 Pen [Happy Face Ice Cream Parlor – Intersection Observer 示例](https://codepen.io/smashingmag/pen/XWRXVXQ)。

請參閱 Michelle Barker 的 Pen Happy Face Ice Cream Parlor – Intersection Observer 示例。

瀏覽器支持

現代瀏覽器廣泛支持 Intersection Observer。 如有必要,它可以為舊版瀏覽器填充——但我更喜歡在可能的情況下採用漸進式增強方法。 就我們的標頭而言,為不支持的瀏覽器提供一個簡單、不變的版本不會對用戶體驗造成太大損害。

要檢測是否支持 Intersection Observer,我們可以使用以下命令:

 if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Code to execute if IO is supported */ } else { /* Code to execute if not supported */ }

資源

閱讀更多關於 Intersection Observer 的信息:

  • 廣泛的文檔,以及來自 MDN 的一些實際示例
  • Intersection Observer 可視化工具
  • Timing Element Visibility with the Intersection Observer API – MDN 的另一個教程,介紹如何使用 IO 跟踪廣告可見性
  • Denys Mishunov 的這篇文章介紹了 IO 的其他一些用途,包括延遲加載資產。 儘管現在這不是必需的(感謝loading屬性),但這裡仍有很多東西需要學習。