Article
用 LQIP 讓圖片載入不再跳來跳去
用 LQIP 技術搭配三層圖片載入策略,讓照片牆從進場到開啟大圖的每個階段都不會出現空白或版面跳動。
本文目錄
瀏覽有大量圖片的網頁時,應該都遇過這種情況:圖片還沒載入完成,畫面就一直跳動,文字被推來推去,或是圖片從上到下一條一條慢慢出現。這些體驗都不太好,尤其在網速較慢的環境下會更明顯。
LQIP(Low Quality Image Placeholder)是一種常見的圖片載入優化技術,核心概念很簡單:在原始圖片還沒下載完成之前,先用一張極小的低畫質圖片當作佔位,等實際要顯示的圖載入完成後再替換上去。
為什麼需要 LQIP?
如果只是把 <img> 丟上去什麼都不處理,會遇到兩個問題:
- 版面跳動(Layout Shift):圖片載入前沒有預留空間,載入後把其他內容擠開,造成 CLS(Cumulative Layout Shift)分數變差
- 載入體驗差:圖片從空白到完整顯示的過程中,要嘛是一片空白,要嘛是一條一條慢慢出現,使用者會覺得頁面還在「壞掉」的狀態
LQIP 同時解決了這兩個問題:透過預先知道圖片的寬高比來預留空間,再用一張模糊的縮圖做視覺過渡,整個載入過程會流暢很多。
我的做法
我在自己的照片牆專案 被光保存的瞬間 中實作了這個技術。這個專案會一次顯示大量照片,每張照片又有高解析度的原圖,很適合拿來做這種優化。
整體流程分成三個階段:上傳時預處理、照片牆渲染、Lightbox 原圖載入。
上傳時預處理
在圖片上傳到後端時,我會做三件事:
- 記錄原圖的寬高:用來計算 aspect ratio,讓前台在任何圖片載入前就能預留正確的空間
- 產生 blurDataUrl:把圖片壓縮成極小尺寸(寬度大概 20~32px),轉成 base64 字串直接存進資料庫
- 產生 thumbnail:壓縮成適合在照片牆上瀏覽的中等尺寸縮圖,上傳到儲存空間
// 概念上的處理流程
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
頁面載入後,每張照片的載入會經過兩個階段:
- 立即顯示 blurDataUrl:因為是 base64,不需要任何網路請求,頁面一渲染就能看到模糊的佔位圖,同時 aspect ratio 也已經是正確的
- thumbnail 載入完成後替換:瀏覽器在背景下載 thumbnail,下載完成後替換掉模糊圖
<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>.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 原圖載入:thumbnail → 原圖
使用者在照片牆上點擊某張照片後,會打開 Lightbox 來查看原始解析度的大圖。這時候又是一次從小圖到大圖的載入過程,一樣可以套用同樣的策略:
- 先顯示 thumbnail:因為 thumbnail 已經在照片牆階段載入過了,瀏覽器有快取,可以瞬間顯示
- 原圖載入完成後替換:高解析度原圖下載完成後,替換掉 thumbnail
<!-- 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 的實作不複雜,核心就是讓每個階段都有東西可以顯示:
- 上傳時:記錄寬高、產生 base64 模糊圖(blurDataUrl)、產生 thumbnail
- 照片牆:先顯示 blurDataUrl,thumbnail 載入後替換
- Lightbox:先顯示已快取的 thumbnail,原圖載入後替換
在圖片量大或原圖解析度高的場景下,這種分層載入的效果會特別明顯。與其讓使用者盯著空白方塊等圖片慢慢冒出來,不如在每個階段都給他一個「夠用」的畫面。