前端性能精進之瀏覽器(三)——圖像

  HTTP Archive 在 2022 年關於多媒體的報告中指出,目前大概有 99.9% 的網站或多或少都會包含點圖像。

  並且高達 70% 的移動頁面和 80% 的桌面頁面的 LCP 指標會受圖像的影響。

  通過這些數據可知,圖像在網頁中佔據着舉足輕重的地位,優化圖像,對於網頁性能可以達到立竿見影的效果。

  優化的核心是控制圖像的尺寸,提前、延遲或減少圖像的請求,以及降低對核心 Web 指標的影響。

  本文所用的示例代碼已上傳至 Github

一、請求

  以我目前的公司爲例,活動頁中圖像的請求數佔比最高可達 64%。

  若不做優化處理,那麼將直接拉長頁面的白屏時間,體驗將會及其糟糕。

1)懶加載

  懶加載就是延遲請求的時機,在觸發某個特定條件後,再請求。

  常用的條件是當圖像出現在屏幕內時,觸發請求。

  當頁面很長時,並不需要在頁面首屏加載時就請求所有圖像,而是滾動到圖像所在位置後,再將圖像顯示,如下圖所示。

  目前有 3 種方式來實現懶加載。那麼在正式講解之前,需要先了解一下視口的概念。

  視口(viewport)就是下圖中的灰色部分,也就是文檔內容的可視區域,圖中用粗線框住的是瀏覽器的外殼部分(如標籤頁、書籤欄、調試工具等)。

  

  先來講解第 1 種懶加載:傳統的 JavaScript 實現,原理就是計算圖像頂部到視口頂部的距離,包括頁面隱藏部分。

  若此距離大於等於當前滾動條的位置(即可視區域),那麼就可以認爲滿足條件,需要顯示圖像。

  假設滾動條是在body中,那麼當前可視區域的範圍如下所示。

const viewTop = window.pageYOffset;
const viewBottom = window.innerHeight + viewTop;

  window.pageYOffset 表示視口上邊的距離,如果沒有出現垂直方向的滾動條,那麼對應屬性的值爲 0。window.innerHeight 表示視口的高度。

  而圖像到視口頂部的距離可以通過 getBoundingClientRect() 的 DOMRect.top 屬性得到,如下所示。

const nodeTop = node.getBoundingClientRect().top + viewTop;

  完整的代碼如下所示, blank.gif 是一張 1*1 的空白佔位圖,data-src 是真實的圖像地址,scroll 是滾動條事件。

<img src="blank.gif" data-src="cover.jpg" width="100%" />
<img src="blank.gif" data-src="cover.jpg" width="100%" />
<img src="blank.gif" data-src="cover.jpg" width="100%" />
<script>
  window.addEventListener('scroll', () => {
    const viewTop = window.pageYOffset;
    const viewBottom = window.innerHeight + viewTop;
    // 查詢包含 data-src 自定義屬性的 img
    document.querySelectorAll('img[data-src]').forEach(node => {
      const nodeTop = node.getBoundingClientRect().top + viewTop;
      if (nodeTop >= viewTop && nodeTop <= viewBottom) {
        node.src = node.dataset.src;
      }
    });
  });
</script>

  當前只是爲了做演示,兼容性和性能方面並未做深入優化,可以參考市面上成熟的懶加載庫,例如 Layzr.js

  接下來講解第 2 種通過 Intersection Observer 實現懶加載。

  Intersection Observer 提供了一種異步的對目標元素與視口是否相交的檢測方法,即檢測目標元素是否在可視區域中。

  示例代碼如下,省去了位置計算的邏輯,通過 isIntersecting 屬性就能判斷元素的可見性。

const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    // 不在可視區域內就返回
    if (!entry.isIntersecting) return;
    const img = entry.target;
    img.src = img.dataset.src;
    observer.unobserve(img); // 取消監控
  });
});
document.querySelectorAll("img[data-src]").forEach((node) => {
  observer.observe(node);
});

  最後講解第 3 種懶加載方式:img 元素的 loading 屬性。該屬性會指示瀏覽器當圖像不在可視區域時的加載方式。

  這種方式相比較前兩者,最爲簡潔,不需要編寫額外的腳本,示例代碼如下所示。

<img src="cover.jpg" loading="lazy" width="300" height="400"/>

  2022 年有 91.47% 的瀏覽器已支持 loading 屬性,並且大概有 24% 的網站在使用它,相比去年有 1.4 倍的增長。

  但是其延遲加載的規則,即圖像與視口頂部的距離是多少時開始加載,全部由瀏覽器自行定義。

  注意,在 Chrome 中調試發現,頁面打開有兩三屏的圖像就開始請求了,開始滾動後,剩餘的圖像就開始陸續請求。

  並不是說到了圖像的可視區域後,纔開始請求。

