🔑 關鍵洞察
✦ AI·GEN這篇記錄了作者把 koimsurai.com 的部落格,從一個 100% CSR 的 React SPA(真人只拿到空殼、爬蟲靠 user-agent 硬塞 meta)一路改造成 SSG/ISR 的完整渲染策略與踩坑。從選型談起——為什麼是 SSG hybrid、為什麼評估過 Next 卻選了 TanStack Start(RR7 差點雀屏中選,被一句「我想要 ISR」翻盤);接著是實作:內文烤進 HTML、重互動留 client、MDX 走 server 編譯加 client eval(一條之後想改成 dynamic-import ESM 來拿掉的路);再到 prerender 生了 111 個檔卻一個都沒被送出、於是改走純 ISR,用 Rust middleware 做發文即刷新(還差點被瀏覽計數端點清光全站快取);最後是進場體驗的除錯長征:無樣式閃爍、雙渲染、被捲動平滑握住的捲動,以及一個被自己按住重想才翻出的錯誤診斷。全篇也一併科普了 SSR、SSG、CSR、ISR、hydration 各是什麼。
每個部落格都要回答同一個問題:一篇文章,怎麼從資料庫走到讀者的螢幕上?我這台站,前兩年用的是最偷懶的答案:。這篇是把那個答案重寫三次的故事:先搬去 SSG、再被自己的 prerender 打臉改走 ISR、最後為了「進文章不要閃一下」跟渲染時序纏鬥了一整晚。
先講清楚幾個名詞,整篇都繞著它們轉:
- SSR():每次請求都由伺服器即時把 HTML 渲染好再送出,首屏就有內容。
- SSG():build 時就把頁面預先渲染成靜態 HTML 檔,之後直接送檔案。
- CSR():瀏覽器拿到空殼再自己畫,也就是我原本那套。
- ISR():介於兩者之間,先送預生成的靜態 HTML(快),背景定期或觸發時重新生成(新)。
盡量忠實呈現整個過程,包括我判斷錯、以及被自己按住重想的地方。
背景:一個對爬蟲隱形的部落格,和一個想全 Rust 的我#
改造前,這站是一個很正常的 React 19 SPA:BrowserRouter + <Routes>、native fetch + 手刻快取,SSR 用量是 0,百分之百 CSR。真人打開首頁,拿到的是一個空的根節點 + 一包 JS,內容全靠瀏覽器長出來;而爬蟲能看到 meta,是因為 serve 層用 user-agent 偵測到 bot,才把 <title>、OG、JSON-LD 硬塞進靜態 HTML。換句話說:文章的 body,從來沒有被伺服器渲染過。 對一個「內容就是全部」的部落格,這個架構是反的。
同一時間我心裡還有另一件事:我想把後端全部改成 Rust。 這件事得先給沒背景的讀者鋪一下,因為它其實跟「讓部落格變快」一點關係都沒有。
順帶一提,我當初最在意的 SEO 症狀,其實是 Google 把「Koimsurai」自動更正成「Katsurai」(一家京都豬排店)。查下來那是品牌實體辨識問題,SSG 修不了它。但「真人跟爬蟲都該拿到真正被 render 過的內文」這件事,還是值得做。
選型:SSR / SSG / CSR,以及為什麼不是 Next#
攤開來就三條路:維持現狀(全 CSR + bot meta shim)、混合 SSG(內文頁 prerender、儀表板留 CSR)、或整站 SSR。對一個部落格,答案意外地偏 SSG,而且它反而更貼合我那個「全 Rust」的定義:
- SSG 的產物就是一堆靜態 HTML,Rust 後端直接 serve 就好,production 零渲染 Node;
- 整站 SSR 會在 prod 多一個常駐的 Node 渲染 process,反而稀釋「全 Rust」;
- 而我的站天然分兩種內容:半靜態的文章/靜態頁(要 SEO)跟即時儀表板(now / 觀影 / 音樂,一直變、根本不需要 SEO)。正好:內文走 SSG、儀表板留 CSR。
那 Next.js 呢?我也請人評估過把網站從 React 遷過去,但最後沒選它。對 Next 的怨我其實早就在別的專案上累積了:一個用 Next 的 Tauri 桌面 app,建置慢到我受不了;NAS 前端更慘,被 Next 16.0.6 的一個 漏洞入侵、塞了挖礦程式。
評估下來它贏不過 TanStack Start,框架短名單於是收斂成 TanStack Start vs framework mode。至於 Next 的招牌 ,在我這也用不上:資料全在 Rust 後端,server component 頂多 await fetch(rustApi),RSC 的好處直接被抵消。
真正把天平壓向 的,是一件事:
NOTE
是「我想要 ISR」翻的盤
React Router 7 的 framework mode 其實差點雀屏中選。我本來就在 RR7 上,切 framework mode 拿 SSG 的遷移摩擦最小(那 83 個 <Link>、24 個 useNavigate 幾乎原封不動)。但 RR7 framework mode 沒有原生 ISR;要 ISR 就得接受 prod 多那層 Node 渲染。而我確實很想要 ISR(發文即刷新、不用等 rebuild),再加上「把最潮的 TanStack 全家桶跑一遍」對我這種個人 vessel 專案本來就是正當的 dogfood 動力。router 這題就這樣定給 Start 了。
順帶說一句這套有多新:TanStack Start v1.0 是 2026 年 3 月才出的、Vite 用的是 8.x(底層換成 Rolldown)、而它底下的伺服器引擎 用的還是 v3 的 beta(TanStack Start 直接整合了它)。整套是 bleeding edge。後面會看到,這個「新」既是樂趣,也咬了我好幾口。
至於更省 JS 的 ?第一輪就因為我有 three.js、Monaco 這種重互動而略過(React-first 才合現實);後來為了「拿掉 eval 上 CSP」我又認真評估過它一次,結論下面會講。最終的心智模型很乾脆:「SSG/ISR 的首屏 + SPA 式的後續導航」。初次載入是伺服器預生成好的 HTML(SEO 與首屏的紅利來源), 之後站內切頁仍像 SPA,兩邊的好處都拿。
實作:內文烤進 HTML,重互動留 client#
先驗 PoC:用真的文章跑 prerender,確認內文烤進了 HTML:/blog/39/index.html 38KB,body 裡有完整的 <article>、標題、表格。四項全綠。核心結論一句話:內文 prerender、重互動留 CSR。 正式做的時候,渲染管線長這樣:
兩個關鍵決定。第一個是要 prerender 哪些頁,從 API 列出來:不寫死 5 條 /en/blog/:id,而是一條動態路由 $locale/blog/$id 處理所有語言;build 時打 /api/posts 拿每篇的 available_locales,只生真的有的語言, 也照這個產、絕不造假。
第二個是內文烤進 HTML、重的互動留 client:那個一千八百行、拉進 mermaid / shiki 的互動版 BlogPost,走 lazy + ,保證 three.js / mermaid 這種在 Node 裡會炸的東西永不進伺服器端的那包程式(server bundle)。
這條路踩了一串 server bundle 與 hydration 的坑,挑幾個有代表性的:
- LinkCard 把整個 BlogPost 拖下水。
History、AboutSite兩頁import { LinkCard } from './BlogPost'——LinkCard 本身完全能 SSR(它就是連結預覽卡,沒碰 window),但它跟 mermaid 一起關在那個兩千行檔案裡,一 import 就把整包 mermaid 拖進 server bundle。解法是把 LinkCard 抽成獨立的 SSR-safe 模組、跟 mermaid 脫鉤。 - 導覽列講韓文的 。 全站外殼(Header/Footer)掛在
__root、卻在每頁的LocaleProvider外面,於是外殼的翻譯 fall back 到全域 i18next 實例。多頁一起 prerender 就漏語言:SSR HTML 是<html lang=en>、Hero 英文(對),導覽列卻是韓文。SSR 導覽列 ko、client 導覽列 en → #418。解法:在 root 包一層以網址判語言的 provider,讓外殼也拿到對的語系。 - 舊 Service Worker 陰魂不散。 舊 SPA 有 PWA plugin 註冊了會 precache 舊資產的 SW;新架構沒有。回訪者拿到舊外殼 + 新 HTML → 樣式壞掉 + #418。解法:serve 層送一支會自我了斷的
/sw.js(反註冊 + 清快取 + reload)。
還有幾個渲染路徑上的細坑,順帶記著,因為它們每一個都跟「SSR 與 client 要逐字一致」有關:
- 標題冒號自動切主副標。 schema 沒有副標欄位,前端一個
splitTitle在第一個冒號切開:主標進h1、副標進p。純呈現層,document.title跟og:title還是用完整標題,SEO 不受影響。 - scroll-spy 被腳註 id 劫持。 原本追蹤「頁面上所有帶
id的元素」,結果腳註、alert 那些user-content-fn-…的 id 把 active 狀態搶走,目錄高亮直接消失。解法:只看「目錄真的有列到的標題 id 集合」。錨點也補了scroll-margin-top,免得跳轉後標題被 sticky header 蓋住。 - mermaid 的 ELK layout 在 SSR 站會 circular JSON 崩潰。 切成 Adaptive(ELK)排版時直接丟
Converting circular structure to JSON。挖到底:@mermaid-js/layout-elk會對整張 ELK 圖做JSON.stringify,而這站的<html>是 TanStack Start 用 React 渲的,documentElement上帶著__reactFiber,序列化就撞到循環參照。這是 React SSR 站特有的坑。解法是一個引用計數的 guard,在 render 期間暫時把JSON.stringify換成會跳過 DOM 節點的版本。
prerender 生了 111 個檔,一個都沒被送出#
實作到一半,撞上一個很荒謬的發現。docker build 裡的 prerender 跑得又快又乾淨(7.8 秒、111 個檔案、零錯誤),.output/public/blog/index.html 也確實生出來了——可是實際請求 /blog/index.html 一律回 404,而且連打兩次 /en 的 md5 不一樣。意思是:每一次請求都在重新 SSR,那 111 個檔一個都沒被送出過。 根因是 Nitro 註冊靜態資產的清單,在 prerender 寫檔之前就掃完了。生了一堆檔卻沒人送,純粹浪費 build 時間、還讓 build 期得連上線的站撈文章清單。
於是我把 prerender 整個拿掉,改走純 ISR。這裡先科普 ISR 的機關,因為官方那條路在我這走不通:
WARNING
官方 ISR 是靠 CDN 跑的,self-host 沒 CDN 就不會重生
TanStack Start 官方 ISR 的機制是:build 時 prerender + 在回應打上 Cache-Control: stale-while-revalidate,而背景重生由 CDN 執行。我是自架 nginx、DNS-only、刻意不掛在別人的 CDN proxy 底下——沒有 CDN,那個 header 就沒有執行者,背景重生完全不會發生。
能救的是 Nitro 的 :它內建 SWR,一行 routeRules 就搞定,而且背景重生發生在我自己的 server 裡:
// vite.config.start.ts —— ISR 一行搞定,而不是自己刻 100 行
const ISR_ROUTE_RULES = {
...swrRules(ISR_PAGES.flatMap(localeVariants), 3600), // UI 頁:1 小時
...swrRules(localeVariants('blog'), 300), // 列表頁:發新文要早點出現 → 5 分鐘
...swrRules(localeVariants('blog').map((p) => `${p}/**`), 3600), // 文章頁:內容幾乎不動 → 1 小時
};整個站的請求流變成這樣——Rust 是唯一真後端,Node/Nitro 只是一層渲染殼(這正是我「業務邏輯全 Rust、前層 Node 只渲染」的定義落地):
這段有三個坑值得展開:
列表頁快取了個空殼。 ISR 快取的 /blog 一開始是空的,因為 Blog 元件在 useEffect 裡抓資料,而 useEffect 不在 server 執行 → SSR 只吐一個 loading 骨架屏(prerender 也一樣救不了它,它同樣不跑 useEffect)。解法是把抓資料搬進路由 loader、用 useLoaderData 餵初始資料:/blog 的 SSR 從 19,541 bytes 的空殼變成 74,242 bytes、含標題與 8 條文章連結。
發文即刷新:on-demand 重生。 光有 TTL 還不夠,新文章要能立刻讓爬蟲看到。做法是一個 Nitro server route /_revalidate(用 header secret 保護;刻意不放 /api/*,因為 nginx 把 /api/ 全送去 Rust,前端根本收不到),Rust 後端在發文/改文成功後 fire-and-forget 打它清快取。而且我把它做成 axum 而不是在 14 個寫入端點各掛一次。逐一掛必漏,漏了不報錯、只是安靜地不更新。
CAUTION
差一個字就會清光全站快取
判斷「這是寫文章的請求」時,如果照直覺寫 path.contains("/posts"),會炸得很安靜:因為 /api/posts/:id/view(每次有人瀏覽任何一篇文章都會打)也含 /posts → 每次有人看文,就清光全站 ISR 快取 → ISR 直接失效、還不會報錯。得明確排除 /view、/like、/reactions、/comments,並且寫一個 unit test 把它釘死。
快取規則用白名單,不用 /**。 全站包(/**)是 fail-open:日後新增任何讀 cookie 或 render 使用者資料的頁,都會被預設公開快取而且沒人會察覺。白名單則相反,新頁預設不快取。明確不快取的:/(要讀 cookie / Accept-Language 做語言導向,快取它等於把第一個訪客的語言發給全世界)、/admin、/auth。
番外:每個 SSR 頁面都靜默卡死的那三隻疊在一起的 bug
遷 Nitro 那天,每個 SSR 頁面都靜默卡死(回 000,不報錯、不 timeout),而 /api proxy 跟靜態資產都 200。查下來是三隻各自都能單獨掛掉全站的 bug 疊在一起,最致命的一隻最陰:nitro@3.0.0 是 9 個月前的舊版。 package.json 寫 ^3.0.0-beta,而 semver 的 prerelease 比對規則永遠匹配不到那些以日期編號的 beta(latest = 3.0.260610-beta)。這就是前面說「整套 bleeding edge 會咬人」的具體一口。舊版有個 routeRules.swr × ssr-renderer 的衝突,會讓請求繞回自己 → 無限自我迴圈。升到最新,全綠。
更值得記的是後半:我一度以為修好了,但停下來反問自己一句「這些修復是真的,還是只是繞過症狀?」——做了消融測試才發現,5 個「修復」裡只有 2 個是真的(升 nitro、SSR 改打本機後端),其他 3 個(server.ts、noExternals、/api proxy)全是在繞舊版 bug,新版根本不需要。那個 /api proxy 甚至是「解決一個不存在的問題」。最後配置塌回官方最小型 plugins: [tanstackStart(), viteReact(), nitro()] + 一份 SWR 白名單。繞過去很容易看起來像修好了;真的修好,得先承認自己可能只是繞過去。
MDX:一條會 eval 的管線,和一筆想還的債#
到這裡文章都還是用 react-markdown 渲的。後來我想要 <Note>、<Annot>、<Diff>、<Chart> 這些自訂 block(為了讓讀者的沉浸感夠深),就疊了一條 管線(逐篇 opt-in、只有 format=mdx 的文章走)。這條管線是這篇最技術的一段,而且我對它的理解一開始就有個要修正的地方:
IMPORTANT
eval 不是互動帶來的,是「文章裡有可執行的 JS」帶來的
我原本以為「有互動的 block 才需要 eval」,不精準。react-markdown 是解析(把字串解析成 AST 再對應成元件),不執行任何東西;而 MDX 是「編譯成 JS 再執行」:文章裡 {new Date().getFullYear()} 這種行內表達式,是真的 JavaScript,要在瀏覽器端執行那段編譯出來的 JS。所以會不會踩到 eval,取決於「文章裡有沒有可執行的 JS」,不是「有沒有互動」。
編譯只在 server 做。 @mdx-js/mdx 的編譯器是 micromark + acorn,很重,不該進 client bundle。所以我用 TanStack 的 :SSR 時 in-process 跑,client 端導覽時走 RPC 回 server 拿編譯結果,產出一段 function-body 字串(可序列化、可 dehydrate 塞進 HTML)。執行在 client 做:前端拿到那段字串,用 runSync 執行成 React 元件,而 runSync 底層就是 new Function,也就是 。
// server-only:編譯器很重,不進 client bundle;產出 function-body 字串可序列化
export const compileMdx = createServerFn({ method: 'POST' })
.handler(async ({ data: source }) => {
const { compile } = await import('@mdx-js/mdx');
return String(await compile(source, { outputFormat: 'function-body', remarkPlugins: [remarkGfm, remarkAlert] }));
});
// client:runSync 同步執行成元件(SSR 與 hydration 都能跑);⚠️ 底層是 new Function = eval
const { default: Content } = runSync(compiled, jsxRuntime);順帶說一段編輯器的岔路:我一度裝了 MDXEditor(市面上唯一 MDX 原生的所見即所得編輯器)做 spike,但它有 131 個依賴、而且把我的自訂 block 全渲染成一排「齒輪 + 標籤」的通用 UI,不是真的元件;我又偏好 Monaco 的樣子。後來想通:MDXEditor 從來不是「讓 MDX 能動」的必要條件。我用 Monaco 寫 MDX 原始碼、渲染器(compileMdx + runSync)把它渲出來,這就夠了。整個 spike 乾淨撤掉,留 Monaco。其餘幾個決定:MDX 編譯失敗自動退回 markdown(不讓一個手殘的標籤炸掉整篇);MDX 文與 markdown 文共用同一批基礎元件(shiki 高亮、mermaid、連結卡、標題錨點)。
那筆想還的債,就在這個 runSync 上:
NOTE
其實我到現在根本還沒開 CSP
這個 eval 目前是可控的:內容都是我自己寫、自己審過的,不是使用者投稿。而且說來有點好笑:我到現在根本還沒開 ,因為 __root.tsx 裡有一段 inline 的「防閃爍/intro」script,貿然上 CSP 會把站打壞,得先把它 nonce 化。所以那些 unsafe-eval 的註解,意思是「等我哪天要上嚴格 CSP,這條 eval 會逼我放行 unsafe-eval」。我不想留著它。
那要怎麼收掉這條 eval?我一開始想的是 :把每個互動 block 改寫成「CSS + 一支 bundled 的 vanilla JS」,重的 block 用 registry 掛載。但這要重寫一堆東西、有行為回歸風險。後來想通一條乾淨太多的路。eval 之所以存在,只是因為我把 MDX 編成了 function-body(那種格式一定要 new Function 才跑得起來)。如果改編成「真正的 ES module」,client 就能用 import() 載入它——而同源的 import() 在嚴格 CSP script-src 'self' 底下是被允許的(那是模組載入,不是 eval)。 整條管線、所有 block、所有動畫完全不動,零重寫零回歸;islands 只是這條撞牆時的退路。目標流程是這樣:
(這條還沒動工,存進待辦了。也是為了同一個目標,我回頭認真評估過 Astro:它原生就是 islands、天生 CSP 友善,但 Astro 是獨立框架、獨立 build,嵌不進我現有的 TanStack Start,「只在 blog 用 Astro」實務上等於把站拆成兩個 app,共用的 Header / 目錄 / 反應 / 留言全要重寫成 island、i18n / ISR / SEO / Rust 資料層全要重建。殺雞用牛刀還要拆廚房,dynamic-import 那條 CP 值高太多。)
首幀不再閃:進場除錯長征#
以上都上線了,但真正磨到深夜的,是「進文章的那一瞬間」。這一段全是渲染時序的坑,也是整篇的核心。先看結果。左邊是當時的慘況,右邊是收尾後:
我最早注意到的是:進文章的一瞬間會閃一下,被 Query 快取之後就不會了。但真相不是 Query:那個 ClientOnly 的 fallback 先吐了一個純 sans-serif、零文章 CSS 的內文,等重的 FullBlogPost chunk(帶 BlogPost.css + shiki + mermaid)載入才整個換上去,是全面重繪,不是套個樣式。
第一版修法是 idle 預暖那個 chunk,救了站內導覽;但直接貼一個 URL 冷開還是會閃,因為 ClientOnly 永遠先送 fallback。第二版才治本:把 fallback 重建成跟 FullBlogPost 一模一樣的結構、引同一份 CSS——JS 全關下,第一幀就是完整樣式的文章。
那個「在原本的 HTML 上加樣式」的中間版本,我自己看了也不對:我要的是骨架載入:整個版面(含側欄、目錄)第一幀先當框架出來,各區塊各自 loading 填入。於是加了 shimmer 骨架:側欄與目錄先出骨架,主文照樣是真的(SEO 不能少)。
我看到的問題很明顯是「被頁面元素清掉、重跑動畫插入」。根因是同一篇內文被渲染了兩次:BlogPostPage(SSR 出)→ 然後 FullBlogPost(ClientOnly)掛載後再渲一次蓋上去;因為是兩棵不同的元件樹,React 只能拆掉重建 = 肉眼可見的「清掉再插入」。分兩階段治:
Tier 1(讓交接看不見): 進場動畫改 initial={false}(別把已經在的內容當全新載入重滑一遍)、目錄直接在 loader 裡算好(server 端、免費,因為內文已經 SSR)、再把抽 heading / 算閱讀時間的邏輯抽成共用 lib,讓兩個版本逐字一致。交接變成約 95% 無縫。
Tier 2(真身): 先把整棵渲染樹掃過、揪出所有 SSR blocker,再動那個一千八百行的檔:把 FullBlogPost 變 SSR-safe(localStorage 改「SSR 給預設值、useEffect 補讀」、日期補時區、window 存取包 guard、mermaid 的 zoom 包成一個小 ClientOnly 島)→ 然後把外層的 ClientOnly 整個拿掉 → 變成單次 SSR 渲染、原地 hydrate。雙渲染從根消失,0 骨架殘留、0 hydration 錯誤,BlogPostPage 就此變成死碼。
reload 一篇文章、錨點把畫面拉回原位時,會先往上、瞬間往下、再往上,卡一下。我的直覺是「感覺是有東西把它握住了,而不是高度計算問題」——這個直覺是對的,而我一度往錯的方向修。
真因是兩個東西疊加:全域的 html { scroll-behavior: smooth } + .post-content 上的 把高度猜錯了(猜 1200px,真實約 9648px,差 8 倍)。而 scroll-behavior: smooth 讓每一次程式化捲動都變成動畫,下一次呼叫打斷上一次、在原地重啟 → 永遠到不了目標;錨點還原用的 scrollTo 就這樣被一路拖住。A/B 實測後拍板:拿掉全域的 smooth,目錄點擊、回頂那種要平滑的地方改用 JS 顯式指定。
單次渲染之後,進場動畫做得回來了,但本體文字的動畫必須用 CSS @keyframes,不能用 framer-motion 的 initial={{opacity:0}}。原因很硬核:Chrome 的 不把 opacity:0 的元素算進去。用 framer 讓內文從透明淡入,等於把 LCP 綁死在 hydration 之後。所以本體走 CSS transform(不碰 opacity),framer 只留給退場、stagger 這些不影響 LCP 的地方。
寫到這裡我也好奇,同樣是內容站,大家最後都停在哪一格:
收束:三次改寫的帳#
這條渲染長征,其實是三筆不同層級的帳:
- SEO 與首屏,是 SSG/ISR 的紅利;hydration,是換來的一整類新 bug。 從此文章內文、標題、hreflang 都真的被 render 進 HTML,爬蟲不用跑 JS 就看得到;代價是多了一層 Node 渲染殼,和「SSR 送的跟 client 畫的要逐字一致」這種以前不存在的 bug。
- 量測,不要猜。 這一晚最值錢的一件事,是逼自己停下來反問「這是真的修好,還是只是繞過去/猜對了?」。它把我兩個憑印象下的錯誤診斷(那三隻 bug 的假修復、捲動的序列化 bug)按了回去,逼我用消融測試和 Playwright 把真相量出來。
- 幾條會反覆咬人的規則:
useEffect不在 server 跑,所以靠它抓資料的頁面,SSR 跟 prerender 都只會吐一個空殼;fail-open 的全站快取規則是顆安全地雷;拿路徑前綴清快取,記得排除高頻端點。
還有幾筆沒還完的債,誠實列著:MDX 的 runSync 是 client eval,雖然目前 CSP 根本還沒開、暫時無害,但這條 eval 是我上嚴格 CSP 前想先收掉的。計畫是把 MDX 編成 ESM 模組、用 import() 取代 runSync(islands 只是退路);ISR 快取還在記憶體裡,每次部署或重啟就全站歸零(還沒接 fs driver);舊的 SEOHead(react-helmet)也還沒完全退役。渲染這題大概永遠改不完,但至少現在,一篇文章從資料庫走到你螢幕上的每一步,我都知道它為什麼長這樣了。
- TanStack Start —— 全端框架(SSR / server functions / Nitro)官網
- MDX —— 在 Markdown 裡寫 JSX官網compile / run
- Nitro —— route rules 與 ISR / SWRRoute Rules
還沒有留言
✨ 成為第一個留言的人吧