iT邦幫忙

2021 iThome 鐵人賽

DAY 11
0
Modern Web

今晚,我想來點 Web 前端效能優化大補帖!系列 第 11

Day11 X Lazy Loading

還記得昨天 Virtualized List 篇章開頭放的 Facebook demo 影片嗎?有沒有發現我們好像遺漏了什麼功能沒有說明?

先問大家一個問題,你有試著滑 FB 動態,一直滑一直滑,滑到底然後不能再滑動看更多貼文的經驗嗎?我想大多數人應該都沒有吧(包括我自己也沒有,喔對了,這邊專指 FB 的首頁喔!),每當要滑到底的時候應用程式都會再去抓更多的貼文進來,就像個無窮無盡的滾動列表一樣。

沒錯,這樣的 feature 也被稱作 「Infinite Scroll」

不過 Infinite Scroll 跟今天的主題 Lazy Loading 又有什麼關聯呢?答案是它其實算是「實現 Lazy Loading 的一種方式」。

到底什麼是 Lazy Loading ?

Lazy Loading 就是延遲載入,「等到真的要用的時候再載入」。

還記得昨天最後的小反思嗎?昨天我們的方式是把所有的資料一次餵進 Virtualized List 裡面,但使用者很可能只看前面幾筆就不看了,那其他的幾千筆幾萬筆資料不就浪費了嗎?Lazy Loading 就是針對這個問題的解決方案:等到資料真的要用到的時候再載入,避免為了載入一些根本就用不到的資料而浪費網路資源與記憶體。

與其說 Lazy Loading 是一項優化技術,我更偏好於稱它是一種「優化概念」,「等到真的要用的時候再載入」是它的核心思維,至於如何去實作這個概念則是開放的問題。也就是說,其實 Lazy Loading 的實作方式不只一種,今天主要要介紹的形式有:

  • 瀏覽器原生支援的圖片 Lazy Loading
  • 搭配 infinite scroll 的 Data Lazy Loading

有寫過 React 的讀者可能會說,那 React.lazy 這個 React 官方提供的 API 是不是在做 Component Based 的 Lazy Loading?沒有錯,但是關於這個因為涉及到另外一個優化技巧,我會在 Day13 再做說明。
(當然其他框架一定有類似的 API,不過筆者對其他框架真的不熟,所以頻頻拿 React 來當例子,真的萬分抱歉QQ)

瀏覽器原生支援的圖片 Lazy Loading

Day006 的 Image Minimize 段落中有提到圖片佔了網站資源相當大的比例,因此如果在網頁載入的瞬間就想把所有圖片都載入下來對效能是一個硬傷。這時候可以採用 lazy loading 的方法去載入圖片,一開始只需載入部分的圖片例如一開始就會出現在螢幕上或是接近螢幕的圖片,其他圖片可以等頁面滾動後快要出現在 viewport 時再去載入。例如 imgur 這種圖床網站就勢必會做圖片的 lazy loading。

要實現 image 的 lazy load 主要有兩種方式:

先來看看瀏覽器原生支援的部分,未來可能只需要在 img tag 或是 iframe tag 加上 loading=lazy,例如

<img src="image.png" loading="lazy" alt="image alt" width="200" height="200">

就可以自動幫我們做好 lazy loading,讓圖片或 iframe 在要進到 viewport 時才被載入。雖然目前瀏覽器支援度還不太普及,但是相信未來等支援度提升之後一定可以讓工程師的開發體驗提升不少。

loading 的不同屬性:

  • auto: 等於沒有加,瀏覽器會採取預設行為
  • lazy: 當圖片一開始就在 Viewport 內或是靠近 Viewport 時開始載入
  • eager: 不管圖片位置在哪,都馬上開始載入

不是所有圖片都需要 Lazy Loading

