昨天晚上看了 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 | inject: |
靜態規則放 source/css/、動態邏輯放 source/js/,主題本體一字不動。這條路至少穩。
坑 2:PJAX 攔截所有導航、VT 永遠不觸發
寫好 view-transitions.css:
1 | @view-transition { |
按理瀏覽器導航時就會自動 transition。但點連結 → 完全沒效果。
我這份 _config.butterfly.yml 開了 PJAX(pjax.enable: true、Butterfly 5.x 官方預設是 false——這條我之前主動開過、現在已經忘了當初為什麼)。PJAX 用 fetch 抽換 DOM,根本不觸發瀏覽器原生 navigation——而 cross-document View Transition 要的就是原生 navigation。PJAX 沒攔截下來,VT 才能跑。
1 | # _config.butterfly.yml |
關掉。個人靜態 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 | /* ❌ 永遠不會 morph */ |
錯。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 | <div id="page-header" style="background-image: url(...)"> |
把整個 #page-header 掛 view-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 | Get-ChildItem -Path "D:\hexo\source\images\posts" -File -Include *.png -Recurse | |
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 行:
- 列出
public/images/posts/*.png - sharp 轉 WebP q80
- 刪掉 public 的 PNG
- 掃所有
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 | hexo.extend.filter.register('after_generate', async function () { |
(完整實作我放在 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。這條我這次踩過了、以後就不會再忘。
下次我會直接從「先量真實成本」開始,而不是「先做炫效果」。