2)預加載

  預加載和懶加載正好相反,它是在圖像還沒出現在可視區域時提前請求。

  之前做過一次公司招聘的活動頁,其中會涉及到好多動畫和很多圖像,並且需要在手機中翻頁瀏覽。

  一開始將所有圖像地址直接寫在頁面中,在測試環境就發現打開非常慢(如下圖所示),過了幾十秒後纔會出現 Loading 過渡動畫。

  

  於是就對其進行優化,將圖像的默認請求替換成一張空白圖(與之前的懶加載一樣),然後在腳本執行後再將首屏替換成真實地址。

  示例代碼如下,初始化 Image 實例,在觸發 load 事件時執行自定義回調,可以是替換 img 元素地址。

function loadImage(url, callback) {
  const img = new Image();
  img.src = url;
  img.onload = function () {
    //將回調函數的 this 替換爲Image對象
    callback.call(img);
  };
}
document.querySelectorAll("img[data-src]").forEach((node) => {
  loadImage(node.dataset.src, function () {
    node.src = this.src;
  });
});

  在翻頁時,可以將後面幾頁的圖像進行預加載,然後在翻到那頁後,不會出現等待圖像加載的情況,並且動畫就會更加絲滑和順暢。

3)Data URI

  img 元素的 src 屬性或 CSS 的 background-image 屬性的值都可以是一個經過 Base64 編碼後 Data URI,這樣能減少額外的HTTP請求。

  Data URI 由協議、MIME 類型(可選)、Base64 編碼設定(可選)和內容組成,格式如下:

data:[<mime type>][;base64],<data>

  在實際使用中的代碼片段如下:

data:image/png;base64,/9j/4AAQSkZJRgAB...

  Base64 會以每 6 個比特爲一個單元,對應某個字符,如果要編碼的字節數不能被 3 整除,就用 0 在末尾補足。

  例如編碼 PW,最後得到的值是 UFc=,計算過程如下圖所示。

  

  雖然使用 Data URI 減少了一次 HTTP 請求,但它會讓嵌入的文檔體積膨脹四分之三,影響瀏覽器渲染。

  並且還會降低 Gzip 的壓縮效率,破壞資源的緩存。

  若要使用,需要權衡利弊,儘量考慮小尺寸和低更新頻率的圖像。

二、大小

  大多數頁面至少有一張超過 100 KB 的圖像,而在頁面尺寸排行中,前 10% 的頁面至少有一張接近 1 MB 或更大的圖像。

  因此,壓縮或降低圖像的大小,可以顯著地提升頁面性能。

1)壓縮

  圖像壓縮分爲有損和無損。

  前者會改變圖像本身,減少信息量,降低圖像質量,文件無法還原,但是壓縮效率會比較高。

  後者會優化數據存儲方式,利用算法描述重複信息,文件可以還原,但是壓縮效率比有損低。

  在線壓縮網站 TinyPNG 採用智能有損壓縮技術對圖像進行處理,在我實際使用時,發現最高可壓縮 70% 以上的大小。

  原理就是通過合併圖中相似的顏色,將 24 位的 PNG 圖像壓縮成小得多的 8 位色值的圖像,並且去掉了不必要的元數據。

  經過壓縮後的圖像,人的肉眼並不會看出與原圖明顯的差異。

  若是要用代碼對圖像進行壓縮,可以採用三種觸發時機。

  第一種是在圖像上傳到服務器後,通過成熟的第三方 Node 庫(例如 imageminnode-ffmpeg 等)進行壓縮處理。

  第二種是在訪問圖像地址時,帶上各類參數,動態的對圖像進行壓縮或裁剪,例如 cover.png?w/100,按比例裁剪成 100 的寬度。

  第三種是在構建過程中對圖像進行壓縮,壓縮後再上傳到服務器中,例如 webpack 的 ImageMinimizerPlugin 插件等。

  目前市面上流行的 CDN 服務都應該會提供此類功能。

