畫出 CSR、SSR、SSG、ISR、串流 SSR 各自的載入時間線,並說出每種策略對 TTFB、FCP、LCP、INP 的影響方向。
解釋 hydration 是什麼、為什麼它常常是 SSR 頁面的長任務來源,以及 selective hydration、islands、React Server Components 各用什麼方式減輕它。
用 React 18+ 的 與 實作串流 SSR,並排除代理 / CDN 緩衝造成的失效。
針對不同類型的頁面(行銷頁、商品頁、儀表板)選擇合適的渲染策略與快取搭配。
在 DevTools 與 Performance API 中辨認 SSR 頁面的典型瓶頸。
點餐時,CSR 像是外送一個「自己組裝的餐盒」:先送空盒與說明書(空 HTML 與 JS),你自己再去拿食材(API)組起來,過程中盒子是空的;SSR 是廚房先組好整份餐點送來,一打開就能看,但廚房要花時間、餐點也可能還沒附餐具(尚未 hydration,不能互動);串流 SSR 則是先送前菜、主菜隨後陸續上桌,你不必等全部做完才開始吃。
渲染策略光譜
L2.5cm L4.3cm L3.4cm L3.4cm 策略 & HTML 何時、在哪產生 & 優點 & 代價
CSR(client-side rendering) & 瀏覽器執行 JS 後才產生內容;伺服器只給空殼 & 伺服器最便宜、可全部放 CDN;互動狀態自然 & FCP / LCP 最晚;依賴 JS 下載 + 執行 + API 往返的瀑布
SSR(server-side rendering) & 每次請求時在伺服器產生完整 HTML & FCP / LCP 早;SEO 友善;資料在伺服器端取得(離資料庫近) & TTFB 變長;伺服器成本;需要 hydration 才能互動
SSG(static site generation) & 建置時預先產生,部署成靜態檔 & TTFB 最低(CDN 命中);零伺服器負載 & 內容更新要重新建置;頁面數多時建置久;無法個人化
ISR(incremental static regeneration) & 第一次請求時產生並快取,過期後背景重新產生(SWR 的 HTML 版) & SSG 的速度 + 可定期更新;可按需清除 & 使用者可能看到略舊的內容;平台綁定較深
串流 SSR(streaming) & 伺服器先送出外殼,慢的區塊準備好再逐段送 & TTFB 與 FCP 不被最慢的資料拖累;可配合 selective hydration & 代理 / CDN 緩衝會破功;狀態碼與標頭必須先決定
Islands / partial hydration & SSR 全頁,但只有標記為互動的元件才載入 JS 並 hydrate & JS 量大幅下降,INP 好 & 元件之間共享狀態較麻煩
RSC(React Server Components) & 元件在伺服器執行並序列化,只有 client component 送 JS & 零 bundle 的資料元件;與串流、Suspense 整合 & 心智模型新;需要框架(Next.js 等)支援
max width=
FCP、LCP 提前:HTML 一到就有內容可畫,不必等 JS。LCP 圖片也能被 preload scanner 更早發現。
TTFB 變長:伺服器要取資料並渲染,這段時間全算進 TTFB;若資料來源慢,TTFB 可能比 CSR 的空殼慢數百毫秒。串流與快取是解方。
INP 風險:hydration 是一段密集的 JS 執行,常形成長任務;使用者在頁面「看起來好了」時點擊,卻要等 hydration 結束才有反應。
CLS 通常改善:內容一開始就在 HTML 中,較少「先空再填」的跳動;但若 SSR 與 client 狀態不一致(例如依裝置寬度渲染),反而會在 hydration 時跳動。
Hydration:SSR 頁面的隱形成本
瀏覽器收到 SSR 產生的靜態 HTML 後,前端框架下載 JS、重建元件樹、把事件監聽器接到既有 DOM 上,讓頁面「活起來」的過程。在 React 中是 。hydration 必須重新執行所有元件的渲染邏輯(雖然不重建 DOM),成本與元件數量成正比。
為什麼 hydration 拖慢 INP
傳統的 hydration 是一整棵樹同步完成,大型頁面可能是 300–1000 ms 的單一長任務;期間任何點擊都要排隊(輸入延遲)。
使用者看到內容的當下最想點,正好撞上這段時間——SSR 讓 FCP 提前,也把「看得到但點不動」的窗口拉開。
hydration 前的互動可能遺失(HTML 上的按鈕沒有監聽器),或在 hydration 後才被重播。
減輕 hydration 的技術
L3.4cm L6.2cm L3.9cm 技術 & 做法 & 代表
Code splitting + lazy & 不在首屏的元件用 / 動態 ,hydration 只處理已載入的部分。 & 所有框架
Selective hydration & 用 切分,各區塊可獨立、非同步地 hydrate;使用者點擊某區塊時優先 hydrate 該區塊並重播事件。 & React 18+
Progressive / lazy hydration & 元件進入視窗()、閒置時()或互動時才 hydrate。 & Astro / 、Vue 3.5 lazy hydration、Nuxt
Islands architecture & 頁面預設是零 JS 的靜態 HTML,只有標記為互動的「島」才送 JS 並 hydrate。 & Astro、Fresh、Marko
Server Components & 純展示、取資料的元件只在伺服器執行,不送 JS 到瀏覽器;client component 才需要 hydrate。 & React Server Components(Next.js App Router)
Resumability & 不做 hydration:在 HTML 中序列化必要狀態與監聽器位置,互動時才載入對應的極小程式碼。 & Qwik
// 進入點(client)
performance.mark('hydration-start');
hydrateRoot(document.getElementById('root'), <App />);
// React 18+ 的 hydrateRoot 是非同步 / 可中斷的,用 requestIdleCallback 或
// 首個 useEffect 標記結束會更準確;這裡用簡化版
requestAnimationFrame(() => {
performance.mark('hydration-end');
const m = performance.measure('hydration', 'hydration-start', 'hydration-end');
console.log('hydration(ms)', m.duration.toFixed(0));
});
// 觀察 hydration 期間有沒有長任務(會拖慢 INP)
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration > 100) console.warn('長任務', e.duration.toFixed(0), 'ms', e.scripts?.[0]?.sourceURL);
}
}).observe({ type: 'long-animation-frame', buffered: true });串流 SSR 與 Suspense
傳統 SSR 是「等全部資料到齊 → 渲染整頁 → 一次送出」,最慢的那個資料源決定了 TTFB。串流 SSR 把 HTML 拆成多段:先送出外殼與已就緒的部分(TTFB 只取決於外殼),慢的區塊用 包起來,先送 fallback,資料到了再把內容當成後續 chunk 送到,並用一小段內嵌 script 把它替換到正確位置。
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App.jsx';
const app = express();
app.get('*', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/assets/client.3f2a1b.js'], // hydration 用的 JS,會加在 HTML 尾端
onShellReady() {
// 外殼(Suspense 邊界以外的部分)已就緒:決定狀態碼與標頭,開始送
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'private, no-cache');
pipe(res);
},
onShellError(err) {
// 外殼本身出錯:回退到 CSR 的空殼或錯誤頁
res.statusCode = 500;
res.send('<!doctype html><p>載入失敗,請重試</p>');
},
onError(err) {
didError = true; // Suspense 邊界內的錯誤:該區塊會退回 client 端渲染
console.error(err);
},
});
setTimeout(abort, 10_000); // 最多等 10 秒,之後剩餘區塊交給 client 完成
});export default function App({ url }) {
return (
<html lang="zh-Hant">
<head>
<meta charSet="utf-8" />
<link rel="stylesheet" href="/assets/main.9c0d.css" />
{/* LCP 圖片:讓 preload scanner 在 HTML 一到時就發現它 */}
<link rel="preload" as="image" href="/img/hero-800.avif" fetchPriority="high" />
</head>
<body>
<Header /> {/* 外殼:立刻送出 */}
<Hero /> {/* 外殼:含 LCP 圖片 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={url} /> {/* 慢的 API:資料到了再串流補上 */}
</Suspense>
<Suspense fallback={null}>
<Recommendations /> {/* 更慢:不影響上面任何東西 */}
</Suspense>
</body>
</html>
);
}串流的前提是每一段 HTML 都能立刻抵達瀏覽器。以下任一環節都會把 chunk 攢起來一次送出,讓串流形同虛設:
反向代理緩衝:nginx 預設 。關閉它,或讓 origin 回 。
壓縮中介層:gzip / Brotli 會累積到一定大小才輸出。Node 的 中介層需要在每個 chunk 後 ;多數框架(Next.js)已處理。
CDN / WAF:有的產品預設緩衝整個回應以便掃描或壓縮;確認「streaming / chunked response」支援。
Serverless 平台:部分 FaaS 只支援一次性回應;請選擇支援 streaming response 的執行環境。
驗證方法:DevTools Network 點該 HTML 請求,Timing 分頁中 Waiting (TTFB) 應該很短、Content Download 拉長;或用 觀察是否逐段輸出。
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_buffering off; # 不要攢 chunk
proxy_cache off;
gzip on; # nginx 的 gzip 會逐 chunk 壓縮,沒問題
}
SSG、ISR 與「把 SSR 結果快取起來」
SSR 每次請求都渲染,是最貴的做法。若頁面對所有匿名使用者都相同,就該把結果存起來:
SSG:建置時產生全部頁面,部署到 CDN。TTFB 幾乎等於 CDN 邊緣命中()。適合行銷頁、文件、部落格。
ISR / 按需再生:以 Next.js 為例, 或路由層的 ,讓頁面在 CDN 上以 SWR 的方式更新;內容變動時呼叫 / 立即清除。這正是與講的 + purge by tag,只是由框架幫你做。
在 CDN 快取 SSR 的 HTML:不靠框架,直接對 SSR 回應加 ,並依 cookie 分流登入者。
Partial Prerendering(PPR):Next.js 發展中的模式:同一頁面的靜態外殼在建置時預先產生、可放 CDN,動態區塊以 Suspense 邊界在請求時串流補上——把 SSG 的 TTFB 與 SSR 的動態性合在一頁。
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export const revalidate = 300; // 此頁的靜態部分每 5 分鐘於背景再生(ISR)
export default async function ProductPage({ params }) {
// 商品基本資料:可快取,並打上 tag 供後台更新時清除
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { revalidate: 300, tags: [`product-${params.id}`] },
}).then((r) => r.json());
return (
<main>
<ProductHero product={product} /> {/* 含 LCP 圖片,走 next/image 的 priority */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice id={params.id} /> {/* 個人化 / 即時:cache: 'no-store',串流補上 */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={params.id} />
</Suspense>
</main>
);
}為每種頁面選策略
L2.8cm L4.6cm L6.1cm 頁面類型 & 特性 & 建議策略
行銷頁、文件、部落格 & 所有人相同、更新不頻繁 & SSG(或 ISR),放 CDN;islands 只為少數互動元件送 JS。
商品列表 / 分類頁 & 匿名者相同、更新頻繁、SEO 重要 & ISR 或 CDN 快取的 SSR(s-maxage + SWR + purge by tag)。
商品頁 & 大部分靜態 + 少量即時(價格、庫存、購物車) & 靜態外殼 SSG / ISR,即時區塊以 Suspense 串流或 client 端補上(PPR 模式)。
搜尋結果 & 查詢組合無限、需要新鮮 & 串流 SSR(外殼立即、結果串流);熱門查詢可短 TTL 快取。
登入後儀表板 / 後台 & 個人化、互動密集、SEO 無關 & CSR + app shell()或 SSR 只做外殼;重點在 code splitting 與 INP。
結帳流程 & 高互動、安全性高、不可快取 & SSR 外殼 + 少量 JS;不要 prerender;。
這頁對所有匿名使用者是否相同?是 → 預先產生並放 CDN(SSG / ISR);只有部分不同 → 靜態外殼 + 動態區塊。
內容多久變一次、變了之後多快要被看到?分鐘級 → SWR / ISR;即時 → 該區塊不快取,用串流補上。
互動有多重?重 → 想辦法少送 JS(islands、RSC、code splitting),並量 INP;輕 → SSR / SSG 幾乎沒有 hydration 問題。
診斷 SSR 頁面
L3.8cm L9.7cm 症狀 & 檢查方式與常見原因
TTFB 很長(\(>\) 800 ms) & Navigation Timing 的 ;伺服器端用 拆解。原因:資料源慢、未快取、沒有串流、伺服器在遠端區域、冷啟動。
TTFB 短但 FCP 晚 & HTML 到了卻沒東西畫:檢查是否 render-blocking CSS / 同步 JS、或 HTML 其實是空殼(view-source 看有沒有內容)。
LCP 晚但 FCP 早 & LCP 圖片是否在 HTML 中直接出現且有 ?是否被 client 端才決定的 (例如依 JS 量寬度)延後?
INP 差、集中在載入初期 & Performance 面板看 hydration 的長任務;Interactions 軌道顯示大量輸入延遲。解法:Suspense 切分、lazy hydration、減少首屏元件、RSC。
CLS 出現在 hydration 時 & SSR 與 client 初次渲染不一致(hydration mismatch 警告);依 或 決定的 UI 應改為 CSS 或延後渲染。
串流沒效果 & 看是否逐段輸出;檢查 nginx buffering、壓縮、CDN 設定。
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>商品名稱</title>
<!-- 關鍵 CSS 內嵌,避免 render-blocking 的往返 -->
<style>/* critical css,約 10 KB 以內 */</style>
<!-- LCP 圖片與字型:在 HTML 最前面宣告 -->
<link rel="preload" as="image" href="/img/hero-800.avif" imagesrcset="/img/hero-480.avif 480w, /img/hero-800.avif 800w" imagesizes="100vw" fetchpriority="high">
<link rel="preload" as="font" type="font/woff2" href="/fonts/brand.woff2" crossorigin>
<!-- 其餘 CSS 非同步、JS 延後 -->
<link rel="stylesheet" href="/assets/rest.9c0d.css" media="print" onload="this.media='all'">
<script type="module" src="/assets/client.3f2a1b.js" defer></script>
</head>
<body>
<!-- 真正的內容直接在 HTML 中,不是 <div id="root"></div> -->
<main>
<h1>商品名稱</h1>
<img src="/img/hero-800.avif" width="800" height="450" alt="..." fetchpriority="high">
...課堂實作
用 Vite + React 建一個顯示商品清單(含一張大圖)的頁面,分別實作:(a)CSR:空殼 + 取 API;(b)SSR:Express + ;(c)串流 SSR: + Suspense,API 故意延遲 1.5 秒。用 Performance 面板(Slow 4G、CPU 4x)錄三次,填表比較 TTFB、FCP、LCP、最長任務、Interactions 的輸入延遲。
在 9-1 的(b)版本加入 500 個含事件處理的列表項目。錄製後在 Main 軌道找到 hydration 的長任務與 Bottom-Up 中的函式。依序嘗試:列表用 包起來、改用 分割、列表項改為 server component(若用 Next.js)。每次量 INP 的變化。
在 9-1(c)前面架一個預設設定的 nginx,觀察串流是否消失(Network Timing 的 Waiting 變長、 一次吐出)。加上 後再測。若有 CDN 帳號,把它放在最前面再驗證一次。
為什麼 SSR 的 TTFB 通常比 CSR 差,LCP 卻通常更好?有沒有 SSR 的 LCP 反而更差的情況?
什麼是 uncanny valley?它對應到哪個 Web Vitals 指標?列出三種縮短它的技術。
串流 SSR 為什麼必須在 時就決定 HTTP 狀態碼?如果 Suspense 邊界內的資料回傳 404,該怎麼處理?
ISR 與 的關係是什麼?
一個電商商品頁,價格與庫存每分鐘變動,其餘內容每週才變。請設計渲染與快取策略,並說明價格變動時發生什麼事。
「把 SSR 搬到邊緣(edge)一定更快」——請舉一個反例。
CSR 的內容要等「JS → API → 渲染」瀑布,LCP 最晚;SSR 把內容放進 HTML,FCP / LCP 提前,但 TTFB 變長並引入 hydration 成本。
hydration 是 SSR 頁面 INP 的主要風險:用 Suspense / selective hydration、lazy hydration、islands、Server Components、code splitting 減少要 hydrate 的東西。
串流 SSR 讓外殼先到、慢區塊後補;記得排除代理、壓縮、CDN 的緩衝,並用 驗證。
對匿名者相同的頁面不要每次 SSR:SSG / ISR / CDN 快取 + SWR + purge by tag,讓 TTFB 回到邊緣命中的水準;動態區塊用 Suspense 或 client 補上。
診斷 SSR 頁面時,用 Navigation Timing 拆 TTFB、view-source 確認內容真的在 HTML 中、Performance 面板找 hydration 長任務。