昨天晚上看了 Coding2GO 一條 5 分 46 秒的影片,講 CSS View Transition API 終於支援跨文件了——純 CSS 兩頁各加幾行(同源 + 兩頁都 @view-transition { navigation: auto; })、靜態網站也能做出 SPA 風的換頁過場。我當下就想到自己那個 Hexo blog:能不能加上 iOS 相簿那種「點縮圖、圖飛進去變大圖」的 morph 效果?

今天動手,跑半天 morph 沒成。但 debug 過程裡順手 PowerShell 抓了一下封面圖檔大小——35 張總共 84.3MB,平均 2.4MB 一張。

結果 morph 收手,反而把整站圖片壓掉 93%(84MB → 6MB)。本來想搞炫的,沒成;結果抓到真正拖慢網站的東西。記錄一下這次的過程。

原本的目標清單

下班前的 2 小時 budget,我想要的兩個東西:

  • 整頁 cross-fade 換頁過場(基本款)
  • list 縮圖 → 詳情頁大圖的 hero morph(進階款)

我以為大半是 CSS 的事,看起來很簡單。估了 2 小時,實際跑了大概三倍。

環境:Hexo + Butterfly 5.5.4。

坑 1:主題裝在 node_modules、不能改 .pug

Butterfly 5.x 是 npm 裝在 node_modules/ 裡,不在 themes/。直接動主題的 .pug 模板,下次 npm update 一跑就被蓋掉,所有客製化白做。

好在 Butterfly 官方有 inject 機制,可以從 _config.butterfly.yml 注入 CSS 跟 JS 到 head 和 bottom:

1
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/css/view-transitions.css">
bottom:
- <script src="/js/some.js"></script>

靜態規則放 source/css/、動態邏輯放 source/js/,主題本體一字不動。這條路至少穩。

坑 2:PJAX 攔截所有導航、VT 永遠不觸發

寫好 view-transitions.css

1
2
3
4
5
6
7
8
9
@view-transition {
navigation: auto;
}
::view-transition-group(*) {
animation-duration: 0.4s;
}
@media (prefers-reduced-motion: reduce) {
@view-transition { navigation: none; }
}

按理瀏覽器導航時就會自動 transition。但點連結 → 完全沒效果。

我這份 _config.butterfly.yml 開了 PJAX(pjax.enable: true、Butterfly 5.x 官方預設是 false——這條我之前主動開過、現在已經忘了當初為什麼)。PJAX 用 fetch 抽換 DOM,根本不觸發瀏覽器原生 navigation——而 cross-document View Transition 要的就是原生 navigation。PJAX 沒攔截下來,VT 才能跑。

1
2
3
# _config.butterfly.yml
pjax:
enable: false # 從 true 改 false

關掉。個人靜態 blog 沒有跨頁要保留的播放器或狀態,PJAX 那點「換頁不重載 JS」的好處邊際效益低,換來原生過場質感划算。

(順帶,Butterfly 還載了 instant.page 做 hover 預取,這個跟 cross-document VT 相容、不攔截導航,留著加分。)

關 PJAX + 重 build,整頁 cross-fade 立刻生效。點連結 → 淡入淡出 → 進新頁,比原本的硬切舒服多了。我點來點去看了幾遍,確定真的有過場、不是錯覺。

第一階段過關。回到第二階段:cover morph。

坑 3:CSS 模板插值是空話

cover morph 的核心是給「list 端那張縮圖」跟「詳情頁那張大圖」設同一個 view-transition-name,瀏覽器才知道這兩個是同一個元素跨頁面位移。每張圖的 name 必須 unique,所以要動態生成。

我一開始的想法很直覺:寫在 CSS 裡用模板插值不就好了?

1
2
/* ❌ 永遠不會 morph */
.post-card .cover { view-transition-name: hero-{{ post.slug }}; }

錯。CSS 靜態檔不過 hexo 模板引擎,{{ }} 會原樣輸出。瀏覽器拿到 hero-{{ post.slug }} 判定非法 ident、整條規則丟掉。

動態 name 走 JS 注入、靜態動畫規則放 .css——這條界線一旦劃清楚,就再也不會誤踩。

(這個坑我前一個 session 寫筆記時就埋下了——當時直接抄了影片裡的 demo code、以為「以後直接這樣寫」很簡單,動手才發現自己埋了顆雷。)

inject 一段 JS:監聽 pageswap、給 list 的 img 掛 viewTransitionName;監聽 pagereveal、給詳情頁的 #page-header 掛同樣的 name。技術上跑通了。

坑 4:cover morph 點下去真的飛——但「不順暢」

JS 寫完,Chrome 打開,點 list 卡片——morph 真的觸發、放大效果出現。

但視覺不順。

問題不在 VT,在 Butterfly 把背景、nav、標題包在同一個 header。抓 DOM 才看清楚:

1
2
3
4
<div id="page-header" style="background-image: url(...)">
<nav id="nav"> <!-- 整條頂部導航列 (60px 高) -->
<div id="post-info"> <!-- 文章標題、發表時間、字數那一塊 (158px 高) -->
</div>

把整個 #page-headerview-transition-name 等於把「背景圖 + nav + 標題區」當一塊快照、跟 list 端的純縮圖 morph。過程中 nav 跟 post-info 在 list 端不存在,於是 fade-in 跟著放大過程冒出來——這就是不順暢感的來源。

要修到完美得在 #page-header 內注入一層獨立 bg-layer(absolute 鋪滿、把背景圖搬到它身上、#page-header 自己清掉 background-image),view-transition-name 只掛在 bg-layer 上。nav 跟標題不掛、不參與 morph。

