今天要解的問題

PageSpeed 一直在「Improve image delivery」這項提醒我圖片太大。點開一看,除了文章裡的大圖,還有那幾隻奇異鳥 logo。

/blogs/ 上 8 個分類的 logo 只顯示 108px,但抓下來的是原圖:咖啡 1024×1024、238 KB,滑板 234 KB,書 204 KB。文章頁上那顆 28px 的分類 chip,也是整張 326px 的 PNG。光是 /blogs/ 這一頁,logo 就吃掉 883 KiB,實在有夠浪費的。

文章圖也是同一個毛病:SEO 健檢那篇的一張圖 1800px 寬,文章裡只顯示 672px,上傳時卻原封不動丟上 CDN。

技術背景

CDN 上的圖不會經過 Hugo

Hugo 有內建的圖片處理(resources.Get 之後可以 .Resize),但這個站的圖都放在 R2,文章和設定檔裡寫的是 https://cdn.kiwi-walk.com/... 的網址。模板遇到 http 開頭的路徑就直接輸出網址,不會去拿檔案。改之前的 layouts/partials/category-info.html 長這樣:

{{- $logo := "" -}}{{- if not (hasPrefix $cat.logo "http") -}}{{- $logo = resources.Get $cat.logo -}}{{- end -}}
{{- if $logo -}}{{- $logoURL = $logo.RelPermalink -}}{{- else -}}{{- $logoURL = $cat.logo | absURL -}}{{- end -}}

所以 CDN 上放多大,讀者就下載多大,縮圖只能在上傳之前做。

Lighthouse 怎麼算「可以省多少」

PageSpeed 底下跑的是 Lighthouse,這一項在 Lighthouse 12.8.2 輸出的 JSON 裡叫 image-delivery-insight,數字是「換成合適尺寸和格式後估計能少傳多少」。同一份報告的 network-requests 會列出每個請求實際傳了幾 byte,這篇的前後數字都是從這兩個欄位算出來的。

想法與取捨

我定了幾條規矩:

  • 文章圖上傳前縮到寬度 1400px 以內,不留原圖 (若以後有攝影作品之類的,就再說)
  • 可以轉 WebP,但前提是看不出失真,截圖、圖表這種文字多的用 lossless,照片才用有損的
  • 已經在 R2 上的舊圖這次不動
  • 先做 logo,因為收益最大、風險最小

logo:每個位置固定一個尺寸,不用 srcset

一開始的想法是做 128 和 256 兩種尺寸的 WebP,再加 PNG 當 fallback,用 <picture> 或 srcset 讓瀏覽器自己挑。

但實際算一下各位置要多大:chip 28px、list 標籤 24px、日曆 32px,3 倍螢幕最多要 96px,128 就夠;hero 72px、hub tile 108px,2 倍螢幕要 216px,256 就夠。所以每個位置直接寫死用哪一張,結果跟 srcset 在 2x、3x 螢幕上會挑的一樣,模板幾乎不用改。代價是 1x 螢幕上的 hero 和 hub tile 會多抓一點,256 那張也才 10–20 KB。

PNG fallback 也拿掉了。256px 的 PNG 就要 78–105 KB,比我給 logo 定的 30 KB 上限還大,而它只有在不支援 WebP 的瀏覽器才會用到。

logo:有損 WebP,不是 lossless

logo 是畫出來的插圖,不是文字截圖。同一張 256px,lossless WebP 還有 57–76 KB,q90 只剩 10–20 KB。把 q90 跟原圖縮到 256 的版本並排、再放大到 200% 看,羽毛的紋路和邊線都看不出差別:

書、咖啡、工程師三個 logo。左:原圖縮到 256px,中:q90 WebP,右:WebP 放大 200%

logo 的來源要用 repo 裡的原圖

CDN 上原本那幾張 PNG,我原本以為就是原圖,結果拿來跟 repo 裡 static/images/logos/ 的檔案比,才發現 CDN 上的是調色盤壓縮過的版本(Pillow 讀出來 mode 是 P,repo 裡的是 RGB)。拿壓過的再壓一次只會更糊,所以 WebP 一律從 repo 的原圖縮。

文章圖:「看不出失真」要變成一個數字

腳本沒辦法看圖,所以兩個判斷都要換成規則。

