現在你看到我了:如何延遲、延遲加載和使用 IntersectionObserver 採取行動
已發表: 2022-03-10曾幾何時,有一位 Web 開發人員成功地說服了他的客戶,網站不應該在所有瀏覽器中看起來都一樣,關心可訪問性,並且是 CSS 網格的早期採用者。 但在他內心深處,表現才是他真正的熱情所在:他不斷優化、縮小、監控,甚至在他的項目中使用心理技巧。
然後,有一天,他了解到延遲加載圖像和其他資產,這些資產不會立即對用戶可見,並且對於在屏幕上呈現有意義的內容不是必不可少的。 這是黎明的開始:開發人員進入了延遲加載 jQuery 插件的邪惡世界(或者可能是async
和defer
屬性的不那麼邪惡的世界)。 甚至有人說他直接進入了所有邪惡的核心: scroll
事件偵聽器的世界。 我們永遠無法確定他最終去了哪裡,但話說回來,這個開發者絕對是虛構的,與任何開發者的任何相似之處都只是巧合。
好吧,你現在可以說潘多拉的盒子已經打開了,我們虛構的開發者並沒有讓這個問題變得不那麼真實。 如今,從速度和頁面權重的角度來看,優先考慮首屏內容對於我們的 Web 項目的性能變得非常重要。
在這篇文章中,我們將走出scroll
的黑暗,談論現代的延遲加載資源的方式。 不僅僅是延遲加載圖像,而是加載任何資產。 更重要的是,我們今天要討論的技術不僅僅是延遲加載資產:我們將能夠根據元素對用戶的可見性提供任何類型的延遲功能。
女士們先生們,讓我們來談談 Intersection Observer API。 但在我們開始之前,讓我們看一下將我們IntersectionObserver
的現代工具的前景。
2017 年對於我們的瀏覽器內置工具來說是非常好的一年,幫助我們提高代碼庫的質量和風格,而無需付出太多努力。 如今,Web 似乎正在從基於非常不同的零星解決方案轉向非常典型的解決方案,轉向更明確定義的 Observer 接口方法(或只是“觀察者”):得到良好支持的 MutationObserver 很快就獲得了新的家庭成員在現代瀏覽器中採用:
- IntersectionObserver 和
- PerformanceObserver(作為 Performance Timeline Level 2 規範的一部分)。
另一個潛在的家庭成員 FetchObserver 正在進行中,它將引導我們更多地進入網絡代理領域,但今天我想更多地談談前端。
PerformanceObserver
和IntersectionObserver
旨在幫助前端開發人員在不同點提高項目的性能。 前者為我們提供了真實用戶監控的工具,而後者是工具,為我們提供了切實的性能改進。 如前所述,本文將詳細介紹後者: IntersectionObserver 。 為了特別了解IntersectionObserver
的機制,我們應該看看通用的 Observer 應該如何在現代網絡中工作。
專業提示:您可以跳過理論並立即深入了解 IntersectionObserver 的機制,或者更進一步,直接了解IntersectionObserver
的可能應用。
觀察者與事件
顧名思義,“觀察者”旨在觀察頁面上下文中發生的事情。 觀察者可以觀察頁面上發生的事情,比如 DOM 變化。 他們還可以監視頁面的生命週期事件。 觀察者也可以運行一些回調函數。 現在細心的讀者可能會立即發現這裡的問題並問:“那麼,重點是什麼? 我們不是已經為此目的舉辦了活動嗎? 是什麼讓觀察者與眾不同?” 很好的一點! 讓我們仔細看看並整理一下。
常規 Event 和 Observer 之間的關鍵區別在於,默認情況下,前者對 Event 的每次發生都進行同步響應,影響主線程的響應能力,而後者應該異步響應,不會對性能造成太大影響。 至少,對於當前呈現的觀察者來說是這樣的:它們都是異步的,我認為這在未來不會改變。
這導致了處理 Observers 回調的主要區別可能會讓初學者感到困惑:Observers 的異步性質可能導致多個 observables 被同時傳遞給回調函數。 正因為如此,回調函數不應該期待一個單一的條目,而是一個條目Array
(即使有時數組中只包含一個條目)。
此外,一些觀察者(尤其是我們今天討論的那個)提供了非常方便的預計算屬性,否則,我們在使用常規事件時使用昂貴的(從性能角度)方法和屬性來計算自己。 為了澄清這一點,我們將在本文稍後的示例中找到一個示例。
因此,如果有人很難擺脫事件範式,我會說觀察者是類固醇上的事件。 另一種描述是:觀察者是事件之上的一個新的近似水平。 但無論您喜歡哪種定義,不用說,觀察者並不是要取代事件(至少現在還沒有); 兩者都有足夠的用例,他們可以愉快地並肩生活。
通用觀察者的結構
觀察者的通用結構(在撰寫本文時可用的任何結構)看起來類似於以下內容:
/** * Typical Observer's registration */ let observer = new YOUR-TYPE-OF-OBSERVER(function (entries) { // entries: Array of observed elements entries.forEach(entry => { // Here we can do something with each particular entry }); }); // Now we should tell our Observer what to observe observer.observe(WHAT-TO-OBSERVE);
再次注意, entries
是一個值Array
,而不是單個條目。
這是通用結構:特定觀察者的實現在傳遞給它的observe()
的參數和傳遞給它的回調的參數方面有所不同。 例如MutationObserver
還應該獲取一個配置對象,以了解更多關於要觀察的 DOM 中的哪些變化。 PerformanceObserver
不觀察 DOM 中的節點,而是具有可以觀察的專用條目類型集。
到這裡,讓我們結束本次討論的“通用”部分,深入探討今天文章的主題IntersectionObserver
。
解構 IntersectionObserver
首先我們來梳理一下IntersectionObserver
是什麼。
根據 MDN:
Intersection Observer API 提供了一種異步觀察目標元素與祖先元素或頂級文檔視口的交集變化的方法。
簡單地說, IntersectionObserver
異步觀察一個元素與另一個元素的重疊。 讓我們談談IntersectionObserver
中這些元素的用途。
IntersectionObserver 初始化
在前面的一段中,我們已經看到了通用 Observer 的結構。 IntersectionObserver
稍微擴展了這個結構。 首先,這種類型的 Observer 需要具有三個主要元素的配置:
-
root
:這是用於觀察的根元素。 它定義了可觀察元素的基本“捕獲框架”。 默認情況下,root
是瀏覽器的視口,但實際上可以是 DOM 中的任何元素(然後將root
設置為document.getElementById('your-element')
類的東西)。 請記住,在這種情況下,您要觀察的元素必須“存在”在root
的 DOM 樹中。
-
rootMargin
:定義root
元素周圍的邊距,當您的root
元素的尺寸不能提供足夠的靈活性時,它會擴展或縮小“捕獲框架”。 該配置的取值選項與 CSS 中的margin
選項類似,例如rootMargin: '50px 20px 10px 40px'
(top, right bottom, left)。 這些值可以簡寫(如rootMargin: '50px'
)並且可以用px
或%
表示。 默認情況下,rootMargin: '0px'
。
-
threshold
:當觀察到的元素與“捕獲框架”(定義為root
和rootMargin
的組合)的邊界相交時,並不總是希望立即做出反應。threshold
定義了觀察者應該做出反應的交叉點的百分比。 它可以定義為單個值或值數組。 為了更好地理解threshold
的影響(我知道它有時可能會令人困惑),這裡有一些例子:-
threshold: 0
:默認值IntersectionObserver
應在觀察元素的第一個或最後一個像素與“捕獲幀”的邊界之一相交時做出反應。 請記住,IntersectionObserver
與方向無關,這意味著它會在兩種情況下做出反應:a)當元素進入時和 b)當它離開“捕獲框架”時。 -
threshold: 0.5
:當觀察到的元素的 50% 與“捕獲框架”相交時,應觸發觀察者; -
threshold: [0, 0.2, 0.5, 1]
:觀察者應該在 4 種情況下做出反應:- 觀察到的元素的第一個像素進入“捕獲框”:元素仍然不在該框內,或者觀察到的元素的最後一個像素離開“捕獲框”:元素不再在框內;
- 20% 的元素在“捕獲框架”內(同樣,方向對於
IntersectionObserver
並不重要); - 50% 的元素在“捕獲框”內;
- 100% 的元素都在“捕獲框架”內。 這與
threshold: 0
嚴格相反。
-
為了通知我們的IntersectionObserver
我們需要的配置,我們只需將config
對象與回調函數一起傳遞給 Observer 的構造函數,如下所示:
const config = { root: null, // avoiding 'root' or setting it to 'null' sets it to default value: viewport rootMargin: '0px', threshold: 0.5 }; let observer = new IntersectionObserver(function(entries) { … }, config);
現在,我們應該給IntersectionObserver
實際要觀察的元素。 這只需將元素傳遞給observe()
函數即可完成:
… const img = document.getElementById('image-to-observe'); observer.observe(image);
關於這個觀察到的元素有幾點需要注意:
- 前面已經提到過,但值得再次提及:如果您將
root
設置為 DOM 中的元素,則觀察到的元素應該位於root
的 DOM 樹中。 -
IntersectionObserver
一次只能接受一個元素進行觀察,不支持批量供應觀察。 這意味著如果您需要觀察多個元素(比如說頁面上的多個圖像),您必須遍歷所有元素並分別觀察它們中的每一個:
… const images = document.querySelectorAll('img'); images.forEach(image => { observer.observe(image); });
- 當加載帶有 Observer 的頁面時,您可能會注意到
IntersectionObserver
的回調已同時針對所有觀察到的元素觸發。 即使是那些與提供的配置不匹配的。 “嗯……不是我所期望的,”這是第一次遇到這種情況時通常的想法。 但是不要在這裡混淆:這並不一定意味著這些觀察到的元素在頁面加載時會以某種方式與“捕獲框架”相交。
但這意味著,該元素的條目已初始化,現在由您的IntersectionObserver
控制。 不過,這可能會給您的回調函數增加不必要的噪音,並且您有責任檢測哪些元素確實與“捕獲框架”相交以及我們仍然不需要考慮哪些元素。 要了解如何進行檢測,讓我們更深入地了解回調函數的結構,並看看這些條目是由什麼組成的。
IntersectionObserver 回調
首先, IntersectionObserver
的回調函數有兩個參數,我們將從第二個參數開始以相反的順序討論它們。 除了前面提到的觀察條目Array
,與我們的“捕獲幀”相交,回調函數將觀察者本身作為第二個參數。
參考觀察者本身
new IntersectionObserver(function(entries, SELF) {…});
當您想在IntersectionObserver
第一次檢測到某個元素後停止觀察某個元素時,獲取對 Observer 本身的引用在很多情況下很有用。 圖像的延遲加載、其他資產的延遲獲取等場景屬於這種情況。 當你想停止觀察一個元素時, IntersectionObserver
提供了一個unobserve(element-to-stop-observing)
方法,該方法可以在對觀察到的元素執行一些操作後在回調函數中運行(例如實際延遲加載圖像,例如)。
其中一些場景將在本文中進一步回顧,但我們不再討論第二個論點,讓我們來看看這個回調劇的主要參與者。
IntersectionObserverEntry
new IntersectionObserver(function(ENTRIES, self) {…});
我們在回調函數中作為Array
獲取的entries
屬於特殊類型: IntersectionObserverEntry
。 這個接口為我們提供了一組關於每個特定觀察元素的預定義和預計算的屬性。 讓我們來看看最有趣的。
首先, IntersectionObserverEntry
類型的條目帶有關於三個不同矩形的信息——定義過程中涉及的元素的坐標和邊界:
-
rootBounds
:“捕獲幀”的矩形(root
+rootMargin
); -
boundingClientRect
:被觀察元素本身的矩形; -
intersectionRect
:被觀察元素相交的“捕獲框”區域。
異步計算這些矩形的真正酷之處在於,它為我們提供了與元素定位相關的重要信息,而無需我們調用getBoundingClientRect()
、 offsetTop
、 offsetLeft
和其他昂貴的定位屬性和方法來觸發佈局抖動。 純粹以性能取勝!
我們感興趣的IntersectionObserverEntry
接口的另一個屬性是isIntersecting
。 這是一個方便屬性,指示觀察到的元素當前是否與“捕獲框架”相交。 當然,我們可以通過查看intersectionRect
來獲取此信息(如果此矩形不是 0×0,則該元素與“捕獲框”相交),但是為我們預先計算此信息非常方便。
isIntersecting
可用於找出觀察到的元素是剛剛進入“捕獲框架”還是已經離開它。 要找出這一點,將此屬性的值保存為全局標誌,當此元素的新條目到達您的回調函數時,將其新的isIntersecting
與該全局標誌進行比較:
- 如果它是
false
現在是true
,那麼元素正在進入“捕獲框”; - 如果它是相反的並且它現在是
false
的而之前它是true
,那麼這個元素正在離開“捕獲框架”。
isIntersecting
正是幫助我們解決前面討論的問題的屬性,即將真正與“捕獲幀”相交的元素的條目與那些僅僅是條目初始化的噪音分開。
let isLeaving = false; let observer = new IntersectionObserver(function(entries) { entries.forEach(entry => { if (entry.isIntersecting) { // we are ENTERING the "capturing frame". Set the flag. isLeaving = true; // Do something with entering entry } else if (isLeaving) { // we are EXITING the "capturing frame" isLeaving = false; // Do something with exiting entry } }); }, config);
注意:在 Microsoft Edge 15 中,未實現isIntersecting
屬性,儘管完全支持IntersectionObserver
,但仍返回undefined
。 不過,這已在 2017 年 7 月得到修復,並且自 Edge 16 起可用。
IntersectionObserverEntry
接口提供了另外一個預先計算的便利屬性: intersectionRatio
。 此參數可用於與isIntersecting
相同的目的,但由於它是浮點數而不是布爾值,因此提供了更精細的控制。 intersectionRatio
的值表示被觀察元素的區域有多少與“捕獲框”相交( intersectionRect
區域與boundingClientRect
區域的比率)。 同樣,我們可以使用來自這些矩形的信息自己進行此計算,但為我們完成它是件好事。
target
是IntersectionObserverEntry
接口的另一個屬性,您可能需要經常訪問它。 但這裡絕對沒有魔法——它只是傳遞給 Observer 的observe()
函數的原始元素。 就像你在處理事件時已經習慣的event.target
一樣。
要獲取IntersectionObserverEntry
接口的完整屬性列表,請檢查規範。
可能的應用
我意識到您很可能正是因為這一章才閱讀這篇文章:畢竟,當我們有用於復制和粘貼的代碼片段時,誰會關心機制? 所以現在不會再用更多的討論來打擾您了:我們正在進入代碼和示例的領域。 我希望代碼中包含的註釋能讓事情更清楚。
延遲功能
首先,讓我們回顧一個示例,該示例揭示了IntersectionObserver
思想的基本原理。 假設您有一個元素,一旦它出現在屏幕上,就必須進行大量計算。 例如,您的廣告應僅在實際向用戶展示時才註冊一次瀏覽。 但是現在,讓我們假設您在頁面第一個屏幕下方的某處有一個自動播放的輪播元素。
一般來說,運行輪播是一項繁重的任務。 通常,它涉及 JavaScript 計時器、自動滾動元素的計算等。所有這些任務都加載主線程,當它在自動播放模式下完成時,我們很難知道我們的主線程什麼時候受到這個打擊。 當我們談論在我們的第一個屏幕上優先考慮內容並希望盡快點擊 First Meaningful Paint 和 Time To Interactive 時,阻塞的主線程成為我們性能的瓶頸。
為了解決這個問題,我們可能會推遲播放這樣的輪播,直到它進入瀏覽器的視口。 對於這種情況,我們將把我們的知識和示例用於IntersectionObserverEntry
接口的isIntersecting
參數。
const carousel = document.getElementById('carousel'); let isLeaving = false; let observer = new IntersectionObserver(function(entries) { entries.forEach(entry => { if (entry.isIntersecting) { isLeaving = true; entry.target.startCarousel(); } else if (isLeaving) { isLeaving = false; entry.target.stopCarousel(); } }); } observer.observe(carousel);
在這裡,我們僅在輪播進入我們的視口時才播放它。 注意沒有傳遞給IntersectionObserver
初始化的config
對象:這意味著我們依賴默認配置選項。 當輪播離開我們的視口時,我們應該停止播放它,以免將資源花在不再重要的元素上。
延遲加載資產
這可能是IntersectionObserver
最明顯的用例:我們不想花費資源來下載用戶現在不需要的東西。 這將為您的用戶帶來巨大的好處:用戶無需下載,他們的移動設備也無需解析和編譯大量他們目前不需要的無用信息。 毫不奇怪,它還將有助於提高應用程序的性能。
以前,為了推遲下載和處理資源,直到用戶可能在屏幕上看到它們,我們正在處理像scroll
這樣的事件的事件偵聽器。 問題很明顯:這太頻繁地觸發了聽眾。 所以我們必須想出限製或去抖動回調執行的想法。 但是所有這些都給我們的主線程增加了很大的壓力,在我們最需要的時候阻塞它。
那麼,回到延遲加載場景中的IntersectionObserver
,我們應該注意什麼? 讓我們看一個延遲加載圖像的簡單示例。
嘗試將該頁面緩慢滾動到“第三屏”,並觀察右上角的監控窗口:它會告訴您到目前為止已經下載了多少張圖片。
在這個任務的 HTML 標記的核心中放置了一個簡單的圖像序列:
… <img data-src="https://blah-blah.com/foo.jpg"> …
如您所見,圖像應該沒有src
標籤:一旦瀏覽器看到src
屬性,它將立即開始下載該圖像,這與我們的意圖相反。 因此,我們不應該將該屬性放在 HTML 中的圖像上,相反,我們可能會依賴一些data-
屬性,例如data-src
。
這個解決方案的另一部分當然是 JavaScript。 讓我們關注這裡的主要部分:
const images = document.querySelectorAll('[data-src]'); const config = { … }; let observer = new IntersectionObserver(function (entries, self) { entries.forEach(entry => { if (entry.isIntersecting) { … } }); }, config); images.forEach(image => { observer.observe(image); });
在結構方面,這裡沒有什麼新東西:我們之前已經介紹過所有這些:
- 我們使用
data-src
屬性獲取所有消息; - 設置
config
:對於這種情況,您希望擴展“捕獲框架”以檢測低於視口底部的元素; - 使用該配置註冊
IntersectionObserver
; - 遍歷我們的圖像並添加所有這些圖像以供此
IntersectionObserver
觀察;
有趣的部分發生在對條目調用的回調函數中。 涉及三個基本步驟。
首先,我們只處理真正與我們的“捕獲框架”相交的項目。 這段代碼你現在應該很熟悉了。
entries.forEach(entry => { if (entry.isIntersecting) { … } });
然後,我們通過將帶有
data-src
的圖像轉換為真實的<img src="…">
以某種方式處理條目。if (entry.isIntersecting) { preloadImage(entry.target); … }
preloadImage()
是一個非常簡單的函數,這裡不值一提。 只需閱讀源代碼。下一步也是最後一步:由於延遲加載是一次性操作,我們不需要每次元素進入“捕獲框架”時都下載圖像,我們應該不
unobserve
已經處理的圖像。 當我們不再需要常規事件以防止代碼中的內存洩漏時,我們應該使用element.removeEventListener()
來處理常規事件。if (entry.isIntersecting) { preloadImage(entry.target); // Observer has been passed as
self
to our callback self.unobserve(entry.target); }
筆記。 除了unobserve(event.target)
我們還可以調用disconnect()
:它完全斷開我們的IntersectionObserver
並且不再觀察圖像。 如果您唯一關心的是觀察者的第一次命中,這將很有用。 在我們的例子中,我們需要 Observer 來持續監控圖像,所以我們現在不應該斷開連接。
隨意分叉示例並使用不同的設置和選項。 當您特別想延遲加載圖像時,有一件有趣的事情要提。 您應該始終牢記由觀察到的元素生成的框! 如果你檢查這個例子,你會注意到第 41-47 行圖像的 CSS 包含所謂的冗餘樣式,包括。 min-height: 100px
。 這樣做是為了給圖像佔位符( <img>
沒有src
屬性)一些垂直尺寸。 做什麼的?
- 如果沒有垂直尺寸,所有
<img>
標籤都會生成 0×0 框; - 由於
<img>
標籤默認生成某種inline-block
框,所有這些 0×0 框將在同一行並排對齊; - 這意味著您的
IntersectionObserver
將一次註冊所有(或者,取決於您滾動的速度,幾乎所有)圖像 - 可能不是您想要實現的。
當前部分的突出顯示
當然, IntersectionObserver
不僅僅是延遲加載。 這是用這種技術替換scroll
事件的另一個例子。 在這個例子中,我們有一個非常常見的場景:在固定導航欄上,我們應該根據文檔的滾動位置突出顯示當前部分。
在結構上,它類似於延遲加載圖像的示例,並且具有相同的基本結構,但有以下例外:
- 現在我們要觀察的不是圖像,而是頁面上的部分;
- 顯然,我們還有一個不同的函數來處理回調中的條目(
intersectionHandler(entry)
)。 但是這個並不有趣:它所做的只是切換 CSS 類。
這裡有趣的是config
對象:
const config = { rootMargin: '-50px 0px -55% 0px' };
你問,為什麼rootMargin
的默認值不是0px
? 好吧,僅僅因為突出顯示當前部分和延遲加載圖像在我們試圖實現的目標上完全不同。 使用延遲加載,我們希望在圖像進入視圖之前開始加載。 因此,為了這個目的,我們在底部將“捕獲幀”擴展了 50 像素。 相反,當我們想要突出顯示當前部分時,我們必須確保該部分實際上在屏幕上可見。 不僅如此:我們必須確保用戶實際上正在閱讀或將要閱讀本節。 因此,我們希望一個部分在我們可以將其聲明為活動部分之前從底部開始超過視口的一半。 此外,我們要考慮導航欄的高度,因此我們將導航欄的高度從“捕獲框架”中移除。
另外,請注意,在突出顯示當前導航項的情況下,我們不想停止觀察任何內容。 在這裡,我們應該始終讓IntersectionObserver
負責,因此您不會在這裡找到disconnect()
和unobserve()
。
概括
IntersectionObserver
是一種非常直接的技術。 它在現代瀏覽器中有很好的支持,如果你想為仍然(或根本不)支持它的瀏覽器實現它,當然有一個 polyfill。 但總而言之,這是一項很棒的技術,它允許我們做各種與檢測視口中的元素相關的事情,同時幫助實現非常好的性能提升。
為什麼 IntersectionObserver 對您有好處?
-
IntersectionObserver
是一個異步非阻塞 API! -
IntersectionObserver
在scroll
或resize
事件時替換了我們昂貴的偵聽器。 -
IntersectionObserver
會為您完成所有昂貴的計算,例如getClientBoundingRect()
,這樣您就不需要了。 -
IntersectionObserver
遵循其他觀察者的結構模式,因此理論上,如果您熟悉其他觀察者的工作方式,應該很容易理解。
要記住的事情
如果我們將 IntersectionObserver 的功能與所有來自它的window.addEventListener('scroll')
的世界進行比較,很難看出這個 Observer 的任何缺點。 所以,讓我們只注意一些要記住的事情:
- 是的,
IntersectionObserver
是一個異步非阻塞 API。 很高興知道這一點! 但更重要的是要了解,即使 API 本身是異步的,您在回調中運行的代碼默認也不會異步運行。 因此,如果您的回調函數的計算使主線程無響應,仍然有機會消除您從IntersectionObserver
獲得的所有好處。 但這是一個不同的故事。 - 如果您使用
IntersectionObserver
延遲加載資產(例如圖像),請在加載資產後運行.unobserve(asset)
。 IntersectionObserver
只能檢測出現在文檔格式結構中的元素的交集。 明確一點:可觀察元素應該生成一個盒子並以某種方式影響佈局。 以下只是一些示例,可以讓您更好地理解:- 有
display: none
是不可能的; -
opacity: 0
或visibility:hidden
確實創建了盒子(即使是不可見的),所以它們會被檢測到; -
width:0px; height:0px
width:0px; height:0px
很好。 Though, it has to be noted that absolutely positioned elements fully positioned outside of parent's borders (with negative margins or negativetop
,left
, etc.) and are cut out by parent'soverflow: hidden
won't be detected: their box is out of scope for the formatting structure.
- 有
I know it was a long article, but if you're still around, here are some links for you to get an even better understanding and different perspectives on the Intersection Observer API:
- Intersection Observer API on MDN;
- IntersectionObserver polyfill;
- IntersectionObserver polyfill as
npm
module; - Lazy-Loading Images with IntersectionObserver [video] by amazing Paul Lewis;
- Basic and short (just 01:39), but very informative introduction to IntersectionObserver [video] by Surma.
With this, I would like to make a pause in our discussion to give you an opportunity to play with this technology and realize all of its convenience. So, go play with it. The article is finally over. This time I really mean it.