背景知識

為了改善網頁的 load performance,我們很自然地會想到圖片的延遲載入。其精神是避免網站在一開始就載入使用者根本不會看到 (not in viewport) 的圖片,圖片的載入會卡住後面的 network request,所以我們應該要避免載入不必要的圖片。

圖片卡住後面的 network request
可以看到一整排的圖片,推遲了網站需要的 js,像是 jQuery, main.bundle.js

傳統的作法是,我們會監聽網站的 scroll event,然後透過 getBoundingClientRect 看看每一個圖片的 DOMRect 來判斷這個張圖片現在是不是在 viewport 裡面,如果圖片已進入或是即將進入 viewport,我們再去把 src 換上去,把真正的圖片拉回來,放上頁面。

判斷是否進入 viewport 的範例如下:

但傳統作法有幾個缺點

  1. getBoundingClientRect 是所謂的 force reflow/layout 的操作,scroll event 會頻繁的觸發,每次觸發就會強迫瀏覽器 reflow/layout,這是非常昂貴的操作,很可能會引起畫面的卡頓。就算是有對 scroll event 做 debounce,也還是太昂貴了。
     
  2. 現代網站有很多類 native app,左右 swipe 的操作,通常這樣的效果是通過 css 的 transform: translate 來達到的,因為他沒有 scroll,所以不會觸發 scroll event,你也就無從得知什麼時候該檢查元素的 DOMRect 狀態。

Intersection Observer

現在,我們可以利用 Intersection Observer 來做到 image 的 lazy load,同時它不會有上述的問題。

這個 API 可以讓你監控兩個元素間相交的狀態,基本的用法是這樣:
假設你有一個 element A,然後你想知道 element B 什麼時候會跟 element A 有相交,你可以這樣寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const options = {
// 告訴 api 以哪個元素當作底,好來監控其他元素與此相交的狀態
root: document.querySelector('#elementA'),

// 這個 root 的元素是否有 margin,預設是 0px,給了的話當其他元素碰到 root 的 margin 就會觸發 callback
rootMargin: '0px',

// 值為 0 ~ 1 之間,代表其他元素與 root 相交了多少百分比會觸發 callback,1.0 代表 elementB 全部進入了 element A 的範圍會觸發
threshold: 1.0,
};

// 產生 intersection oberserver 的 instance
const observer = new IntersectionObserver(callback, options);

// 定義 callback,每次相交會觸發一次
const callback = function(entries, observer) {
// 相交囉!
entries.forEach(entry => {
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};

// 監控 elementB
const target = document.querySelector('#elementB');
observer.observe(target);

如此一來,我們就可以監控兩個元素之間相交的狀態。當 elementB 進入 elementA 時,就會觸發 callback function

回到 image lazy load

我們需要的是檢查 viewport 跟 image 之間的相交狀況,所以我們可以這樣寫。

1
2
3
4
5
6
7
8
9
const options = {
// 如果 root 不給值,或是給 null,root 就會是你的 viewport,超讚!
root: null,

// 我希望它即將出現在 viewport 之前就觸發 callback,這邊設定它往下滑動時進入 viewport 之前 100px 就觸發去拉圖片
rootMargin: '0px 0px 100px 0px',

threshold: 1.0,
};

這邊重點就在於 root 不給的話,預設就會是你的 viewport,以及使用 rootMargin 去設定傳統上所謂的 threshold。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 監控 image
const $imgList = document.querySelectorAll('img');

const callback = function(entries, observer) {
entries.forEach(function($img) {
if ($img.isisIntersecting) {
// 假設圖片真正的 src 放在它的 data-src 裡
$img.target.src = $img.target.dataset.src;

// 已經換上真正的 src,不用再監控了
observer.unobserve($img.target);
}
});
};

// 註冊監控所有的圖片
$imgList.forEach($img => observer.observe($img));

上面的程式碼在描述,當圖片即將(100px 的 threshold)與使用者的 viewport 相交時,它就會將圖片換上真正的 src,而這個真正的 src 之前是預先存在 data-src 裡。 如此一來就達到我們想要的 image lazy load。

這樣的好處是

  1. 你無須註冊任何的 scroll 或是 resize event,不需要一直頻繁的在你的 main thread 上看元素的 DOMRect,然後觸發一堆 reflow,而是直到元素有相交的時候才會觸發 callback,這對效能來說會增進很多。
    但要注意的是,callback 還是跑在 main thread 上的,所以 callback 裡面做的事情要盡量少,或是放在 [Window.requestIdleCallback()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) 裡面。
  2. IntersectionObserver 也能夠偵測使用 css transform 改變大小或是位置的相交狀態,太棒了!

IntersectionObserver 支援程度

目前主流的瀏覽器大多支持,但要注意 Safari 直到 12.2 才支持。 不支持的瀏覽器我們可以使用 W3C 開發的 polyfill
IntersectionObserver 支援程度

React 方面也有許多現成的 library 可以參考:

Reference