主題: Web platform
別只看 JavaScript Bundle:SSR HTML 裡的資料也有成本
SSR 能改善首屏與 SEO,但 hydration JSON、RSC payload 仍要下載與解析。這篇整理 Next.js 的量測、診斷與瘦身順序。
動態迷因(展開/收合)
前端效能檢查很容易從 JavaScript bundle 開始,也停在 JavaScript bundle:哪個 chunk 太大、能不能 tree-shake、要不要 dynamic import。
但 server-rendered React 還有另一包常被忽略的資料。Pages Router 把 page props 放進 __NEXT_DATA__;App Router 則傳送 React Server Component payload。它們不是傳統 .js bundle,卻同樣要穿過網路、被瀏覽器解析,並參與 hydration 或後續導覽。
最近 daily.dev 整理的 RSC performance pitfalls 把「跨 server-client boundary 傳太大的資料結構」列為常見問題;Reddit 也有人遇到 RSC request 只有約 60 kB,卻等待兩三秒。
這兩個案例看起來都叫 payload problem,解法卻可能完全不同。大小決定傳輸、解析與記憶體成本;等待時間也可能來自動態 rendering、cache miss、cold start 或資料庫距離。 沒先拆開量,就很容易刪錯東西。
SSR 省掉一次 client fetch,不代表資料不用送
Server-side rendering 的好處很實際:使用者先拿到可顯示的 HTML,crawler 也能在初始文件讀到內容。可是 client 端若要接手互動,React 必須知道 server 當時 render 了什麼。
React 的 hydrateRoot會把元件邏輯接到 server 產生的 HTML 上,而且 client 第一次 render 必須和 server output 相符。Framework 因此需要一份能重建或 reconcile 畫面狀態的資料契約。
這份契約不是免費的:
Document HTML
+ serialized page data or RSC payload
+ client JavaScript
+ CSS, fonts, images, and later requests
把 JavaScript bundle 從 300 kB 降到 200 kB,同時把 500 kB CMS response 原封不動塞進 HTML,不算完成優化。只是把重量換了行李箱。
Pages Router:所有 props 都會走進初始 HTML
Next.js 的 getServerSideProps 文件明確提醒:無論 rendering 類型,傳給 page component 的 props 都能在 client 的初始 HTML 中看到,因為 hydration 需要它們。敏感資料因此不該放進 props。
Pages Router 通常把這些資料序列化到:
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{...}}}
</script>
Next.js 目前會對超過 128 kB 的 page data 顯示 Large Page Data warning。官方列出的成本包括:
- 每次 HTML response 都內嵌資料,增加 page weight
- React 要等 JSON parse 後才能 hydrate
- 即使畫面只用其中一小部分,整包資料仍留在 client memory
128 kB 是 framework 的警示線,不是「127 kB 就一定快」的效能保證。真正 budget 要看使用者網路、裝置、頁面互動需求與 cache 行為;但看到 warning 時,不該只把 threshold 調高然後繼續。
App Router:Server Component 不等於零 payload
App Router 改變了傳輸格式,也減少許多 client JavaScript,但沒有取消 server 與 browser 的資料交換。
Next.js 的 Server and Client Components 文件說明,初次載入會同時使用兩份輸出:
- HTML 先顯示 route 的 non-interactive preview。
- RSC payload 用來 reconcile Server/Client Component tree。
- JavaScript hydrate Client Components,加入互動。
RSC payload 包含 Server Components 的 render 結果、Client Component 位置與其 JavaScript reference,以及從 Server Component 傳給 Client Component 的 props。
所以 Server Component 的正確優勢是:server-only code 與 dependency 不必進 client bundle,而且可以直接存取資料來源。它不是「fetch 出來的整個物件都不用送」。一旦資料成為 render output,或穿過 'use client' boundary 成為 props,browser 仍可能收到它。
實用原則是把 Client Component boundary 往下放。互動只有一個收藏按鈕時,就讓按鈕當 Client Component;不要為了那個按鈕把整張含完整 CMS response 的文章卡變成 client tree。
先分清楚:下載慢,還是 server 等太久
「一個 60 kB request 花兩秒」不等於「60 kB 太大」。兩秒也可能幾乎全耗在 Waiting for server response,而真正 Content Download 只花幾十毫秒。
先在 DevTools Network 拆開 timing:
| 現象 | 優先調查 |
|---|---|
| Waiting/TTFB 很長 | 動態 rendering、資料查詢、region、cold start、cache miss |
| Content Download 很長 | payload 大小、壓縮、網路品質 |
| Download 快,互動仍晚 | JSON parse、hydration、client JS、main-thread work |
| Client navigation 才慢 | RSC/API route 是否命中 CDN、prefetch 是否生效 |
再看 next build 的 route table。原本以為是 static 的頁面若被標成 dynamic,先找 cookies()、headers()、cache: 'no-store'、未處理的 dynamic segment 或其他 request-time dependency。不要先把可見內容刪掉,因為那可能完全不影響真正的兩秒等待。
Next.js Automatic Static Optimization也強調,沒有 blocking data requirement 的 page 才能預先產生 static HTML;加入 getServerSideProps 或 getInitialProps 會改成 request-time rendering。Payload 與 rendering mode 是兩條不同的檢查線。
三個數字,比一個 Lighthouse 分數有用
我會先記錄三種 budget:
- Document transfer size:使用者實際從網路下載多少 HTML。
- Serialized state size:
__NEXT_DATA__或 RSC/route payload 多大。 - Client JavaScript size:下載、parse 與 execute 的 JS 多大。
Pages Router 可以直接在 console 量 __NEXT_DATA__:
const json = document.querySelector("#__NEXT_DATA__")?.textContent ?? "";
new TextEncoder().encode(json).byteLength;
正式環境的壓縮傳輸量可用:
curl --compressed -sS -o /dev/null \
-w 'download=%{size_download} bytes\n' \
https://example.com/page
App Router 則在 Network 面板分開看初始 Document 與 client navigation 的 RSC request。別只看檔名;同時記錄 size、Waiting、Content Download 與 cache header。
最後比較修改前後。沒有 baseline 的「感覺變輕」很容易只是本機 cache 剛好命中。
動態迷因(展開/收合)
瘦身順序:先縮資料契約,不要先拆架構
最便宜的修法通常在資料離開 server 前:
1. Query 就只選畫面要用的欄位
不要 SELECT * 或把完整 CMS entry 交給 page,再期待 React 幫忙忽略。列表卡若只顯示 id、title、slug、thumbnail 與兩個狀態,就只回這些欄位。
const cards = rows.map(({ id, title, slug, thumbnail, status }) => ({
id,
title,
slug,
thumbnail,
status,
}));
這不是為了漂亮 type,而是明確定義 server-to-browser contract。
2. Pagination 要發生在 server
先傳 2,000 筆,再用 CSS 隱藏 1,980 筆,不是 pagination。HTML、serialized data 與 memory 都已經付費。第一頁只取第一頁,下一頁再透過 route 或 user action 取得。
3. 把 'use client' boundary 往互動元件移
Server Component 可以產生大部分靜態 markup,只把 button 所需的 id、初始狀態與 label 傳給 Client Component。不要把整個 API response 穿過 boundary,只因孫節點需要一個 click handler。
4. 延後次要資料,不延後主要內容
推薦項目、非首屏統計或展開後才看的詳情,可以等互動或進 viewport 再載入。頁面標題、主要正文、canonical 所需資料與 crawler 應看到的內容,仍應留在初始 HTML。
把所有內容搬成 client fetch,確實會讓 HTML 變小;同時也可能得到 loading spinner、request waterfall、較差的弱網體驗與不穩定 SEO。那不是瘦身,是轉嫁。
5. 開啟壓縮,但別拿壓縮掩護過量資料
web.dev 的 HTML performance 指南建議文字資源使用 Brotli 或 gzip。壓縮能減少 wire bytes,卻不能消除解壓後的 JSON parse、hydration 與 memory 成本。
高重複 JSON 可能壓得很好,讓 Network 看起來不大;browser 最後仍要還原並解析整包。Raw size 與 transfer size 都要保留。
四個常見假修復
- 只調高 large-page-data threshold。 Warning 消失,使用者成本沒消失。
- 為了少一包 JSON,讓主要內容改成 client fetch。 HTML 變瘦,卻多一個 waterfall。
- 直接遷移 App Router。 新架構不會替你決定哪些資料該穿過 client boundary。
- 只看壓縮後 KB。 Download 變小,不代表 parse、hydrate 與 memory 免費。
還有一條不是效能問題,而是 security boundary:所有進 page props 或 Client Component props 的資料,都要假設使用者看得到。Server function 能讀 secret,不代表它的 return object 可以整包交給 client。
我的檢查順序
遇到 SSR 頁面慢或 HTML 過大,我會依序做:
- 看 route 是 static 還是 dynamic。
- 拆 Network timing,分清 TTFB 與 download。
- 量 Document、serialized state、client JS 三個大小。
- 找最大 props/RSC boundary,而不是先換 framework。
- 在 query 或 mapper 做欄位 projection。
- 對長列表加入 server-side pagination。
- 重測弱網、低階裝置與 production cache。
這個順序刻意把重構放在最後。多數 payload 問題不需要新 data layer;它只需要停止把 server 知道的所有事情都告訴 browser。
本站實作觀察:沒有 SSR,就別假裝在優化 RSC payload
本站透過 src/lib/articles.ts 的 eager import.meta.glob 讀取本機 MDX,sitemap route 也在 ./dist 上傳成 static assets 前 prerender。換句話說,文章 metadata 與正文都在 build 時解析;一般讀者造訪本站不會拿到 Next.js __NEXT_DATA__ 或 RSC stream。
實際驗證刻意很無聊:
pnpm lint
pnpm build
pnpm seo:check
這些指令能證明 source 合法、static output 成功產生,以及本站的 sitemap 與 metadata assertion 通過。它們不量測瀏覽器效能,也不能證明 production 已更新;部署後仍要另外檢查 live response。
本篇談的 SSR 問題,只有在未來加入 client island、request-time data,或引入 React/Next.js surface 時才會套用到本站。現在去縮一個不存在的 RSC payload,只是表演式優化;真正要看的 budget 是已送出的 HTML、CSS、媒體與實際 shipped 的 JavaScript。
結論:HTML 也是交付格式
SSR 常被當成「效能已經處理了」的選項。其實它只是決定第一份 UI 在哪裡 render,沒有替你決定應傳多少資料。
Pages Router 的 __NEXT_DATA__、App Router 的 RSC payload,以及傳入 Client Component 的 props,本質上都是 server 與 browser 之間的 API contract。只是其中一些藏在 HTML 或 framework protocol 裡,看起來不像一般 JSON endpoint。
所以我的預設是:保留使用者與 crawler 需要的完整內容,但只傳 browser 完成畫面與互動真正需要的資料。
先量大小與等待時間,再縮 contract。不要因為資料是 SSR 出來的,就假裝它沒有重量。
外部參考連結
- Next.js:Large Page Data
- Next.js:getServerSideProps
- Next.js:Server and Client Components
- Next.js:Automatic Static Optimization
- React:hydrateRoot
- web.dev:General HTML performance considerations
- daily.dev:6 React Server Component performance pitfalls in Next.js
- Reddit:Next.js RSC payload requests are taking 2–3 seconds