如果是一開始就在 viewport 上或是很靠近 viewport 的圖片就不應該做 lazy loading,因為如果要做 Lazy Loading,瀏覽器得先判斷圖片的位置再決定要不要載入,這個空擋應該要拿來載入重要且需要馬上呈現的圖片。

給予 Placeholder,避免影響 CLS,造成不好的使用者體驗

還記得 Day004 的時候提過的 CLS 嗎?
使用 Lazy Loading 的圖片時都應該使用 placeholder 來預先撐開圖片所需要的空間,避免圖片載入完成後造成版面偏移,影響使用者體驗與 CLS 分數。常見的解法如在外層包一個特定大小的 Container:

<img src="ironman.png" width="100" height="100" />
<img src="ironman2.png" width="100" height="100" />
<div class="container">
  <img src="ironman3.png" />
</div>

不過關於 placeholder 的樣式也是一大學問呢!
為了給予用戶更好的使用者體驗,有很多比單純放一個灰色區塊更好的選擇,例如

1. 將原圖的超小版本放大當作 placeholder(會產生模糊感),等圖片載入再切換回原圖,Medium 就是使用這種方式

2. SQIP - A SVG-based LQIP technique

不用等到真的出現在 viewport 才載入

我們已經知道圖片的 Lazy Loading 是把一些需要經過捲動才會到可視區的圖片做延遲載入,但通常實作時不會等到圖片真的出現在 viewport 才開始載入,想想如果使用者的網速很差,他會看到許多圖片緩慢載入的過程,這想必不是一個好的使用者體驗。

所以可以透過加一些 margin 來解決這個問題,例如在圖片距離 viewport 500px 時就開始載入,設定一個適合的 margin 既可以解決上面的問題,也不容易造成浪費網路資源的情形。

Lazy Loading With Intersection Observer

首先要瞭解到可以被延遲載入的不是只有圖片而已,資料也是可以被延遲載入的,像是臉書的動態牆就是一個經典例子。

今天要介紹的另一種 Lazy Loading 形式為「Infinite Scroll 的 Lazy Loading」。infinite scroll 的特色在於要不停的去載入新的資料,讓使用者有永遠都滑不到底的使用者體驗。而實作 infinite scroll 的重點就在找出「什麼時候」要去載入新的資料。

infinite scroll 載入更多資料的時機應該蠻好理解的,就是當原有資料的最後幾個 item 出現在螢幕可視區或是快要出現在可視區時(代表現有資料快要用完了),就去抓取新的資料。(以臉書動態為例子可能比較好了解,但現有的最後一則動態出現在螢幕上時,就應該要去載入新的動態了)

有人可能會提問說,為什麼不要再更早一點就去載入新的資料,比方說現有資料滑到一半就去抓更多資料。其中幾個原因是這樣可能造成網路資源的浪費,也許使用者根本不會再往下滑,但你卻花了額外的網路請求去抓取了新的資料。再來以使用者體驗的角度來說,如果使用者可以看到資料正在載入的過程(例如本篇貼的臉書與 imgur 的影片中都有 placeholder 區塊來提示使用者正在載入新的資源),比起在使用者不知情的狀況下載入更多資源且讓使用者可以不間斷一直滑動,有給予使用者正在抓取更多貼文的提示會是一個更好的使用者體驗。

想要得知決定要不要載入更多資料的 item 有沒有出現在 viewport 裡,我們可以利用 Intersection Observer 這個 Web API 來達成。

Basic Introduction Of Intersection Observer Web API

簡單來說它的作用就是「能夠監聽目標元素在畫面上出現或離開的時機,並執行我們給予它的 callback function。」

在過去要實作 intersection detection 這樣的功能,可能得透過事件監聽加上 looping 去執行 Element.getBoundingClientRect() 之類的 API 才能得到想要的資訊,不過這種方式會讓 main thread 太過繁忙,因此有可能會造成一些效能瓶頸。Intersection Observer API 的出現,不僅讓實作 intersection detection 變得容易,同時也能減輕 main thread 的負擔。