截圖還是照片,用來源格式分:PNG 走 lossless WebP,JPEG 走有損。手機照片本來就是 JPEG;PNG 就算其實是照片,lossless 也不會讓它變醜,最多就是省得少。

有損要壓到多少,用 PSNR 判斷:從 q85 開始試,壓出來的圖跟原圖差距在 40 dB 以上就收,不夠就換 q90、q95,三個都不夠就保留 JPEG。最後還有一關:轉完沒有變小,也保留原格式。

拿三張真實的圖試:文字多的截圖、長條圖、一張 2024 咖啡回顧裡的照片。兩張 PNG 轉 lossless 之後逐像素比對完全相同;照片在 q85 是 41.7 dB,放大到 200% 看不出差別:

由上到下:文字截圖(lossless)、長條圖(lossless)、照片(q85),各自原圖與 WebP 在 100% 與 200% 並排

實作

hugo.yml:logo 和 logoSmall

每個分類多一個 logoSmall,兩個都指到 R2 上的 WebP:

    coffee:
      displayName: Coffee
      # ...
      logo: https://cdn.kiwi-walk.com/static/images/logos/logo-coffee-256.webp
      logoSmall: https://cdn.kiwi-walk.com/static/images/logos/logo-coffee-128.webp

hero 和 hub tile 本來就讀 logo,所以只要換網址。chip、list 標籤、日曆都是透過 category-info.html 拿 logo,改成先拿 logoSmall,沒有就退回 logo:

{{- $src := $cat.logoSmall | default $cat.logo -}}
{{- $logo := "" -}}{{- if not (hasPrefix $src "http") -}}{{- $logo = resources.Get $src -}}{{- end -}}
{{- if $logo -}}{{- $logoURL = $logo.RelPermalink -}}{{- else -}}{{- $logoURL = $src | absURL -}}{{- end -}}

| default $cat.logo 是給 Projects 用的:它的 logo 是一張 svg,沒有大小之分,不用另外準備一張。<img> 上原本寫好的 width/height 是顯示尺寸,一個都不用動。

14 張 WebP(7 個分類 × 2 種尺寸)上傳後,用 curl -sI 看一次大小:

art 6576 / 17590   book 7558 / 20076   coffee 5774 / 15654   engineer 5846 / 15820
idea 5414 / 14320  others 6236 / 16722  skateboard 3996 / 10116     (bytes, 128 / 256)

最大的是 256px 的書,20 KB。以後加新分類,用 src/make_category_logos.py <name> 產生這兩張,它會順便印出上傳指令。

上傳腳本:縮圖和轉檔

文章圖本來就是用 src/migrate_images_to_r2.py 搬上 R2:上傳、把 Markdown 裡的路徑換成 CDN 網址、刪掉本機檔。這次在上傳前多加一步 prepare_image:

MAX_WIDTH = 1400
LOSSY_QUALITIES = (85, 90, 95)
MIN_PSNR = 40.0

def prepare_image(path: Path) -> Prepared:
    # ...
    with Image.open(path) as src:
        old_size = src.size
        fmt = src.format
        if fmt == "GIF" or getattr(src, "is_animated", False):
            return Prepared(raw, suffix, old_size, old_size, "unchanged: gif/animated")
        im = ImageOps.exif_transpose(src)  # bake in phone rotation; drops EXIF/GPS
        im.load()

    resized = im.width > MAX_WIDTH
    if resized:
        h = round(im.height * MAX_WIDTH / im.width)
        im = im.resize((MAX_WIDTH, h), Image.LANCZOS)
    # ...
    if fmt == "JPEG":
        rgb = im.convert("RGB")
        webp, note = None, "kept jpeg: no webp quality reached %.0f dB" % MIN_PSNR
        for q in LOSSY_QUALITIES:
            data = _encode(rgb, "WEBP", quality=q, method=6)
            with Image.open(io.BytesIO(data)) as back:
                if _psnr(rgb, back) >= MIN_PSNR:
                    webp, note = data, f"webp q{q}"
                    break
    else:
        webp = _encode(im, "WEBP", lossless=True, method=6)
        note = "webp lossless"

    if webp is not None and len(webp) < len(base):
        return Prepared(webp, ".webp", old_size, im.size, note)
    # ...

exif_transpose 是給手機照片的:手機拍的照片常常是橫著存、靠 EXIF 標記要轉 90 度,重新編碼會把這個標記丟掉,所以先把旋轉套進像素,順便也把 EXIF 裡的 GPS 一起拿掉了。GIF 和動圖原樣上傳,縮了會只剩第一格。

