Article

用 LQIP 讓圖片載入不再跳來跳去

用 LQIP 技術搭配三層圖片載入策略,讓照片牆從進場到開啟大圖的每個階段都不會出現空白或版面跳動。

本文目錄

瀏覽有大量圖片的網頁時,應該都遇過這種情況:圖片還沒載入完成,畫面就一直跳動,文字被推來推去,或是圖片從上到下一條一條慢慢出現。這些體驗都不太好,尤其在網速較慢的環境下會更明顯。

LQIP(Low Quality Image Placeholder)是一種常見的圖片載入優化技術,核心概念很簡單:在原始圖片還沒下載完成之前,先用一張極小的低畫質圖片當作佔位,等實際要顯示的圖載入完成後再替換上去。

為什麼需要 LQIP?

如果只是把 <img> 丟上去什麼都不處理,會遇到兩個問題:

  1. 版面跳動(Layout Shift):圖片載入前沒有預留空間,載入後把其他內容擠開,造成 CLS(Cumulative Layout Shift)分數變差
  2. 載入體驗差:圖片從空白到完整顯示的過程中,要嘛是一片空白,要嘛是一條一條慢慢出現,使用者會覺得頁面還在「壞掉」的狀態

LQIP 同時解決了這兩個問題:透過預先知道圖片的寬高比來預留空間,再用一張模糊的縮圖做視覺過渡,整個載入過程會流暢很多。

我的做法

我在自己的照片牆專案 被光保存的瞬間 中實作了這個技術。這個專案會一次顯示大量照片,每張照片又有高解析度的原圖,很適合拿來做這種優化。

整體流程分成三個階段:上傳時預處理照片牆渲染Lightbox 原圖載入

上傳時預處理

在圖片上傳到後端時,我會做三件事:

  1. 記錄原圖的寬高:用來計算 aspect ratio,讓前台在任何圖片載入前就能預留正確的空間
  2. 產生 blurDataUrl:把圖片壓縮成極小尺寸(寬度大概 20~32px),轉成 base64 字串直接存進資料庫
  3. 產生 thumbnail:壓縮成適合在照片牆上瀏覽的中等尺寸縮圖,上傳到儲存空間
JavaScript
UTF-8|17 Lines|
// 概念上的處理流程
const metadata = await sharp(imageBuffer).metadata()
const { width, height } = metadata

// 產生 base64 模糊圖
const blurBuffer = await sharp(imageBuffer)
  .resize(32)
  .blur(1)
  .toBuffer()
const blurDataUrl = `data:image/jpeg;base64,${blurBuffer.toString('base64')}`

// 產生 thumbnail
const thumbnailBuffer = await sharp(imageBuffer)
  .resize(480)
  .toBuffer()

// 儲存:原圖 URL、thumbnail URL、blurDataUrl、寬高

blurDataUrl 是 base64,可以直接跟著 API 回應一起回來,不需要額外的 HTTP 請求。一張 32px 寬的模糊圖通常只有幾百 bytes,對回應大小幾乎沒有影響。

照片牆渲染:blurDataUrl → thumbnail

頁面載入後,每張照片的載入會經過兩個階段:

  1. 立即顯示 blurDataUrl:因為是 base64,不需要任何網路請求,頁面一渲染就能看到模糊的佔位圖,同時 aspect ratio 也已經是正確的
  2. thumbnail 載入完成後替換:瀏覽器在背景下載 thumbnail,下載完成後替換掉模糊圖
Vue
UTF-8|18 Lines|
<template>
  <div
    class="image-wrapper"
    :style="{ aspectRatio: `${image.width} / ${image.height}` }"
    @click="openLightbox(image)"
  >
    <!-- 底層:blurDataUrl 作為佔位 -->
    <img :src="image.blurDataUrl" class="blur-placeholder" />

    <!-- 上層:thumbnail 載入完成後顯示 -->
    <img
      :src="image.thumbnailUrl"
      :class="{ loaded: thumbnailLoaded }"
      class="thumbnail"
      @load="thumbnailLoaded = true"
    />
  </div>
</template>
CSS
UTF-8|23 Lines|
.image-wrapper {
  position: relative;
  overflow: hidden;
  cursor: pointer;
}

.blur-placeholder,
.thumbnail {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.thumbnail {
  opacity: 0;
  transition: opacity 0.3s ease;
}

.thumbnail.loaded {
  opacity: 1;
}

使用者看到的效果是:頁面一打開,所有照片的位置就已經排好,每張都是模糊但有輪廓的樣子。接著 thumbnail 陸續載入完成,一張一張從模糊變清晰,整個過程很順暢,沒有版面跳動。

使用者在照片牆上點擊某張照片後,會打開 Lightbox 來查看原始解析度的大圖。這時候又是一次從小圖到大圖的載入過程,一樣可以套用同樣的策略:

  1. 先顯示 thumbnail:因為 thumbnail 已經在照片牆階段載入過了,瀏覽器有快取,可以瞬間顯示
  2. 原圖載入完成後替換:高解析度原圖下載完成後,替換掉 thumbnail
Vue
UTF-8|10 Lines|
<!-- Lightbox -->
<div class="lightbox" v-if="currentImage">
  <img :src="currentImage.thumbnailUrl" class="lightbox-placeholder" />
  <img
    :src="currentImage.originalUrl"
    :class="{ loaded: originalLoaded }"
    class="lightbox-original"
    @load="originalLoaded = true"
  />
</div>

因為 Lightbox 中的 thumbnail 已經被快取過,使用者點開後會先看到一張稍微模糊但完整的圖,而不是一片空白等原圖慢慢載入。原圖載入完成後再無縫切換成高解析度版本。

三層載入的完整流程

整理一下使用者實際看到的體驗:

階段顯示內容來源等待時間
頁面載入模糊佔位圖blurDataUrl(base64)無,瞬間顯示
thumbnail 載入完成清晰縮圖thumbnail URL視網速而定
點擊照片,原圖載入完成高解析度原圖原圖 URL視網速與圖片大小而定

每一層都不會出現空白或版面跳動,使用者在任何時刻都能看到「有東西」的畫面。

總結

LQIP 的實作不複雜,核心就是讓每個階段都有東西可以顯示:

  1. 上傳時:記錄寬高、產生 base64 模糊圖(blurDataUrl)、產生 thumbnail
  2. 照片牆:先顯示 blurDataUrl,thumbnail 載入後替換
  3. Lightbox:先顯示已快取的 thumbnail,原圖載入後替換

在圖片量大或原圖解析度高的場景下,這種分層載入的效果會特別明顯。與其讓使用者盯著空白方塊等圖片慢慢冒出來,不如在每個階段都給他一個「夠用」的畫面。