How To Use It ?

老樣子,用最簡單的方式來 demo 一下用法。

首先在頁面中放一個 element 在需要透過滾動才能被看到的位置。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="demo-box">DEMO BOX</div>

    <script src="index.js"></script>
  </body>
</html>

index.css

body {
  height: 5000px;
}

/* 放在有點下面的位置 */
.demo-box {
  width: 100px;
  height: 100px;
  background: #003456;
  position: relative;
  top: 2000px;
  color: white;
}

JavaScript 的部分先建立 IntersectionObserver 的 instance 後傳進我們指定的 callback function,這個 callback function 可以接收一個 IntersectionObserver 丟回來的 Array 型別的參數,Array 內裝著的是所有正在監聽的元素,我們可以從這些元素裡的 isIntersecting attribute 來判斷當前的元素是進入 viewport,還是離開 viewport 了。
(這裡的 viewport 不一定是指整個螢幕,也可以是我們自己指定的區塊,例如包在列表外的 Virtualized List 的可視區域)

接著使用 IntersectionObserver 的 observe method,把想要監聽的 DOM Element 當作參數傳給它,就可以註冊對該元素的監聽器。

index.js

const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log('demo box 進到 viewport了!');
  } else {
    console.log('demo box 離開 viewport了!');
  }
});

intersectionObserver.observe(document.querySelector('.demo-box'));

跑起來的結果會長這樣:

另外除了 callback 之外,new IntersectionObserver 還可以帶入第二個 options 物件參數:

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

root 讓開發者可以自行指定檢查目標元素有沒有出現的 viewport 範圍,如果沒有指定的話預設會是整個 browser 的 viewport。

threshold 這個屬性可以讓我們指定當目標元素的多少比例進到 viewport 時要觸發 callback function。在都不指定的狀況下 threshold 預設會是 0,代表只要目標元素有任何一點 pixel 出現在 viewport 都會觸發回呼,如果設為 1.0 則代表整個目標元素都顯示在 viewport 上時才會觸發 callback function。

threshold 除了傳入數字以外,還能傳入一個 Array。如果你想要目標元素每多出現 25% 的部分到 viewport 上,就呼叫一次 callback,那我們可以這樣寫:

let options = {
  threshold: [0, 0.25, 0.5, 0.75, 1],
};

const intersectionObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    console.log('demo box 進到 viewport了!');
  } else {
    console.log('demo box 離開 viewport了!');
  }
}, options);

intersectionObserver.observe(document.querySelector('.demo-box'));

可以看到藍色目標元素每過 25% 的高度,就會觸發一次 console.log 的回呼

我們都知道使用 event listener 的時候要養成好習慣把用不到的監聽器移除,避免影響效能。intersectionObserver 也是如此,可以透過 unobserve method 來移除監聽

intersectionObserver.unobserve(
  document.querySelector('.demo-box')
);

Intersection Observer Web API 當然沒有那麼簡單(基本上大部分的 Web API 都是這樣,我們不太可能記起所有 properties 與 methods,都是以解決當前需求為優先),不過目前所學已經足以應付簡單的 Lazy Loading 需求了,想更深入了解的讀者就再自行研究文件囉!

附上簡單的 demo code: https://github.com/kylemocode/it-ironman-2021/tree/master/lazy-loading-demo

Demo Time - Virtualized List + Lazy Loading

為了快速 Demo,我決定以之前應徵實習的作業考題為範例, Github Repo: https://github.com/kylemocode/dcard-reader
(Dependencies 中有些 potential security vulnerabilities,因為是一年半以前做的 project,就沒有特別去修了)

題目為串接 Dcard 的 API 實現可以無限捲動的文章列表(需使用 React 開發),專案執行後畫面如下:

為什麼要搭配 Virtualized List ?

這個實習作業的要求只有說當滾動到最下面時,要去載入更多的文章,很明顯是在指 infinite scroll。不過當我看到題目需求的時候第一個想法就是 - Virtualized List 是作業合不合格的關鍵。