轉成 WebP 的圖,R2 上的 key 和 Markdown 裡的路徑都跟著換成 .webp。--dry-run 會把每張圖的處理結果列出來,先看一眼再真的上傳。在一篇文章裡暫時放兩張測試圖跑出來是這樣:

  content/blogs/engineer/blog-seo-audit/images/zz-test-diagram.png -> content/posts/engineer/blog-seo-audit/images/zz-test-diagram.webp
    1800x940 -> 1400x731, 132 KB -> 106 KB, webp lossless
  content/blogs/engineer/blog-seo-audit/images/zz-test-photo.jpg -> content/posts/engineer/blog-seo-audit/images/zz-test-photo.webp
    3000x2000 -> 1400x2100, 212 KB -> 39 KB, webp q85

第二張是故意做的 3000×2000、EXIF 標記要轉 90 度的照片,轉正之後變直的,寬度才縮到 1400。

這篇的三張圖就是第一批真的用它上傳的,封面那張是 1800x1260 -> 1400x980, 547 KB -> 299 KB, webp lossless。

前後數字

改前改後各 build 一份,放在同一台電腦的同一個 localhost port 上,各跑 3 次 Lighthouse 取中位數(mobile 把 CPU 降速調到 12 倍,比較接近 PageSpeed 的機器)。圖片都從同一個 CDN 抓,差別只有網址指到哪張圖:

頁面分類 logo 傳輸量Improve image delivery(mobile)
/blogs/883 → 114 KiB881 → 113 KiB
/series/kiwi-daily/305 → 21 KiB847 → 563 KiB
/blogs/coffee/233 → 16 KiB3015 → 2798 KiB
文章頁28 → 6 KiB154 → 132 KiB

/blogs/coffee/ 還有 2.8 MB 可以省,是那幾篇咖啡文的大封面,屬於這次不回頭處理的舊圖。正式站上的 PageSpeed 要等部署之後再量。封面圖沒有寫寬高造成的版面位移是另一件事,另外處理。

踩到的坑

同一頁 Perf 掉了 7 分,但跟圖無關

/blogs/ 在 mobile 的 Perf 中位數從 94 掉到 87,數字上看像是變差了。但 LCP 前後都是 1.50 秒,圖片少傳了 700 多 KB,掉的是 TBT(主執行緒被卡住的時間)。三次各別攤開(欄位是第幾次、Perf、TBT 毫秒、LCP 毫秒):

before 1 90 407 1504
before 2 94 292 1503
before 3 95 260 1505
after 1 100 0 1504
after 2 87 495 1355
after 3 86 573 1503

改後第一次 Perf 100、TBT 0,後兩次 TBT 五百多。這次沒有改任何 JavaScript,TBT 卻在 0 到 573 毫秒之間跳,就是本機跑 12 倍 CPU 降速的雜訊。只看中位數的 Perf 會被它騙,要看圖片相關的欄位。

舊版 wrangler 不認得 –remote

上傳 R2 要加 --remote,不然 wrangler 只會寫進本機的快取。我先拿電腦裡現成的一份 wrangler 跑,14 張全部失敗,錯誤是:

✘ [ERROR] Unknown argument: remote

那份是 3.114 版,還沒有這個參數。換成 4.149 版就全部上傳成功。

headless Chrome 截不出 375px

想用 headless Chrome 加 --window-size=375,812 截手機版的前後對照,結果截出來的版面比 375 寬,一排 hub tile 被切到一半,給了 375 也沒有真的排成 375。所以手機寬度的檢查改用瀏覽器的 mobile 模擬(375×812),量 document.documentElement.scrollWidth 等於 375,沒有橫向捲動。

小結

分類 logo 現在是 R2 上的 WebP:chip、list 標籤、日曆用 128px,hero 和 hub tile 用 256px,每張 4–20 KB。/blogs/ 的 logo 從 883 KiB 降到 114 KiB,文章頁那顆 chip 從 28 KB 降到 6 KB,Retina 螢幕上也是用 2 倍以上的圖在畫,不會糊。

上傳腳本以後會把文章圖縮到 1400px 寬,PNG 轉 lossless WebP,JPEG 在 40 dB 以上才轉有損 WebP,轉完沒變小就保留原格式。