2)WebP

  WebP 是由 Google 提供的一種圖像格式,支持無損和有損兩種壓縮。

  官方資料表明,WebP 比 PNG 格式的圖像小 26%,比 JPEG 格式的圖像小 25~34% 。

  在可以接受有損壓縮的情況下,有損 WebP 也支持透明度,並且其文件大小通常比 PNG 小 3 倍。

  雖然表現如此優秀,但是 2022 年,WebP 格式的使用率只佔 8.9%,如下圖所示,GIF、JPEG 和 PNG 仍然是主流。

  

  阻礙其推廣的一大問題是兼容性,好在目前 iOS 14 以上已經支持 WebP,不考慮 IE 的話,主流的瀏覽器都已支持 WebP 格式。

3)響應式

  響應式是指根據屏幕尺寸、像素密度或其它設備特性,動態的請求最符合場景的圖像。

  像素密度(PPI)就是每英寸像素,計量設備屏幕的精細程度,值越高圖像越精細,常見的屏幕有 Retina、XHDPI 等。

  接下來用一個例子來演示不同尺寸的屏幕顯示不同的圖像,首先爲 img 元素聲明 srcset 屬性。

  用逗號分隔多個描述字符串,每一段包含圖像地址和寬度或像素密度描述符,注意,此處的寬度是圖像的原始寬度。

  然後再聲明 sizes 屬性,其值就是媒體查詢的條件和圖像的顯示寬度,最後一條描述可以省略條件,如下所示。

<img srcset="cover-small.jpg 375w, cover.jpg 2449w" 
    sizes="(max-width: 375px) 375px, 800px" 
    src="cover.jpg" />

  cover-small.jpg 的原始寬度是 375px,cover.jpg 的原始寬度是 2449px。

  當設備最大寬度是 375 時,將圖像寬度設爲 375px,在 srcset 中鎖定最接近的那張圖像的描述。

  800px 是默認的圖像寬度,當無法滿足條件時,就採用這個值。

  如果在做媒體查詢時不清楚各類屏幕尺寸的閾值,那麼可以參考 Bootstrap 的 Containers

  注意,若在 srcset 聲明的是像素密度,那麼就不需要再額外聲明 sizes 屬性了。

  在 2022 年,srcset 屬性的使用佔比在 34%,size 屬性的使用佔比在 13%~19%。

  如果要同時適配特定的屏幕尺寸和像素密度,那麼可以通過 picture 元素實現響應式。

  下面是一個示例,在 source 元素中,media 是媒體查詢條件,srcset 的功能和 img 元素中的相同。

<picture>
  <source media="(max-width: 375px)" srcset="cover-small.jpg, cover-2x.jpg 2x" />
  <img src="cover.jpg" />
</picture>

  當都不符合條件時,就會採用默認的 img 元素。在 2022 年,picture 元素的使用佔比是 7.7%。

  除了響應式圖像,picture 元素還可以用來選擇不同格式的圖像,如下所示。

  當瀏覽器支持 WebP 時,就加載這種格式的圖像,否則就加載後面的默認圖像。

<picture>
  <source type="image/webp" srcset="cover.webp"> 
  <img src="cover.jpg" />
</picture>

三、其他優化

  除了上述兩類比較大的優化之外,還有一些其他的細碎優化,在此節會列舉幾個。

  例如對圖像一個比較簡單而有益的優化是預設寬高,提前佔位,就能避免影響 CLS 的計算。

1)延遲解碼

  圖像解碼是光柵化過程中一個比較耗時的步驟,當圖像越大時,解碼時間就越長。

  那麼非合成動畫(即非 CSS3 動畫)就有可能因主線程被阻塞而卡頓。值得一提的是,CSS3 動畫運行在合成線程中,所以不會受其影響。

  HTMLImageElement.decode() 方法可以確保圖像解碼後,再將圖像添加到 DOM 中,如下所示。

const img = new Image();
img.src = "cover.jpg";
img.decode().then(() => {
  document.body.appendChild(img);
});

2)失敗處理

  在圖像請求失敗時,對頁面的交互並不會造成影響,但是圖像會裂開,在視覺體驗上比較糟糕。

  爲 img 元素註冊 error 事件,就能在錯誤時做糾正處理。

document.querySelector("img").addEventListener("error", function () {
  this.src = "../assets/img/cover-small.jpg";
});

  不過,若要想知道究竟是什麼原因的錯誤,目前還無法做到。

總結

  本文對圖像的優化進行了系統性的梳理,首先是對請求做優化。

  爲了更科學的對圖像進行請求,列出了懶加載、預加載和 Data URI 三種優化方法。

  然後對尺寸做優化,講解了壓縮細節,WebP 格式的特點,以及響應式處理的妙用。

  最後再介紹了幾個同樣也能優化圖像的方法,包括佔位、延遲解碼和失敗處理。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章