像 Dcard 這種社群平台,需要呈現大量的貼文讓使用者觀看,雖然可以透過 Lazy Loading 延遲載入資料,但是如果已載入文章的 DOM 元件都一直存在於頁面上,當數量一多一定會造成效能瓶頸,導致頁面變得卡頓。除此之外,使用 Virtualized List 也可以降低初次渲染的工作量,降低初頻渲染時間。因此我才會說有沒有想到 windowing 技術是作業合不合格的關鍵,考驗的是面對長列表時有沒有考量到效能瓶頸的 sense。

在這個專案中我選擇使用了第三方 Virtualized List 套件:React-Window,比起昨天實作的簡易版本多了非常多功能,有興趣的讀者可以再自行研究一下。

看看剛認識的新朋友 Intersection Observer 怎麼發揮作用

雖然看起來稍微複雜了點,不過其實只是透過 Intersection Observer 去監聽當前貼文資料的最後一則貼文有沒有出現在螢幕上,有的話就更新 state 去記錄當前最後一個貼文的 id,因為通常有做 pagination 的 API 的格式會是這樣

BASE_URL + '&limit=' + LIMIT + '&before=' + lastId

透過更新 lastId,在重新 call API 資料的時候就可以指定要從哪個 item 之後開始拿,一次抓取的數量則可以用 LIMIT 指定。

測試一下 Frame Rate

透過 Chrome Devtool 的 FPS meter,我們可以檢測頁面的 frame rate 與 memory 使用量。來看看 MDN 官方怎麼描述 frame rate 吧

幀速率是一個網站的響應的量度。「低或不一致」的幀速率可以使一個網站出現反應遲鈍或 janky,鬧出不好的使用者體驗。

60fps 的幀頻被算為是平穩的性能目標,給你所有的需要在應對某些事件做出同步更新16.7毫秒的時間預算。

這個 Demo Project 在滾動時可以維持在接近 60fps 且十分穩定的 frame rate,使用者體驗與動畫品質都還不錯。

最後來比較一下使用 Virtualized List 前後的差別:

從上圖可以看到使用 windowing 技術渲染有一萬個 items 的列表相比沒有優化的版本有幾個顯著的提升:

  • Frame Rate 可以維持在 60 fps 左右
  • Memory 用量低了許多
  • Initial Render 花費的時間差了將近 100 倍

本日小結

昨天介紹了 Virtualized List 的觀念,了解到 Windowing 技術對於渲染大量列表的重要性。不過通常渲染長列表的資料還可以透過 Lazy Loading 來減少流量與記憶體的浪費。

而要實現 Lazy Loading 最重要的是知道要載入更多資料的時機。我們可以透過 Intersection Observer 去監聽目標元素是不是出現在我們指定的 viewport 裡,再進而去載入更多的資料。

今日的最後也以一個過去的 Side Project 當作 Demo,看完後讀者們應該也可以感受到 Virtualized List 加上 Lazy Loading 對於頁面效能的影響。

話說這兩天寫 JavaScript 是不是有點寫膩了呢?明天我們換個心境,來寫點 CSS。不過不是隨便亂寫喔,我們要來寫一些 「High Performance 的 CSS」,大家明天見!

References & 圖片來源

https://calibreapp.com/blog/investigate-animation-performance-with-devtools

https://addyosmani.com/blog/react-window/

https://medium.com/starbugs/%E7%94%A8%E5%8E%9F%E7%94%9F%E7%9A%84-javascript-intersection-observer-api-%E5%AF%A6%E7%8F%BE-lazy-loading-6bedccd0950


上一篇
Day10 X 實作一個簡單的 Virtualized List 吧!
下一篇
Day12 X Writing High Performance CSS
系列文
今晚,我想來點 Web 前端效能優化大補帖!30

尚未有邦友留言

立即登入留言