但這真的在改主題渲染後的 DOM。我 npm 裝的是 Butterfly 5.5.4,2026 年內可能就會出 6.x、主題改了 DOM 結構就壞。

技術 blog 的 cover morph 是 nice-to-have,DOM 注入投報率不夠。我選擇收手。最煩的不是沒動,是它有動但醜——明明放大效果都出來了,差那層收尾沒做。但想到「為了這層 polish、要 inject 一段 JS 改主題渲染後的 DOM、然後祈禱 Butterfly 6.x 出來別動結構」這條路,還是算了。

(順便挖個墳:影片裡的 demo 只有圖,Butterfly 的 cover 是整個 header;差別就在這裡。)

真正的炸點:debug 過程中發現自己有 84MB 圖

我以為在做動畫,其實在測主題架構邊界——而真正該測的,是我從沒測過的東西。

收手前,我懷疑「為什麼放大感不順」可能是圖太大、瀏覽器拍快照時還沒 decode 完。順手 PowerShell 統計了一下:

1
2
Get-ChildItem -Path "D:\hexo\source\images\posts" -File -Include *.png -Recurse |
Measure-Object -Property Length -Sum

35 張 cover、全是 1536×1024 PNG、平均 2.4MB、總 84.3MB。

第二眼確認沒看錯。

PNG 沒壓縮,gpt-image 直接吐出來就這麼大。我用 codex-image-gen skill 自動生 cover 已經幾個月,從來沒問過自己「這些圖到底多大」。

每個讀者點開一篇文章,光詳情頁那張 banner 就要載 2 點多 MB。難怪手機 4G 點進去常常卡半秒。

這才是真正該做的事。

修圖路上又踩一坑:hexo after_generate 不是寫檔後的 hook

決定壓 WebP。35 張全轉、引用同步改寫(HTML 跟 RSS atom.xml 都要掃,CSS/JSON 不動),希望接進 build pipeline、未來新圖自動處理、寫文流程一個字都不改。

選項評估:hexo 圖片 plugin(hexo-yam、hexo-imagemin)看了一下,要嘛停更、要嘛只壓不改 HTML 引用,都不夠用。決定自製 hexo filter + sharp(libvips 底層、業界標準)。

寫了一個 after_generate filter,大概 100 行:

  1. 列出 public/images/posts/*.png
  2. sharp 轉 WebP q80
  3. 刪掉 public 的 PNG
  4. 掃所有 public/**/*.html、把 .png 引用改成 .webp

第一次跑 → 0 張壓縮。一片靜悄悄。

加 console.log 才發現:hexo after_generate 比較像 route 生成後的 hook、不是 public/ 寫完後的 hook。filter 觸發時 routes 已經生成(in-memory)但 hexo-cli 還沒把它們寫到 public/。我從 disk 讀,當然 ENOENT。

正解是用 hexo.route API 操作 in-memory routes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hexo.extend.filter.register('after_generate', async function () {
const route = hexo.route;
const pngPaths = route.list().filter(p =>
/^images\/posts\/.*\.png$/i.test(p)
);
for (const pngPath of pngPaths) {
const pngBuf = await routeToBuffer(route, pngPath);
const webpBuf = await sharp(pngBuf).webp({ quality: 80 }).toBuffer();
const webpPath = pngPath.replace(/\.png$/, '.webp');
route.set(webpPath, webpBuf);
route.remove(pngPath);
}
// 接著用 route.list() + route.get() + route.set()
// 改寫所有 .html / .xml route 的 .png 引用 → .webp
});

(完整實作我放在 scripts/cover-to-webp.js。實際版本還保留了「filter 永不讓 build 失敗」「單張失敗保留 PNG 跳過該檔 HTML 改寫」這些 fail-safe 邊界,我整理過。)

第二次跑:

1
[cover-to-webp] 35 壓縮 / 0 失敗,84.3MB → 6.0MB (saved 93%),12.9s

84MB → 6MB,平均 171KB 一張、目視看不出差別。source 那邊 PNG 留底沒動、frontmatter 的 cover: 路徑也照舊寫 .png 不用改——build 階段在 in-memory 換成 WebP。deploy 出去的 public/ 只有 WebP、PNG 永遠只活在 source、不會兩份大圖一起發。

最終成果

項目 狀態
整頁 cross-fade 換頁過場 ✅ 上線
cover image morph(iOS 相簿風) ❌ 收手
cover PNG → WebP 自動壓縮 ✅ 84MB → 6MB

下班 2 小時 budget 跑了大概三倍時間。「進階款 morph」沒成、但「沒在計畫內的副產品」反而是這次最大收益——每個讀者現在每頁少載 ~2MB,LCP 理論上會跟著改善(沒做正式量測,單純從下載差距推算)。

學到什麼

幾個我會帶到下次的:

主題客製不要碰 node_modules、但要意識到 inject 也是有極限的。第一階段 cross-fade 純 CSS、零耦合、升級不會壞,這是健康的 inject。第二階段 morph 要 DOM 注入抽 bg-layer,這已經在「改主題渲染後的 DOM 結構」,Butterfly 6.x 出來大概就會撞到。同樣是 inject、技術債的量級差很多。

炫效果常是發現真效能病的觸發點。 為了 morph 跑去抓圖大小,順手治了載入慢的真病。如果今天不是想做 morph,我可能再過半年都不會發現自己藏了 84MB 圖片巨石。

Hexo after_generate filter 不能讀 public/。它在 routes 寫到 disk 之前觸發、要用 hexo.route API 操作 in-memory。這條我這次踩過了、以後就不會再忘。


下次我會直接從「先量真實成本」開始,而不是「先做炫效果」。