資深前端的 15 個核心心智模型:從瀏覽器渲染路徑到微前端架構

文字解析 · 1 支影片 · 產生於 2026-10-01 00:54

🎧 語音解析📝 筆記✍️ 練習

🎧 語音解析 47m24s · TTS 講解與作者原聲交錯;點章節可跳。
邊聽邊看逐句講稿,點任一句從那裡開始
章節(17)

1. Outline

  1. 起點 · 15 Frontend Concepts Every Senior Dev Has Mastered
    以瀏覽器的關鍵渲染路徑為地基,依「量測 → 網路優化 → 資料建模 → 渲染位置 → 組織架構」的順序,把 15 個跨框架成立的前端心智模型串成一條推理鏈。

2. YouTuber 的思維推導

15 Frontend Concepts Every Senior Dev Has Mastered

theSeniorDev · 35m42s · 字幕 en · vision=on (使用者指定 vision=true;影片本身也大量出現「as you see here」「that's how it looks in the code」「in this picture」等指示語,說明高度依賴畫面上的流程圖、程式碼與 UI 截圖。) · YouTube

預估 vs 實際耗時
階段預估實際
fetch 6s 2s
segment 1m41s 3m30s
shot 1m16s 10s
analyze 15m00s 15m00s
render 5s –

作者的出發點是一個經驗觀察:他訪談過 300 多位資深前端工程師,背景與框架各不相同,唯一的共同點是同一組心智模型。於是他不從框架 API 講起,而是從瀏覽器把 HTML 變成像素的那條路徑(critical rendering path)開始,把它當作整支影片的骨架。接著他一層一層往上疊:先有路徑,才能量它(Core Web Vitals);要讓路徑變快,就從別重複下載(HTTP 快取)→ 距離變短(CDN)→ 位元組變少(內容協商壓縮)→ 根本先不載(lazy loading)→ 把 bundle 切開(bundle splitting)→ 連 CSS 也只送必要的(critical CSS)。

到這裡效能面走完,他把主題轉到資料建模:狀態越少越好(essential state),狀態的變更要收斂成單一純函式(reducer pattern),渲染量本身要受控(windowing)。最後把渲染工作搬去伺服器(SSR)、補回互動性(rehydration)、只對需要的區塊補(partial pre-rendering)、連 JavaScript 都省掉(server components),再把尺度從一頁拉到整個組織(micro frontends)。整條線的收束是他反覆講的第三個建議:不要背 15 個名詞,要看見它們彼此的關係——每一個新概念都是在補前一個概念留下的缺口。

推理鏈:每段留給下一段的線索

每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。

  1. 為什麼卡在 mid-level → 既然這 15 個模型是由底層往上長的,就得先有一個地基——瀏覽器到底怎麼把 HTML 變成螢幕上的像素。
  2. #1 關鍵渲染路徑 → 路徑講完了,但「快」是個相對詞——下一步需要能把這條路徑量化的指標。
  3. #2 Core Web Vitals → 指標有了,接下來的問題是怎麼改善它們;而路徑上最貴的一段是下載,所以第一個手段是別重複下載同一份檔案。
  4. #3 HTTP 快取與 TTL → 快取解決了「重複下載」,但第一次下載還是得從遠端伺服器跑一趟——距離本身還沒有被處理。
  5. CDN:把快取推到邊緣 → 距離縮短了,但傳輸的位元組數量本身還沒動——CDN 順手做的那個「壓縮」到底是怎麼談成的,就是下一個問題。
  6. #4 內容協商與壓縮 → 位元組變少了,但那些資源畢竟還是被送出去了;下一個更根本的問題是——有些東西根本不需要在一開始就送。
  7. #5 延遲載入與動態匯入 → 既然單一模組可以延後,那整個打包好的 bundle 顯然也不該一次全送——問題變成怎麼把它切開。
  8. #6 Bundle splitting → JavaScript 已經被切開了,但還有一種資源從第一段起就一直擋在路徑上、而且到現在都沒被處理——CSS。
  9. #7 Critical CSS → 到這裡效能面的路徑優化告一段落;但畫面畫得再快,如果背後的資料建模是亂的,重繪本身就會失控——主題該換到狀態了。
  10. #8 Essential state → 狀態被壓到最小之後,還有一個問題沒解:這些狀態該怎麼變更,才不會在複雜畫面上失控。
  11. #9 Reducer 模式 → 變更被收斂成一次計算了,但如果畫面上本來就掛著上千個節點,那一次計算引發的重繪還是會很慢——下一個要管的是渲染量本身。
  12. #10 Windowing 與可視區觀察 → 客戶端的渲染量已經被控制住了,但有一件事從頭到尾沒被質疑過——為什麼第一次渲染非得在客戶端做?
  13. #11 伺服器端渲染 → 使用者現在立刻看得到內容了,但那份 HTML 是死的——點下去沒有反應,這個缺口必須補上。
  14. #12 Rehydration → 互動性補回來了,但這個補法是整頁一起補;問題是同一頁裡其實有些區塊根本不需要互動。
  15. #13 Partial pre-rendering → 分區之後有個更激進的問題浮出來:那些完全不需要互動的區塊,是不是連 JavaScript 都不必送?
  16. #14 伺服器元件 → 到這裡單一應用內部能拆的都拆完了;最後一個尺度的問題是——當一個前端大到一個團隊放不下時該怎麼辦。
  17. #15 微前端與收尾

3. 逐段說明

15 Frontend Concepts Every Senior Dev Has Mastered

1. 為什麼卡在 mid-level 0:00–0:34

作者用 300 位資深前端工程師的共同點開場:不管背景與框架,他們都掌握同樣 15 個前端核心心智模型。多數開發者連五個都說不清楚,這正是卡在 mid-level 多年、面試一直失敗的原因。整支影片的承諾是把這 15 個模型講到能自己解釋。

承上 前情提要裡假設你已經在寫前端、也聽過「要往資深走」,但沒有人給過一張清單說到底該懂什麼。這段就從這個缺口開始。

推理作者不從「資深該會什麼技能」出發,而是從一份樣本反推:分析 300 多位資深前端工程師後發現,背景、公司、框架都不同,唯一穩定的共同點是同一組心智模型。既然是共同點,它就不是某個框架的知識,而是更底層的東西——這個推論決定了整支影片的講法:由瀏覽器機制往上講,框架只當範例。

AI 補充這裡有個作者沒說破的判準:他挑的 15 個概念全都是「跨框架仍然成立」的。React 換成 Vue、Webpack 換成 Vite,這 15 條都不用重學。這也解釋了為什麼「會用 React」不等於資深——框架 API 是實作細節,會隨版本淘汰,而心智模型不會。另外要注意樣本本身的偏誤:這 300 人來自作者自家的輔導計畫,「共同點」有一部分可能來自他們受過同一套訓練,不必當成客觀統計。

mental model

心智模型

一套能讓你在不查文件的情況下推論系統會怎麼運作的內部模型。

心智模型不是定義,而是「可以拿來推論」的結構。判斷你有沒有某個心智模型的方法很簡單:遇到沒看過的變體時,你能不能自己推出答案。舉例來說,知道「快取的定義」只能背;有快取的心智模型,你會自動追問 TTL 多久、誰負責失效、失效時誰付代價。面試裡換個問法就答不出來,通常就是只有定義沒有模型。

相關術語: critical rendering path (本片第一個)

出處:第 1 段「為什麼卡在 mid-level」

留給下一段 既然這 15 個模型是由底層往上長的,就得先有一個地基——瀏覽器到底怎麼把 HTML 變成螢幕上的像素。

2. #1 關鍵渲染路徑 0:34–2:54

第一個也是整片的地基:critical rendering path 是瀏覽器從 HTML/CSS/JS 走到螢幕像素的完整步驟。伺服器給 HTML,瀏覽器由上往下讀,遇到 CSS/JS 就去下載、解析,組成 CSSOM 與 DOM,合成 render tree,再走 style → layout → paint → composite 四步,直到 first contentful paint。頭部的資源因此叫 render blocking。作者順帶給第一個資深建議:解釋事情要從瀏覽器機制講起,框架只是實作範例。

畫面上是 style → layout → paint → composite 四步流程圖與 FCP 里程碑,這是後面每個概念回頭引用的骨架圖,只聽聲音記不住順序
1:35 · 畫面上是 style → layout → paint → composite 四步流程圖與 FCP 里程碑,這是後面每個概念回頭引用的骨架圖,只聽聲音記不住順序
HTML 文件被標出 render blocking 資源(head 的 CSS/JS)與第一個 H1,直接看到「哪些東西擋住渲染」
2:20 · HTML 文件被標出 render blocking 資源(head 的 CSS/JS)與第一個 H1,直接看到「哪些東西擋住渲染」
承上 承上:既然要從瀏覽器機制而非框架講起,第一個地基就是瀏覽器把 HTML/CSS/JavaScript 變成像素的完整路徑。

推理因為上一段確立了「由底層往上長」,作者就把 critical rendering path 放在第一個,並明說後面每個概念都會回頭引用它。他的講法是先給全景圖(server → HTML → CSS/JS/fonts → CSSOM/DOM → render tree → 四步渲染),再用一份具體的 HTML 文件把「哪些資源會擋住渲染」指出來——先建立骨架,再讓細節掛上去。

AI 補充截圖裡的流程圖值得逐格看:CSS 走到 CSSOM、HTML 走到 DOM,兩者合流成 render tree,才進入 Style → Layout → Paint → Composite。這四步的分工是後面很多優化的根據:Layout 重算最貴,Paint 次之,只動 Composite(例如 transform、opacity)最便宜。另外作者這裡口誤了一處:DOM 是由 HTML 標記解析出來的,JavaScript 只是「可能會擋住解析、也可能修改 DOM」,不是「用 JavaScript 建 DOM」。第二張截圖把重點講得比聲音清楚——head 裡的三個 stylesheet 與兩個 script 是 render blocking,body 裡的 `<h1>First paint</h1>` 才是 FCP,紅色箭頭就是瀏覽器由上往下讀的順序。

critical rendering path

關鍵渲染路徑

瀏覽器從收到 HTML 到把第一個像素畫上螢幕所必經的步驟序列。

它是整支影片的骨架:之後每個效能優化,本質上都是在這條路徑上「砍掉一段」或「延後一段」。快取砍掉下載、壓縮縮短下載、lazy loading 延後下載、critical CSS 縮短 CSSOM 建構、SSR 把整段搬到伺服器。面試時被問「怎麼讓網站更快」,正確的答法是先畫出這條路徑,再說你要動哪一段,而不是列出一串技巧名詞。

相關術語: render tree (路徑的產物)、first contentful paint (路徑的終點)

出處:第 2 段「#1 關鍵渲染路徑」

DOM

文件物件模型

瀏覽器解析 HTML 後在記憶體裡建立的節點樹。

DOM 來自 HTML 標記本身,JavaScript 只是能讀寫它。很多人以為「React 產生 DOM」,實際上 React 產生的是描述,最後仍由瀏覽器建 DOM 節點。DOM 節點數量會直接影響記憶體與重排成本,這也是後面談大量清單渲染時的痛點來源。

相關術語: CSSOM (並行的對應物)

出處:第 2 段「#1 關鍵渲染路徑」

CSSOM

CSS 物件模型

瀏覽器解析所有 CSS 後建立的樣式規則樹。

CSSOM 必須「完整」才能用,因為任何一條後來的規則都可能覆蓋前面的宣告,瀏覽器無法只算一半就開始畫。這個「全有全無」的性質正是 CSS 被設計成 render blocking 的原因,也是後面 critical CSS 這個技巧存在的理由。

相關術語: render blocking (造成原因)

出處:第 2 段「#1 關鍵渲染路徑」

render tree

渲染樹

DOM 與 CSSOM 合併後、只包含實際會被畫出來的節點的樹。

render tree 不等於 DOM:`display: none` 的節點在 DOM 裡存在,在 render tree 裡不存在(而 `visibility: hidden` 仍在,因為它仍佔位)。有了 render tree,瀏覽器才知道要排版哪些東西,Layout 階段才有輸入。

相關術語: DOM (輸入之一)、CSSOM (輸入之一)

出處:第 2 段「#1 關鍵渲染路徑」

first contentful paint

首次內容繪製(FCP)

瀏覽器畫出第一個來自 DOM 的內容(文字、圖片等)的時間點。

FCP 只保證「有東西出現」,不保證那個東西有意義——一個 spinner 或一條分隔線都能觸發 FCP。這個區別在談單頁應用時會變得很關鍵,因為那時畫面上出現的可能只是一個空容器。

相關術語: critical rendering path (第一個里程碑)

出處:第 2 段「#1 關鍵渲染路徑」

render blocking

阻擋渲染

必須先下載並解析完成、否則瀏覽器不會繼續渲染的資源。

典型的 render blocking 資源是 head 裡的 stylesheet 與沒有 async/defer 的 script。CSS 阻擋是因為樣式必須算完(見 CSSOM);同步 script 阻擋是因為它可能用 document.write 改變後面的標記,瀏覽器不敢先往下解析。給 script 加上 defer 或把它移到 body 底部,是最便宜的一種優化。

相關術語: critical rendering path (路徑上的瓶頸)

出處:第 2 段「#1 關鍵渲染路徑」

確定錯誤/已過時 1:14
「with the JavaScript that it will find in the hat section of the HTML document it will build the DOM」
DOM 是瀏覽器解析 HTML 標記本身建出來的,不是用 head 裡的 JavaScript 建的。head 裡的同步 script 反而會暫停 HTML 解析、延後 DOM 建構。作者這裡把 CSS→CSSOM 的對應關係誤套到 JS→DOM 上(同一張投影片的箭頭其實也是 HTML→DOM)。
依據: HTML Living Standard, 8.1 Parsing HTML documents;MDN「Critical rendering path」
留給下一段 路徑講完了,但「快」是個相對詞——下一步需要能把這條路徑量化的指標。

3. #2 Core Web Vitals 2:54–5:53

有了渲染路徑,才能談怎麼量它。Core Web Vitals 是三個經驗指標:LCP 與 CLS 量初次渲染,INP 量互動後的重繪速度。作者用 F1 賽車的極速、敏捷、下壓力做類比,說明指標是用來跟業界平均對標的。接著把三個指標一一掛回關鍵渲染路徑:LCP 是渲染過程中最大元素出現的時刻,CLS 是瀏覽器連續截圖比對出的版面位移,INP 則是使用者互動 → state 更新 → reconciliation → 瀏覽器重繪的整段時間。

三個指標(LCP / INP / CLS)與各自量測時機的對照圖,是本段的核心分類
3:15 · 三個指標(LCP / INP / CLS)與各自量測時機的對照圖,是本段的核心分類
作者實測自家網站的 CWV 報告(CLS 很好、LCP 很差),示範指標怎麼讀
4:05 · 作者實測自家網站的 CWV 報告(CLS 很好、LCP 很差),示範指標怎麼讀
承上 承上:有了關鍵渲染路徑這條時間軸,接下來需要能把它量化的指標,才知道「快」到底快多少。

推理因為上一段留下了「快是相對的」這個缺口,作者引入 Core Web Vitals 作為經驗指標,並刻意用 F1 賽車的極速/敏捷/下壓力做類比:一個複雜系統無法用單一數字描述,得用幾個互補的指標。更關鍵的是他接著做的動作——把三個指標一一掛回上一段的路徑:LCP 是渲染過程中最大元素出現的時刻,CLS 是持續渲染中版面位移的累積,INP 則是路徑跑完之後、使用者互動觸發的那一輪重繪。這是他示範「概念要連起來」的第一個實例。

AI 補充截圖上的門檻值比口述精確,值得記下來:LCP 好 ≤ 2.5 秒、差 > 4 秒;INP 好 ≤ 200 毫秒、差 > 500 毫秒;CLS 好 ≤ 0.1、差 > 0.25。作者說 LCP 與 CLS「在初次渲染時量測」,這對 LCP 大致成立,對 CLS 並不準確——CLS 會累積整個頁面生命週期的位移,你捲到一半才插進來的廣告一樣會扣分。另外要補一個作者略過的層次差別:他示範的 Lighthouse 報告是實驗室數據(lab data,同一台機器、模擬網路),Google 排名實際看的是 CrUX 蒐集的真實使用者數據(field data),兩者常常對不上。還有一點值得記在心裡:INP 的定義涉及「state 更新 → 重繪」這條鏈,而這條鏈正是後面談狀態管理與渲染量控制時要動刀的地方。

Core Web Vitals

核心網頁指標

Google 定義的三個使用者體驗指標,用來量測載入速度、互動回應與視覺穩定度。

它們是「經驗指標」而非理論值:門檻是從大量真實網站的分佈裡取百分位訂出來的,所以會隨時間調整(例如 2024 年 3 月 INP 才正式取代 FID)。它也是少數會直接影響搜尋排名的前端指標,這是它在面試裡出現頻率高的現實原因。

相關術語: critical rendering path (量測的對象)

出處:第 3 段「#2 Core Web Vitals」

LCP

最大內容繪製

視窗內最大的那個內容元素(通常是主圖或大標)被畫出來的時間。

LCP 與 FCP 的差別是「有東西」vs「有主角」:FCP 可能只是一個 spinner,LCP 才代表使用者看到了頁面的重點。改善 LCP 的手段幾乎都落在關鍵渲染路徑的前半段——縮短伺服器回應、預先載入主圖、去掉阻擋渲染的資源。

相關術語: first contentful paint (更嚴格的版本)

出處:第 3 段「#2 Core Web Vitals」

INP

互動到下次繪製

從使用者互動到畫面完成下一次繪製之間的延遲,量的是回應速度而非載入速度。

INP 在 2024 年 3 月取代了舊的 FID。差別在於 FID 只量「第一次互動的等待時間」,INP 量的是整個頁面生命週期裡所有互動的延遲分佈(取接近最差的那一個)。所以 INP 差通常不是載入問題,而是主執行緒被長工作卡住——過大的 state 更新、過多的 DOM 節點、沒有節流的事件處理。

相關術語: reconciliation (延遲來源)

出處:第 3 段「#2 Core Web Vitals」

CLS

累積版面位移

頁面上非使用者觸發的版面跳動量的累積分數。

瀏覽器連續比對前後畫格,算出移動的面積比例乘上移動距離比例。最常見的成因是沒指定寬高的圖片、事後插入的廣告或橫幅、以及晚到的網頁字型造成的文字重排。修法通常很機械:先為圖片與嵌入內容保留尺寸。

相關術語: LCP (同屬初次渲染)

出處:第 3 段「#2 Core Web Vitals」

reconciliation

協調(差異比對)

元件框架比對新舊虛擬樹、算出最少 DOM 操作的過程。

使用者點擊 → state 更新 → reconciliation → 真實 DOM 更新 → 瀏覽器重繪,這條鏈的總長度就是 INP。所以 INP 有一半的責任在框架層而不是網路層:元件樹越大、每次更新影響的節點越多,reconciliation 越久。這也是為什麼「少一點狀態」與「少一點 DOM 節點」在本片後半會變成主題。

相關術語: INP (構成其延遲)

出處:第 3 段「#2 Core Web Vitals」

見仁見智 3:14
「the LCP and the CLS are basically measured during the initial render」
LCP 確實只看初次載入到使用者第一次互動為止,但 CLS 是整個頁面生命週期持續累積的(以 session window 計算),捲動過程中晚載入的元素造成的位移一樣算分。把 CLS 說成只在初次渲染量測會讓人漏掉最常見的扣分來源。
依據: web.dev「Cumulative Layout Shift (CLS)」;CLS 自 2021 年 6 月改為 session window 演算法
留給下一段 指標有了,接下來的問題是怎麼改善它們;而路徑上最貴的一段是下載,所以第一個手段是別重複下載同一份檔案。

4. #3 HTTP 快取與 TTL 6:25–9:30

要改善這些指標,最直接的手段是別重複下載。作者先把快取往上抽象成 memoization:同樣輸入就重用上次輸出,這個模式在程式、資料庫、檔案各層都成立。檔案層的快取多了 TTL(視為新鮮的時間)。接著走一次完整流程:瀏覽器先向伺服器要檔案,回應依 cache-control 存進快取;TTL 內直接用快取,過期後拿 ETag 去問伺服器有沒有新版,這就是 cache invalidation。最後用 Walmart 的 CSS 檔在 DevTools 裡驗證 max-age 真的存在。

瀏覽器 ↔ 快取 ↔ 伺服器的請求流程圖,TTL 與 ETag 驗證的時序全在這張圖上
7:50 · 瀏覽器 ↔ 快取 ↔ 伺服器的請求流程圖,TTL 與 ETag 驗證的時序全在這張圖上
Walmart 網站 DevTools 裡真實的 cache-control / max-age 標頭,把抽象概念對到可以自己重現的畫面
9:05 · Walmart 網站 DevTools 裡真實的 cache-control / max-age 標頭,把抽象概念對到可以自己重現的畫面
承上 承上:指標指出瓶頸多半在下載,於是第一個改善手段就是讓同一份檔案不必重複下載。

推理作者在講 HTTP 快取之前先往上抽象一層,這個順序是刻意的:他先說快取的底層模式是 memoization——同樣輸入就重用上次輸出——並指出這個模式在程式、資料庫、檔案各層都成立。有了通用模式,HTTP 快取就只是它在檔案層的特例,多了一個 TTL。接著他才走完整條時序:首次請求 → 伺服器回應並依 cache-control 存進快取 → TTL 內直接命中 → 過期後拿 ETag 去問「還新鮮嗎」。最後用 Walmart 的真實回應驗證這些標頭確實存在。

AI 補充第一張截圖是時序圖的起手式(Browser/Browser Cache/Server 三條泳道),完整動畫在影片裡才看得到,但泳道本身已經說明了關鍵:快取是攔在瀏覽器與伺服器之間的一層,命中就不必走到最右邊。第二張 Walmart 的 DevTools 值得逐行讀:`Cache-Control: public, max-age=30541039`、`Content-Encoding: br`、`Expires: Tue, 29 Sep 2026`,三個標頭剛好對應本段與下兩段的主題。這裡作者說錯了單位——`max-age` 的單位是秒不是毫秒,30541039 秒約等於 353 天,跟旁邊的 Expires 日期正好對得上;若真是毫秒就只有 8.5 小時,跟畫面矛盾。另外要補一個作者沒說、但實務上最常踩的坑:TTL 設很長的前提是檔名帶雜湊(`app.4f2a9c.js`),改版時檔名跟著變,舊快取自然不會被用到;沒有這個前提就長 TTL,使用者會卡在舊版本。

memoization

記憶化

對同樣的輸入重用上一次計算結果,而不重新計算。

memoization 是快取的通用形式,出現在每一層:函式層(React 的 useMemo)、資料層(查詢結果快取)、檔案層(HTTP 快取)、網路層(DNS 快取)。它成立的前提是「同樣輸入必得同樣輸出」,也就是被記憶的操作必須是純的;一旦操作有副作用或依賴外部可變狀態,記憶化就會產生錯誤結果——這也是快取失效問題的根源。

相關術語: TTL (有效期上限)

出處:第 4 段「#3 HTTP 快取與 TTL」

TTL

存活時間

一份快取資料被視為新鮮、可以直接重用的時間長度。

TTL 是在「省流量」與「拿到舊資料的風險」之間下的賭注。你會在很多層看到它:DNS 記錄、CDN 邊緣快取、HTTP 的 max-age、Redis 的 EXPIRE。設定 TTL 時真正要問的不是「多久」,而是「這份資料過期時誰會受影響、影響多大」。

相關術語: memoization (加上時間限制)

出處:第 4 段「#3 HTTP 快取與 TTL」

Cache-Control

快取控制標頭

HTTP 回應標頭,告訴瀏覽器與中間層這份資源可以被快取多久、由誰快取。

常見指令:`max-age=N` 給出 TTL;`public` 允許 CDN 等共享快取儲存,`private` 只允許瀏覽器;`no-cache` 不是不快取,而是每次都要先驗證;`immutable` 表示在 TTL 內連驗證都不用做。實務上靜態資源用「長 max-age + 檔名雜湊」,HTML 用「no-cache」,是最常見的組合。

相關術語: ETag (過期後接手)

出處:第 4 段「#3 HTTP 快取與 TTL」

ETag

實體標籤

伺服器給某個版本資源的識別碼,用來判斷瀏覽器手上的快取是否仍是最新版。

TTL 過期後瀏覽器不會直接重新下載,而是帶著 `If-None-Match: <etag>` 再問一次;內容沒變伺服器就回 304 Not Modified,不含 body,省下整個檔案的流量。所以 ETag 的價值是「便宜地確認」,不是「避免請求」——請求還是會發出去,只是回應很小。

相關術語: cache invalidation (實作手段)

出處:第 4 段「#3 HTTP 快取與 TTL」

cache invalidation

快取失效

判斷並汰換掉已經不再正確的快取內容的機制。

電腦科學裡有句老話說最難的兩件事是命名與快取失效,難在它是個分散式的一致性問題:同一份資料的副本散在瀏覽器、CDN 邊緣、反向代理、應用層,你無法同時通知全部。前端最常用的迴避手法是根本不做失效——改用內容雜湊當檔名,新版本就是新網址,舊快取自然被繞過。

相關術語: ETag (驗證依據)

出處:第 4 段「#3 HTTP 快取與 TTL」

確定錯誤/已過時 9:09
「set that max age to 3 million milliseconds」
Cache-Control 的 max-age 單位是「秒」,不是毫秒。畫面上的值是 30541039 秒(約 353 天),與同一張截圖裡 Expires 標示的 2026-09-29 相符;若當成毫秒只有約 8.5 小時,前後就矛盾了。數量級也念錯了:30541039 是三千萬而非三百萬。
依據: RFC 9111 §5.2.2.1(max-age 以 delta-seconds 表示)
留給下一段 快取解決了「重複下載」,但第一次下載還是得從遠端伺服器跑一趟——距離本身還沒有被處理。

5. CDN:把快取推到邊緣 9:30–10:36

快取政策通常不用自己設,CDN 會代勞。CDN 把靜態資源推到全球 edge locations,使用者從最近的節點拿檔案;每次重新編譯後的 JS bundle 與 CSS 都會發到 CDN,CDN 實際上取代了原伺服器的角色。除了距離變短,它還自帶合理的快取政策與壓縮,作者認為這是網頁效能投資報酬率最高的第一步。壓縮怎麼談成的,就是下一個概念。

CDN 邊緣節點分布與資源發佈路徑的示意圖,說明「為什麼變快」靠的是地理距離
9:50 · CDN 邊緣節點分布與資源發佈路徑的示意圖,說明「為什麼變快」靠的是地理距離
承上 承上:快取消掉了重複下載,但第一次下載仍要跨越使用者與伺服器之間的物理距離,這一段處理的就是距離。

推理作者的推進方式是「把上一段的機制搬到別的位置」:快取政策其實多半不用自己寫,因為 CDN 會代勞;而 CDN 做的事在概念上就是把快取往使用者的方向推到全球各地的邊緣節點。他特別點出一個角色轉換——每次重新編譯後的 JS bundle 與 CSS 都發到 CDN,於是 CDN 實際上取代了原伺服器面對使用者的角色。他把 CDN 稱為網頁效能投資報酬率最高的第一步,理由是它同時帶來距離、快取政策與壓縮三件事。

AI 補充截圖把「為什麼變快」講得比聲音清楚:橘色的 origin server 只有一個(北美),藍色的 CDN server 散佈在各大洲,每個使用者連的是離自己最近的藍點,橘線只在邊緣節點回源時才走。這裡值得補一個作者省略的量級感:跨洲的來回延遲通常是 150–300 毫秒,而同城是個位數毫秒;一個頁面要串接十幾個請求時,這個差距是決定性的。另外「CDN 取代伺服器」要加個限制條件——它取代的只是靜態資源的分發,動態 API 請求仍會回到你的伺服器(除非你另外用邊緣運算)。順帶一提,上一段 Walmart 截圖裡的 `Server-Timing: cdn-cache;desc=HIT` 正是 CDN 命中的證據,那條標頭現在讀得懂了。

CDN

內容傳遞網路

把靜態資源複製到全球多個節點、讓使用者從最近的節點取得的基礎設施。

CDN 帶來的其實是三件事綁在一起:地理距離變短、預設就有合理的快取政策、以及自動壓縮與協議升級(HTTP/2、HTTP/3)。這是它投報率高的原因——不改一行程式碼就同時改善多個環節。代價是多了一層快取要管理:部署後看到舊版本,第一個要懷疑的就是 CDN 邊緣還沒失效。

相關術語: edge location (組成單位)、Cache-Control (自動套用)

出處:第 5 段「CDN:把快取推到邊緣」

edge location

邊緣節點

CDN 部署在世界各地、實際回應使用者請求的伺服器據點。

邊緣節點沒有你要的檔案時會「回源」向 origin server 取一次再快取起來,所以某地區的第一個使用者仍然比較慢(cold cache),之後才快。近年邊緣節點也開始能跑程式(Cloudflare Workers、Vercel Edge Functions),把部分運算也搬到靠近使用者的地方。

相關術語: origin server (回源對象)

出處:第 5 段「CDN:把快取推到邊緣」

origin server

來源伺服器

資源的權威來源,CDN 節點在沒有快取時回頭去取的那台伺服器。

把 origin 與 edge 分開之後,「部署」這個動作的意義也變了:你更新的是 origin,使用者看到新版的時間點取決於邊緣快取何時失效。這就是為什麼多數 CDN 都提供 purge/invalidate API,讓你在發版後主動清掉邊緣的舊副本。

相關術語: CDN (被其前置)

出處:第 5 段「CDN:把快取推到邊緣」

留給下一段 距離縮短了,但傳輸的位元組數量本身還沒動——CDN 順手做的那個「壓縮」到底是怎麼談成的,就是下一個問題。

6. #4 內容協商與壓縮 10:36–12:39

CDN 自帶的壓縮,機制是 content negotiation:瀏覽器在 accept-encoding 標頭裡告訴伺服器它接受哪些編碼(gzip、br/Brotli,後者效率更高),伺服器挑一個有的版本送出,並在 content-encoding 標明。之所以划算,是因為解壓縮比多下載那些位元組便宜。作者在此給第二個資深建議:前端到資深必須有紮實的資料層基礎——HTTP、TCP、HTTPS、REST、GraphQL、WebSocket,否則會變成致命盲點。

accept-encoding / content-encoding 的請求—回應對照圖,看得到協商到底在哪個標頭上發生
11:05 · accept-encoding / content-encoding 的請求—回應對照圖,看得到協商到底在哪個標頭上發生
承上 承上:CDN 順手做掉的那個壓縮,靠的不是單方面決定,而是客戶端與伺服器之間的一次協商——這一段拆開它。

推理作者把上一段留下的「壓縮從哪來」補完:瀏覽器在 Accept-Encoding 標頭裡列出自己接受的編碼,伺服器挑一個手上有的版本送出,並在 Content-Encoding 標明用了哪個。他接著給出這件事划算的理由,而且是用算的而不是用感覺的——解壓縮的 CPU 成本遠低於多傳那些位元組的網路成本。這一段也是他第二個資深建議的落點:能不能講清楚這種協商,反映的是你對資料層(HTTP/TCP/HTTPS/REST/GraphQL/WebSocket)的掌握程度。

AI 補充截圖把機制講得非常乾淨:左邊 Chrome 送出 `Accept-Encoding: gzip, deflate, br, zstd`,右邊伺服器同時備有 `/style.css`、`/style.css.gzip`、`/style.css.br` 三個版本,挑一個回。注意畫面上瀏覽器實際送的清單裡有 `zstd`,比作者口述的「只有 gzip 和 Brotli」多一個——Zstandard 從 2024 年起已被 Chrome 與 Firefox 支援,壓縮率接近 Brotli 但速度快得多。同一張圖還有一個容易被忽略的細節:`Accept-Language: en-GB,en-US;q=0.9` 說明內容協商不只協商壓縮,也協商語言與格式(`Accept: text/css`),這是同一個機制的三種用途。實務上還有第三個維度作者沒提:伺服器回應時應該附上 `Vary: Accept-Encoding`,否則中間的快取層可能把 Brotli 版本餵給不支援它的客戶端。

content negotiation

內容協商

客戶端用請求標頭表明它能接受哪些格式,伺服器據此挑選最適合的版本回應的機制。

協商的維度不只壓縮:`Accept` 談媒體型別(要 JSON 還是 XML)、`Accept-Language` 談語言、`Accept-Encoding` 談壓縮。標頭裡的 `q=0.9` 是偏好權重,數字越大越想要。伺服器一旦依某個標頭改變回應,就必須在回應加上對應的 `Vary`,否則共享快取會把錯誤的版本發給別人。

相關術語: Brotli (協商的選項)

出處:第 6 段「#4 內容協商與壓縮」

Accept-Encoding

可接受編碼標頭

請求標頭,列出瀏覽器能解開的壓縮格式。

這是「客戶端先講自己能吃什麼」的典型設計,好處是新壓縮演算法可以漸進推出:伺服器只在客戶端明說支援時才用它,舊瀏覽器自動退回 gzip,不需要版本判斷。回應端對應的標頭是 `Content-Encoding`。

相關術語: content negotiation (協商的載體)

出處:第 6 段「#4 內容協商與壓縮」

gzip

gzip 壓縮

最普遍支援的 HTTP 壓縮格式,幾乎所有客戶端與伺服器都能處理。

gzip 基於 DEFLATE,1990 年代就存在,對文字類資源(HTML/CSS/JS)通常能壓到原本的 25–30%。它的價值在於「零風險的預設值」:即使你什麼都不設,只要開啟 gzip 就已經拿到大部分收益。已經是二進位且壓過的檔案(JPEG、PNG、WOFF2)再壓 gzip 沒有意義,反而浪費 CPU。

相關術語: Brotli (被其取代)

出處:第 6 段「#4 內容協商與壓縮」

Brotli

Brotli 壓縮(br)

Google 開發的壓縮格式,對網頁文字資源的壓縮率明顯優於 gzip。

Brotli 的優勢來自內建一份針對 HTML/CSS/JS 常見字串訓練出來的字典,所以小檔案也能壓得好。代價是最高等級的壓縮很慢,實務上靜態資源用高等級離線預壓、動態回應用低等級即時壓。上一段 Walmart 的回應標頭 `Content-Encoding: br` 就是它。

相關術語: gzip (更高壓縮率)

出處:第 6 段「#4 內容協商與壓縮」

Content-Encoding

內容編碼標頭

回應標頭,告訴瀏覽器這份 body 用了哪種壓縮,需要先解開。

它與 `Transfer-Encoding` 容易混淆:Content-Encoding 描述的是資源本身的表示形式(端到端,快取會照樣存壓縮版),Transfer-Encoding 描述的是這一段連線上的傳輸方式(逐跳,例如 chunked)。看 DevTools 時,Size 欄顯示的是壓縮後大小,滑鼠移上去才看得到解壓後的實際大小。

相關術語: Accept-Encoding (請求端對應)

出處:第 6 段「#4 內容協商與壓縮」

見仁見智 11:03
「broadly with broadly being actually the most efficient one」
Brotli 對文字資源的壓縮率確實通常優於 gzip,但說它「最有效率」已經不完整:畫面上瀏覽器送出的 Accept-Encoding 就包含 zstd。Zstandard 自 2024 年起被 Chrome 123+ 與 Firefox 126+ 支援,壓縮率接近 Brotli 而壓縮/解壓速度快數倍,動態內容場景反而更適合。「最有效率」也要看比的是壓縮率還是速度。
依據: RFC 8878(Zstandard);Chrome 123 起支援 Content-Encoding: zstd(2024-03)
留給下一段 位元組變少了,但那些資源畢竟還是被送出去了;下一個更根本的問題是——有些東西根本不需要在一開始就送。

7. #5 延遲載入與動態匯入 12:39–14:46

壓縮只是讓東西變小,更狠的做法是根本先不載。lazy loading 的反面是 eager loading:一次載完四張圖。改成捲到才載,效率立刻不同。JavaScript 同樣可以延遲載入,靠的是 dynamic import:傳統 static import 在 build time 就被 bundler 收進主 bundle,成為擋住渲染的關鍵資源;動態匯入則是在 runtime、使用者按下按鈕之後才去抓那塊 chunk 並執行,初始要跑的 JavaScript 因此變少。

四張圖 eager 全載 vs 捲動才載的對照畫面,一眼看出差別
13:10 · 四張圖 eager 全載 vs 捲動才載的對照畫面,一眼看出差別
static import 與 dynamic import 的程式碼並排,以及 click 事件裡 await import 的寫法
14:05 · static import 與 dynamic import 的程式碼並排,以及 click 事件裡 await import 的寫法
承上 承上:壓縮只能讓送出去的東西變小,而更根本的做法是讓一部分東西一開始根本不送——這就是延遲載入。

推理作者先用對照法定義:延遲載入的反面是 eager loading,一次把四張圖全載完。改成捲到才載,成本立刻不同。接著他把同一個想法推廣到 JavaScript,這一步需要一個機制上的區分——static import 在 build time 就被打包進主 bundle,成為擋住渲染的關鍵資源;dynamic import 則在 runtime 才去抓那塊 chunk。這個「編譯期 vs 執行期」的分界是整段的關鍵,也是後面切分 bundle 的前提。

AI 補充第一張截圖標了「1MB」與「Loaded On User Scroll」:上面兩張圖已渲染,下面兩個位置是 spinner,捲到才載。第二張是最小可行的動態匯入寫法——`button.addEventListener("click", async () => { const {openModal} = await import("./modal"); openModal({step:1}); })`。三個細節值得注意:`import()` 回傳 Promise 所以要 await;解構出的是模組的具名匯出;這行程式碼會讓打包工具自動把 `./modal` 切成獨立 chunk,不需要額外設定。要補充作者沒說的部分:圖片的延遲載入現在有原生解法,`<img loading="lazy">` 不必寫 JavaScript;而延遲載入不是無代價的——把資源延到互動當下才抓,使用者會多等一次網路來回,所以會影響互動回應(見第 3 段的 INP)。實務折衷是在滑鼠移上去或元件即將進入視窗時就先預抓。

術語:module bundler

lazy loading

延遲載入

把資源的載入時機推遲到真正需要的那一刻,而不是初次渲染就全載。

延遲載入的判準是「這個資源對第一屏有沒有貢獻」。首屏的主圖絕對不能 lazy(那會直接拖累 LCP),首屏之下的圖片與只有點擊後才用得到的程式碼則是理想對象。過度使用會讓使用者在每次互動時都感受到一次等待,所以通常要搭配預抓策略。

相關術語: eager loading (相反做法)、dynamic import (JS 的實作)

出處:第 7 段「#5 延遲載入與動態匯入」

eager loading

積極載入

在初次渲染時就把所有資源一次載入的預設做法。

eager 不是錯的做法,只是預設值:資源少的時候它最簡單也最快(沒有額外的網路來回)。它變成問題是在規模上來之後——資源多到初次載入的時間超過使用者的耐心,才需要引入延遲載入的複雜度。

相關術語: lazy loading (相反做法)

出處:第 7 段「#5 延遲載入與動態匯入」

static import

靜態匯入

檔案頂端的 `import x from "y"`,在建置階段就被解析並打包進 bundle。

靜態是它的優點也是它的限制:因為路徑在編譯期就確定,打包工具能做 tree shaking(砍掉沒用到的匯出)與依賴分析;但也因此它一定會進入某個 bundle,無法在執行期決定要不要載。靜態匯入必須寫在模組頂層,不能放在條件式或函式裡。

相關術語: dynamic import (對照組)

出處:第 7 段「#5 延遲載入與動態匯入」

dynamic import

動態匯入

`import()` 函式形式的匯入,在執行期才抓取模組,回傳 Promise。

它是 ES2020 的標準語法,不是打包工具的專利,瀏覽器原生就支援。除了延遲載入,它還能做條件載入(依語言載入不同的語系檔)與錯誤降級(載入失敗時退回簡化版 UI)。在 React 裡它被包裝成 `React.lazy` + `<Suspense>`,本質仍是這一行 `import()`。

相關術語: chunk (產生的單位)

出處:第 7 段「#5 延遲載入與動態匯入」

module bundler

模組打包工具

把散落的原始碼模組解析成依賴圖、輸出可部署檔案的建置工具。

打包工具最初存在的理由很現實:瀏覽器沒辦法把幾百個模組都寫進 HTML 的 head。現在它的職責擴大很多——轉譯語法、tree shaking、切分 chunk、擷取 CSS、產生內容雜湊檔名。Webpack 是老牌代表,Vite(開發期用原生 ESM、產出時用 Rollup)與 esbuild/Turbopack 是速度導向的後繼者,但概念完全相同。

相關術語: static import (解析的對象)

出處:第 7 段「#5 延遲載入與動態匯入」

chunk

程式碼區塊

打包工具切出的一個可獨立載入的 JavaScript 檔案。

每個 `import()` 呼叫點通常會成為一個 chunk 邊界。切得太細會產生大量小請求(在 HTTP/2 之下代價已不高,但仍有解析開銷),切得太粗又失去延遲載入的意義。打包工具通常會自動把多個 chunk 共用的模組再抽成一個共享 chunk,避免重複。

相關術語: dynamic import (由其觸發)

出處:第 7 段「#5 延遲載入與動態匯入」

留給下一段 既然單一模組可以延後,那整個打包好的 bundle 顯然也不該一次全送——問題變成怎麼把它切開。

8. #6 Bundle splitting 14:46–16:40

把延遲載入的想法套到整個 bundle,就是 bundle splitting。Webpack 之類的 bundler 原本把所有模組打成單一 production bundle,好處是不用在 head 塞一堆檔案,壞處是每一頁的使用者都得下載、解析全部 JavaScript。切分後,相依套件進 vendor.js,其餘依路由切成 login / home / dashboard,進 dashboard 的人不必載 login 的程式碼。作者的第三個資深建議在此:用系統、元件、關係去思考,不要背誦——關鍵渲染路徑同時牽連 bundler 與 Core Web Vitals,這種連結才是記得住的原因。

單一 bundle 切成 vendor / login / home / dashboard 的分割圖,是這段唯一的具體結構
15:40 · 單一 bundle 切成 vendor / login / home / dashboard 的分割圖,是這段唯一的具體結構
承上 承上:單一模組可以延後載入,那麼把整個 bundle 依路由切開就是同一個想法在更大尺度上的應用。

推理作者先說明單一 bundle 為什麼曾經是進步:把幾百個模組合成一個檔案,才解決了「不可能把所有檔案都寫進 head」的問題。但同一個決定在規模變大後就變成瓶頸——每一頁的使用者都得下載、解析、執行全部 JavaScript,而這些全都發生在關鍵渲染路徑上。切分的邏輯因此很自然:相依套件變動少,抽成 vendor;其餘依路由切開,只載當前路由要的。這一段也是他第三個資深建議的落點:不要背 15 個名詞,要看見它們的關係——關鍵渲染路徑同時牽連打包工具與 Core Web Vitals,這種連結才是記得住的原因。

AI 補充截圖把結構講得很完整:左邊是一堆互相依賴的 JS 模組,中間是 Webpack 圖示,右邊的 Build 方框裡是 `common.js` 加上 `/login`、`/home`、`/dashboard`、`/settings` 四個路由各自的檔案。這裡有個作者沒展開的關鍵理由,也是把相依套件單獨抽出來的真正動機:vendor 幾乎不變,所以它的長 TTL 快取能跨版本存活;你改自己的程式碼發新版時,使用者只需重新下載那個小小的路由 chunk,vendor 仍然命中快取(見第 4 段)。切分也有反效果要注意:切得太細會產生瀑布式的相依請求,而共用模組若沒被正確抽出會在多個 chunk 裡重複。在 React 的實作上,路由層的切分幾乎就是每個路由元件套一層 `React.lazy` 加 `<Suspense>`。

術語:vendor bundle

bundle splitting

打包切分

把單一產出檔案拆成多個依路由或功能載入的檔案。

常見的切法有三層:依路由切(進哪一頁載哪一頁)、依相依關係切(vendor 與自家程式碼分開)、依互動切(modal、圖表等點了才用的功能)。判斷切得好不好的方法不是看檔案數量,而是看「進入某一頁時實際下載的位元組」有沒有下降。它與 code splitting 講的是同一件事,只是視角一個在產物、一個在原始碼。

相關術語: lazy loading (同一想法放大)、vendor bundle (切出的一塊)

出處:第 8 段「#6 Bundle splitting」

vendor bundle

第三方套件包

把 node_modules 來的相依套件單獨打成一個檔案。

抽出 vendor 的目的是快取穩定性而非體積:第三方套件版本不常變,檔案雜湊就不變,使用者升級你的應用時不必重下載這一大塊。反過來說,如果你每週都升級依賴,vendor 的快取優勢就會消失,這時把它再依套件切成幾塊會更有效。

相關術語: Cache-Control (受益於長 TTL)

出處:第 8 段「#6 Bundle splitting」

留給下一段 JavaScript 已經被切開了,但還有一種資源從第一段起就一直擋在路徑上、而且到現在都沒被處理——CSS。

9. #7 Critical CSS 16:40–19:20

JavaScript 拆完了,CSS 呢?CSS 是設計上就 render blocking:瀏覽器無法預知哪條規則會影響首個元素,只好把 CSS 全部算完才渲染,於是大型應用的 CSS 本身成為瓶頸。Critical CSS 的做法是只挑 above-the-fold(桌機與手機視窗不同)需要的樣式:bundler 外掛用 Puppeteer 之類的無頭瀏覽器在指定視窗預渲染,算出實際用到的選擇器,把這部分內聯進 HTML(不多一次請求、讀起來極快),其餘 CSS 才用 link 從 CDN 載入且不擋渲染。想法簡單,但需要成熟工具與大量測試,因此多見於 Walmart、Amazon 這種大型電商。

桌機與手機各自的 above-the-fold 範圍示意,說明 critical CSS 是隨視窗變動的集合
17:45 · 桌機與手機各自的 above-the-fold 範圍示意,說明 critical CSS 是隨視窗變動的集合
bundler 把 CSS 切成內聯的 critical 部分與延後載入部分的流程圖
18:25 · bundler 把 CSS 切成內聯的 critical 部分與延後載入部分的流程圖
承上 承上:JavaScript 被切開之後,路徑上還剩下 CSS——而它是設計上就阻擋渲染的那一個。

推理作者先解釋為什麼 CSS 沒辦法像 JavaScript 那樣用 async 隨便延後:瀏覽器無法預知哪一條規則會影響第一個元素,只好把 CSS 全部算完才敢畫。這是設計取捨而非缺陷。既然不能不要,那就只送必要的——他把 CSS 依「是否影響首屏」切成 critical 與其餘。難點在於怎麼知道哪些是必要的,他給的答案是自動化:用無頭瀏覽器在指定視窗預渲染一次,收集實際被命中的選擇器。這個推理形狀跟上一段一樣,都是「切開 + 只送必要的」,只是判斷依據從路由換成了視窗。

AI 補充第一張截圖點出一個容易被忽略的前提:above the fold 在桌機與手機上是不同的集合,所以 critical CSS 必須依裝置分別產生,這也是這個技巧比切 JavaScript 麻煩的原因之一。第二張是完整的擷取流程——CSS 進入 module bundler,bundler 呼叫 Puppeteer 做 pre-render,回饋出被命中的規則,抽成獨立的 Critical CSS。之後 critical 的部分內聯進 HTML(省掉一次請求、而且與 HTML 同時抵達),其餘用非阻擋的方式從 CDN 載入。要補三件作者沒說的事:一是內聯的 CSS 不會被快取,所以 critical 必須夠小(一般抓 14KB 以內),太大反而拖慢 HTML 本身;二是「其餘 CSS 不阻擋」的標準寫法是 `<link rel="preload" as="style" onload="this.rel='stylesheet'">`;三是這條路線正在被更根本的方案取代——CSS Modules 與 Tailwind 這類工具讓每個路由的樣式一開始就只有自己那份,等於在源頭避免了問題。作者說它主要用於 Walmart、Amazon 這種大型網站,正確的理由是它需要成熟工具鏈與大量回歸測試,抽錯規則會直接造成首屏跑版。

critical CSS

關鍵 CSS

只包含渲染首屏所需規則的那一小份 CSS,直接內聯在 HTML 裡。

它是「阻擋渲染的資源必須存在」與「阻擋越少越好」之間的折衷:既然一定要有 CSS 才能畫,那就讓必須等待的那份小到可以忽略。判斷成效看的是 FCP 與 LCP 是否下降;如果抽取工具漏抽了某條規則,使用者會看到首屏閃一下再跳位,這時 CLS 反而會變差。

相關術語: render blocking (為解決它)、above the fold (擷取範圍)

出處:第 9 段「#7 Critical CSS」

above the fold

首屏(摺線以上)

使用者不捲動就看得到的那一塊畫面區域。

這個詞來自報紙——對摺後上半版就是攤位上唯一看得見的部分。在網頁上它不是固定尺寸,而是隨裝置與視窗變動,所以任何依賴它的優化都必須為多種視窗各做一份。與它相對的 below the fold 則是延遲載入的理想對象。

相關術語: LCP (多發生在此)

出處:第 9 段「#7 Critical CSS」

headless browser

無頭瀏覽器

沒有可視介面、由程式驅動的完整瀏覽器,常用於自動化渲染與測試。

Puppeteer(驅動 Chromium)與 Playwright 是主流。它「完整」這件事很關鍵:因為它跑的是真的排版引擎,算出來的「哪些 CSS 被用到」才可信,靜態分析程式碼做不到這件事(選擇器可能由執行期的類名組合而成)。同一套工具也被拿來做視覺回歸測試與產生社群分享圖。

相關術語: critical CSS (擷取工具)

出處:第 9 段「#7 Critical CSS」

inlining

內聯

把資源內容直接寫進 HTML,而不是用連結另外請求。

內聯換掉的是「一次網路來回」,代價是這段內容無法單獨被快取,而且每一頁的 HTML 都會重複帶著它。所以內聯只適合小而且每頁都需要的東西:critical CSS、關鍵的小圖示(data URI)。大檔案內聯會讓 HTML 本身變成新的瓶頸。

相關術語: critical CSS (典型用途)

出處:第 9 段「#7 Critical CSS」

留給下一段 到這裡效能面的路徑優化告一段落;但畫面畫得再快,如果背後的資料建模是亂的,重繪本身就會失控——主題該換到狀態了。

10. #8 Essential state 19:34–22:06

從這裡話題從效能轉到資料建模。Essential state 是「渲染某個 UI 所需的最小資料表示」。作者用汽車工程師看車的比喻:外行看外觀,工程師看內部系統;資深前端看 Netflix 會直接看到元件結構與狀態,像有 X 光。以購物車 UI 為例,新手會把商品資訊、原價、折扣價、折扣文字、小計、運費、稅、總計全部放進 state;實際上只需要商品資訊、折扣率、運費、稅率,其餘都能算出來。狀態越少越好,因為狀態會造成重繪、也讓測試變難。

購物車 UI 上被逐項標出的「看起來像 state」的欄位,是這段推理的題目本身
20:50 · 購物車 UI 上被逐項標出的「看起來像 state」的欄位,是這段推理的題目本身
壓縮後只剩四項的 essential state 對照,答案畫面必須看才知道砍掉了什麼
21:25 · 壓縮後只剩四項的 essential state 對照,答案畫面必須看才知道砍掉了什麼
承上 承上:路徑優化到頭了,接下來要處理的是重繪的源頭——畫面背後到底該存哪些資料。

推理作者用一個換位的比喻切入:一般人看車看的是外觀,汽車工程師看到的是內部系統。資深前端看 UI 也一樣,看到的不是版面而是元件結構與狀態。接著他把這個抽象能力變成一道可作答的題目——這個購物車 UI 的 essential state 是什麼?新手會把畫面上每個數字都當成狀態,正確答案是只留無法推導的那幾項,其餘全部算出來。他給的理由不是美感而是後果:狀態會造成重繪、會讓測試變難,所以越少越好。

AI 補充兩張截圖剛好是題目與答案。第一張是原始的購物車:原價 $20.39、劃掉的 $23.99/$59.99、折扣文字、小計 $20.39、運費 $4.99、稅 $1.83、總計 $27.21。第二張把每一項都用紅框標出來,逼你面對「這些看起來都像狀態」的直覺。收斂後只剩四項:商品資訊(含原價)、折扣率、運費、稅率——其餘每一個數字都是這四項的函式。這裡值得把作者的理由講得更精確:多餘狀態真正的危險不是效能,而是它們可以彼此矛盾。如果折扣價與總價各自存一份,任何一次漏更新都會讓畫面顯示一個算術上不可能的組合,而且這種 bug 無法用型別檢查抓到。反過來,凡是能算的就算,畫面就不可能自相矛盾——這是所謂 single source of truth 的實質意義。實務上的取捨是計算成本:真的很貴的推導值可以用 memoization(見第 4 段)快取,但快取的是計算結果,不是又存一份狀態。

術語:derived state

essential state

本質狀態

渲染某個 UI 所需、且無法從其他資料推導出來的最小資料集合。

找它的方法是逐項問「這個值能不能算出來」:能算的就不是狀態。實務上有三類東西常被誤放進狀態——衍生值(總價)、伺服器資料的複本(應交給資料查詢層管理)、以及可以從網址推得的資訊(目前分頁、篩選條件)。把後兩類移出元件狀態,通常能讓元件瘦一大圈。

相關術語: derived state (應被排除)、single source of truth (背後原則)

出處:第 10 段「#8 Essential state」

derived state

衍生狀態

可以由其他狀態計算得出、卻被另外存起來的值。

衍生狀態是資料不一致的主要來源:同一個事實有兩份副本,就有機會對不上。判斷一個值是不是衍生的,問「如果我不存它,我能不能在 render 時算出來」。在 React 裡的具體症狀是:你寫了一個 useEffect,只為了在某個 state 改變時去同步另一個 state——那幾乎總是該直接在渲染時計算。

相關術語: essential state (相反概念)

出處:第 10 段「#8 Essential state」

single source of truth

單一事實來源

每一項事實在系統中只存放一份,其他表現形式都由它推導。

它不只適用於前端狀態,資料庫正規化、設定檔管理、設計系統的 design token 都是同一個原則。代價是讀取端要多做計算或連接,好處是不可能出現互相矛盾的兩份資料。當你發現要寫程式碼「讓 A 和 B 保持同步」時,通常表示違反了這個原則。

相關術語: derived state (禁止其存在)

出處:第 10 段「#8 Essential state」

留給下一段 狀態被壓到最小之後,還有一個問題沒解:這些狀態該怎麼變更,才不會在複雜畫面上失控。

11. #9 Reducer 模式 22:06–24:55

狀態壓到最小之後,還要處理它怎麼變。傳統做法是使用者動作 → 舊 state → 新 state → 重繪,但在 Intuit 會計軟體那種複雜 UI 上,一個全域開關會影響五六個元件,得沿著元件樹逐層更新、把 state 往上提再 prop drilling,既繁瑣又難維護。Reducer 模式把它換成單一 pure function:吃舊 state 與 action、算出新 state,元件各自訂閱新 state。上千次分散修改變成一次可測試、無副作用、確定性的計算。作者強調中階開發者談 Zustand/Redux,資深談的是背後的模式——掌握模式後學任何新函式庫幾乎是瞬間的事。

Intuit 會計軟體的複雜 UI,說明「一個按鈕影響五六個元件」的問題規模
22:25 · Intuit 會計軟體的複雜 UI,說明「一個按鈕影響五六個元件」的問題規模
action → reducer → 新 state → 各元件的流程圖,與前面 prop drilling 的畫面成對照
23:40 · action → reducer → 新 state → 各元件的流程圖,與前面 prop drilling 的畫面成對照
承上 承上:狀態壓到最小之後,剩下的問題是變更本身——在複雜畫面上,一次互動要改動的地方可能散落在整棵元件樹。

推理作者先把最小的循環畫出來:使用者動作 → 舊狀態 → 新狀態 → 重繪。然後把它放進一個壓力測試——Intuit 的會計軟體那種畫面,一個全域開關會同時影響五六個元件。傳統做法是沿著元件樹逐層更新,為此得把狀態往上提、再一路 prop drilling 傳下去,既繁瑣又脆弱。他的解法是把「散在各處的多次修改」換成「集中的一次計算」:所有動作都送進同一個純函式,由它算出新狀態,元件各自訂閱結果。上千次分散的修改變成一次可測試、無副作用、確定性的計算。

AI 補充兩張截圖正好是改造前後。第一張是傳統循環:User Action → Old State → New State → Re-Render,四個節點連成環。第二張多了一個 Reducer 方框把 Old State 罩住,User Action 從外面射進來——差別就在於狀態的變更被關進了一個明確的邊界,外面只能送 action 進去,不能直接改。這裡值得補作者略過的一步:reducer 之所以能保證確定性,關鍵在於它是純函式而且回傳新物件而非就地修改;一旦你在 reducer 裡改了傳進來的 state(或呼叫了 API),時光旅行除錯、樂觀更新回滾、單元測試這些好處會同時失效。他說中階談 Zustand/Redux、資深談模式,這個對比可以再推一步:真正該問的是這個函式庫怎麼處理不可變性、怎麼決定哪些元件要重繪、以及訂閱的粒度有多細——這三個問題的答案決定了它在大型應用裡的表現,而它們都與函式庫名稱無關。也要誠實補一句:reducer 不是免費的,簡單的區域狀態硬套 reducer 只會多寫樣板碼,它的價值在「多個元件受同一次變更影響」時才顯現。

術語:lifting state up

reducer pattern

Reducer 模式

把所有狀態變更集中到一個「(舊狀態, 動作) → 新狀態」的純函式裡。

它源自函數式程式設計的 fold/reduce:把一連串事件摺疊成一個累積結果。這個形狀帶來三個實際好處——變更邏輯集中在一處可讀、每次變更都能單獨測試、以及所有動作被記錄下來後可以重放(這就是 Redux DevTools 時光旅行的原理)。React 內建的 useReducer、Redux、Zustand 的 set 函式,骨架都是同一個。

相關術語: pure function (實作前提)、action (輸入形式)

出處:第 11 段「#9 Reducer 模式」

pure function

純函式

同樣輸入必得同樣輸出、且不產生任何副作用的函式。

純函式的兩個條件常被只記住一半:不只要「確定性」,還要「不改變外界」——不寫全域變數、不改參數、不發請求、不讀時間或亂數。純函式可以被安全地重試、平行執行、快取(見第 4 段的 memoization)與測試,這些性質全都建立在這兩個條件上。React 要求元件的渲染函式也是純的,正是為了讓並行渲染時可以放心中斷與重跑。

相關術語: immutability (常見搭配)、memoization (使其可行)

出處:第 11 段「#9 Reducer 模式」

immutability

不可變性

不修改既有物件,而是產生一個帶有新值的新物件。

不可變性讓「有沒有改變」可以用參考比較(`prev !== next`)一次判定,不必深度比對整棵物件樹——這正是元件框架決定要不要重繪的依據,也是為什麼在 reducer 裡就地修改 state 會導致畫面不更新。代價是每次變更都要複製路徑上的物件,Immer 這類函式庫用 Proxy 讓你寫起來像在直接修改、實際上仍產出新物件。

相關術語: pure function (使其成立)

出處:第 11 段「#9 Reducer 模式」

action

動作

描述「發生了什麼事」的資料物件,是送進 reducer 的輸入。

好的 action 描述事件而不是描述要改哪個欄位:`{type: 'cart/couponApplied', code}` 比 `{type: 'setDiscount', value: 0.15}` 好,因為前者保留了意圖,同一個事件之後要影響更多欄位時不必改動呼叫端。這也讓 action 序列本身成為有價值的紀錄,可以拿來做分析或重放。

相關術語: reducer pattern (驅動其執行)

出處:第 11 段「#9 Reducer 模式」

prop drilling

屬性穿透

為了把資料送到深層元件,被迫讓中間每一層都轉傳同一個 prop。

它的害處不只是打字量:中間層被迫知道它其實不關心的資料,元件因此失去可重用性,重構時牽一髮動全身。解法有三種層次——用 context 提供、用外部 store 訂閱、或用組合(把子元件當 children 傳入)避開層級。第三種常被忽略,卻往往是最簡單的。

相關術語: lifting state up (常見副產物)

出處:第 11 段「#9 Reducer 模式」

lifting state up

狀態上提

把狀態移到需要它的多個元件的共同祖先,讓它們共享同一份資料。

這是 React 官方建議的第一步解法,適合兩三個相鄰元件共享。問題在規模:共同祖先常常離使用者很遠,狀態被提到接近根節點後,任何變更都會讓一大片元件重新渲染,而且中間會產生 prop drilling。reducer 加上訂閱機制就是為了在不上提的情況下達到共享。

相關術語: prop drilling (導致的問題)

出處:第 11 段「#9 Reducer 模式」

留給下一段 變更被收斂成一次計算了,但如果畫面上本來就掛著上千個節點,那一次計算引發的重繪還是會很慢——下一個要管的是渲染量本身。

12. #10 Windowing 與可視區觀察 24:55–27:35

重繪變快之後,還有一個沒解掉的 Core Web Vital:INP,Google 建議在 200 毫秒內,作者認為 100 毫秒才算好。像 Pinterest 那種無限捲動的動態牆,DOM 元素破千後瀏覽器本身就開始吃力,記憶體與 CPU 壓力讓應用即使沒做什麼也變慢。Windowing(list virtualization)只掛載視窗內的元素、其餘卸載,並多留一兩個緩衝以免捲動時來不及。判斷進出視窗用原生的 Intersection Observer,實務上多半直接用 react-virtualized 之類的函式庫。作者提醒 Intersection Observer 是常見面試題。

動態牆上大量 DOM 元素堆積的示意,解釋效能崩壞的來源
25:30 · 動態牆上大量 DOM 元素堆積的示意,解釋效能崩壞的來源
只掛載視窗內元素、上下卸載的 windowing 示意圖與 Intersection Observer 程式碼片段
26:50 · 只掛載視窗內元素、上下卸載的 windowing 示意圖與 Intersection Observer 程式碼片段
承上 承上:變更收斂成一次計算之後,剩下的瓶頸是畫面上掛了太多節點,讓那一次計算引發的重繪依然很慢。

推理作者在這裡回收了第 3 段留下的伏筆:三個 Core Web Vitals 裡,INP 一直還沒有對應的解法,而它多半來自重繪太慢。他給出量化門檻(Google 建議 200 毫秒,他自己要求 100 毫秒),接著指出典型的犯罪現場——Pinterest 那種無限捲動的動態牆。問題的形狀是:畫面上大部分元素其實不可見,卻仍佔著 DOM 節點與記憶體。既然不可見,就不該存在,於是解法就是只掛載視窗內的元素、其餘卸載。判斷進出視窗要可靠,就需要一個原生 API 而非捲動位置的估算。

AI 補充兩張截圖是問題與解法。第一張整片黃色 Item 塞滿畫面,就是「全部都在 DOM 裡」的樣子;第二張只有中間六個是黃色(已掛載),上下都是灰色(已卸載),旁邊三行字把規則講得比口述精確:只渲染螢幕上的元素、隨捲動掛載與卸載、用 Intersection Observer 維持穩定。要補作者略過的關鍵細節:卸載的元素必須留下等高的佔位空間,否則捲軸長度會不斷跳動;如果每列高度不一,還得先估算再量測校正,這正是虛擬清單函式庫真正在解的難題。他提的 react-virtualized 現在通常換成同作者的 react-window 或 TanStack Virtual(更小、支援動態高度)。另外,Intersection Observer 的優勢不只是方便:它在瀏覽器內部非同步計算交集,不會像 `getBoundingClientRect` 那樣強制同步排版,所以捲動時不會掉幀。他提的「上千個 HTML 元素」只是概略感覺,實際門檻取決於每個節點的複雜度與 CSS 成本。

術語:viewport

windowing

視窗化(清單虛擬化)

只把視窗內(加上少量緩衝)的清單項目掛載到 DOM,其餘卸載。

它也叫 list virtualization。核心取捨是「DOM 節點數」換「捲動時的計算量」:節點少了記憶體與重繪成本下降,但每次捲動都要重新計算該顯示哪一段。難點幾乎都在細節——保持捲軸長度正確、處理高度不定的項目、以及讓瀏覽器的頁內搜尋與無障礙工具仍能運作(被卸載的內容搜尋不到,這是真實代價)。

相關術語: INP (為改善它)、Intersection Observer (偵測工具)

出處:第 12 段「#10 Windowing 與可視區觀察」

Intersection Observer

交集觀察器

原生 DOM API,非同步通知你某個元素何時進入或離開視窗。

在它出現以前,判斷元素是否可見得在 scroll 事件裡呼叫 `getBoundingClientRect`,那會強制瀏覽器同步重算排版,捲動時掉幀。Intersection Observer 由瀏覽器在渲染流程內部計算並批次回報,成本低得多。除了虛擬清單,它也是圖片延遲載入、無限捲動觸發、廣告曝光統計與捲動動畫的標準做法。可用 `rootMargin` 提前觸發、用 `threshold` 指定要露出多少比例才算進入。

相關術語: windowing (實作基礎)、lazy loading (另一用途)

出處:第 12 段「#10 Windowing 與可視區觀察」

viewport

視窗(可視區)

瀏覽器中目前實際顯示網頁內容的那塊矩形區域。

視窗是很多概念的共同參照點:above the fold 是首次載入時的視窗範圍、windowing 依它決定掛載哪些節點、Intersection Observer 預設以它為 root。行動裝置上還要區分 layout viewport 與 visual viewport(縮放與虛擬鍵盤會讓兩者不同),這是行動端版面 bug 的常見來源。

相關術語: above the fold (由其界定)

出處:第 12 段「#10 Windowing 與可視區觀察」

見仁見智 25:38
「if you go after the thousand HTML elements even the browser starts to suffer」
沒有「一千個節點」這樣的固定門檻,作者自己也說「不記得確切數字」。實際成本取決於節點的樣式複雜度、是否觸發排版、以及每次更新影響的範圍——幾千個簡單節點可能毫無問題,而幾百個帶陰影與濾鏡的節點就會卡。Lighthouse 的參考值是 DOM 總數超過 1400 才警告、深度超過 32 層才提醒,與影片說的數量級不同。
依據: Lighthouse「Avoid an excessive DOM size」稽核門檻(警告 ~1400 節點)
留給下一段 客戶端的渲染量已經被控制住了,但有一件事從頭到尾沒被質疑過——為什麼第一次渲染非得在客戶端做?

13. #11 伺服器端渲染 27:35–29:42

前面都在優化客戶端渲染,但問題的根源是現代應用送出的 HTML 只有一個空的 root div。FCP 只畫出一個空 div,要等 bundle、React、React DOM 下載執行完才有第一次有意義的繪製,中間就是白畫面(white screen of death),在慢裝置或慢網路上使用者會以為網站壞了。SSR 把這段渲染搬回伺服器:伺服器跑 React、直接向資料庫取資料、預先產出完整 HTML 再送給客戶端。使用者立刻看到內容,但那是靜態的、還不能互動。

空的 root div 與白畫面時間軸,說明 CSR 的痛點發生在哪一格
28:10 · 空的 root div 與白畫面時間軸,說明 CSR 的痛點發生在哪一格
伺服器渲染 + 伺服器端取資料 + 送出完整 HTML 的流程圖,與上一張時間軸直接對照
29:10 · 伺服器渲染 + 伺服器端取資料 + 送出完整 HTML 的流程圖,與上一張時間軸直接對照
承上 承上:客戶端的渲染量已經受控,但「第一次渲染必須在客戶端做」這個前提本身還沒被檢驗過。

推理作者從症狀反推:現代單頁應用送出的 HTML 其實只有一個空的 root div,所以第 2 段講的 FCP 只畫出一個空容器,真正有內容的畫面要等 bundle、React、React DOM 下載並執行完才出現。中間那段空白在慢裝置或慢網路上長到讓使用者以為網站壞了。既然瓶頸是「渲染工作在客戶端做」,最直接的解法就是把它搬回伺服器——伺服器跑框架、直接向資料庫取資料、產出完整 HTML 再送出。他刻意把這說成「回到過去」,因為這確實是伺服器渲染時代的做法,只是現在用同一份元件程式碼。

AI 補充第一張截圖用一個最小的 React 頁面把問題釘死:`<div id="root"></div>` 被標成 First Contentful Paint,下面那段載入 React 並呼叫 `root.render(<App/>)` 的 script 才被標成 First Meaningful Paint——兩個標記之間的距離就是使用者盯著白畫面的時間。第二張是 SSR 的起手式(Client 送出 GET,Server 端有 Initial Render 方框)。要補一個作者沒說清楚的重點:SSR 改善的是「看得到」的時間,不是「能互動」的時間,而且它把成本轉嫁到了伺服器——每個請求都要跑一次渲染,流量大時伺服器成本與回應時間(TTFB)都會上升,所以實務上常搭配快取或改用建置期的靜態產生(SSG)。另外 SSR 的資料抓取發生在伺服器內網,通常比從瀏覽器打 API 快很多,這是它常被低估的第二個好處。

術語:server-side renderingclient-side rendering

server-side rendering

伺服器端渲染(SSR)

在伺服器上執行元件程式碼、產出完整 HTML 後才送給瀏覽器。

SSR 與更早的 PHP/Rails 樣板渲染的差別在於:同一份元件程式碼同時用於伺服器與客戶端,伺服器產出的是「第一幀」,之後由客戶端接手。它同時解決兩件事:使用者更快看到內容,以及搜尋引擎與社群分享的預覽能抓到真正的內容。代價是伺服器負擔、部署複雜度,以及所有元件都必須能在沒有 window/document 的環境中執行。

相關術語: client-side rendering (相反做法)、first meaningful paint (為改善它)

出處:第 13 段「#11 伺服器端渲染」

client-side rendering

客戶端渲染(CSR)

伺服器只送出空殼 HTML,畫面完全由瀏覽器執行 JavaScript 產生。

CSR 的優點是伺服器單純(發靜態檔就好)、頁面切換不需重新載入、互動體驗連貫。它的代價集中在第一次載入:白畫面、對慢裝置不友善、以及對不執行 JavaScript 的爬蟲不可見。多數框架現在的策略是首屏用 SSR、之後的導覽用 CSR,兩者混用而非二選一。

相關術語: server-side rendering (相反做法)

出處:第 13 段「#11 伺服器端渲染」

first meaningful paint

首次有意義繪製

畫面上出現使用者真正在等的內容、而不只是空容器或載入動畫的時刻。

它與 FCP 的差距正是單頁應用的典型病灶:FCP 可能在 0.5 秒就達成(那個空 div 或 spinner),FMP 卻要等到 3 秒。這個指標因為難以自動判定,後來在標準裡被 LCP 取代,但作為思考工具仍然好用——問「使用者到底什麼時候看到他要的東西」,比問「什麼時候畫了第一個像素」更貼近體驗。

相關術語: first contentful paint (更嚴格版本)

出處:第 13 段「#11 伺服器端渲染」

white screen of death

白畫面

頁面已回應但還沒渲染出內容,使用者只看到一片空白的期間。

它特別傷的地方在於使用者無法分辨「還在載」與「壞掉了」,於是關掉分頁。最低成本的緩解不是優化速度,而是給出回饋——伺服器直接吐出骨架畫面(skeleton),讓使用者知道東西正在來。這也是為什麼許多應用即使沒導入 SSR,也會先做一個內聯的載入畫面。

相關術語: client-side rendering (常見副作用)

出處:第 13 段「#11 伺服器端渲染」

留給下一段 使用者現在立刻看得到內容了,但那份 HTML 是死的——點下去沒有反應,這個缺口必須補上。

14. #12 Rehydration 29:42–30:28

SSR 送來的 HTML 沒有互動性,使用者亂點會變成 rage clicking,補上互動的機制就是 rehydration:仍然下載並解析 bundle,在客戶端建立虛擬 DOM 後執行 hydrate,把事件與狀態接回既有的 markup,網站才真的能點。常見的坑是 hydration error,成因不只一種。

server HTML + client bundle 合流成可互動頁面的 hydrate 示意圖
30:00 · server HTML + client bundle 合流成可互動頁面的 hydrate 示意圖
承上 承上:伺服器送來的 HTML 讓使用者立刻看得到,卻點不動——這一段補的就是互動性。

推理作者用使用者行為描述缺口:畫面看起來好了,點下去沒反應,使用者開始暴躁地連點(rage clicking)。要補上互動,客戶端還是得下載並解析 bundle,在記憶體裡建出對應的虛擬結構,然後執行 hydrate——把事件監聽與狀態接回伺服器已經送來的那份 markup,而不是重新畫一次。關鍵在於「接回」而非「重畫」:markup 已經在了,客戶端只是把它認領下來。他也順帶點出這個機制最常見的故障:hydration error。

AI 補充截圖把整條鏈完整畫出來:伺服器端的 Initial Render → Data Fetching → HTML(旁邊接著 Data Layer API),送到 Client 做 Initial Render 與 Display(右邊出現 Walmart 的靜態頁面),然後往下才是 Rehydration。這張圖說明了一件口述沒強調的事——使用者看到畫面與能互動之間,隔著一整段 bundle 下載與執行的時間,SSR 只縮短了前半段。這也解釋了 hydration error 的成因:伺服器產出的 HTML 與客戶端第一次渲染的結果必須完全一致,任何隨機值、`Date.now()`、`window` 判斷或只在客戶端才有的語系資料,都會讓兩邊對不上,React 就會警告並丟掉伺服器那份重畫,反而更慢。實務上的判斷法是把這類差異延後到 hydrate 之後再處理(例如放進 effect),而不是在渲染時直接讀。補一個版本細節:作者說的 hydrate 指令在 React 18 之後是 `hydrateRoot`,舊的 `ReactDOM.hydrate` 已被淘汰。這條鏈的長度也正是後面兩段要繼續縮短的目標。

rehydration

再水合

客戶端在既有的伺服器端 HTML 上接回事件與狀態,讓靜態畫面變成可互動。

名字來自「乾燥的 HTML 加水復活」:伺服器送來的是脫水版本(有結構沒行為),客戶端把行為加回去。它的成本與元件數量成正比,所以大型頁面即使 SSR 很快,仍可能有一段長長的「看得到但點不動」期間。減少這段時間的方向有兩條——延後不重要區塊的 hydration,或根本不對它們做 hydration。

相關術語: server-side rendering (必要的後續)、hydration error (常見故障)

出處:第 14 段「#12 Rehydration」

hydration error

水合錯誤

伺服器產出的 HTML 與客戶端首次渲染結果不一致時發生的錯誤。

常見成因幾乎都是「兩邊看到的世界不同」:時間與亂數、只有瀏覽器才有的 `window`/`localStorage`、依使用者時區或語系格式化的字串、以及會插入節點的瀏覽器擴充功能。React 18 之後多數情況會警告並在客戶端重畫該子樹(效能損失),巢狀標籤不合法之類的結構性錯誤則可能直接崩潰。除錯時先問「這個值在伺服器上算得出來嗎」。

相關術語: rehydration (其失敗形式)

出處:第 14 段「#12 Rehydration」

virtual DOM

虛擬 DOM

框架在記憶體中維護的一份輕量元件樹,用來比對差異後再更新真實 DOM。

虛擬 DOM 的價值不是「比較快」,而是讓你能用宣告式的方式寫 UI,把「怎麼從舊畫面變到新畫面」交給框架算。hydrate 時它扮演的角色是對照表:客戶端建出虛擬樹,逐一對應到伺服器送來的真實節點並掛上事件。Svelte、Solid 這類編譯期框架則完全不用虛擬 DOM,直接產生精準的更新程式碼。

相關術語: reconciliation (在其上運作)

出處:第 14 段「#12 Rehydration」

rage clicking

暴躁點擊

使用者因為介面沒有回應而在短時間內反覆點擊同一處的行為。

它是使用者體驗分析工具(Hotjar、FullStory 等)會自動標記的訊號,因為它幾乎總是對應到真實故障:元素還沒 hydrate、請求卡住卻沒有載入指示、或按鈕看起來可點其實被覆蓋。它的價值在於不需要使用者回報就能定位問題頁面。

相關術語: rehydration (延遲的症狀)

出處:第 14 段「#12 Rehydration」

見仁見智 29:55
「run the hydrate command on the client」
React 18(2022 年 3 月)起改用 `hydrateRoot(container, <App/>)`,舊的 `ReactDOM.hydrate` 已標記為棄用,在 React 19 被移除。概念完全相同,但照著舊 API 名稱去查文件或寫程式會踩空。
依據: React 18 release notes;React 19 移除 ReactDOM.hydrate
留給下一段 互動性補回來了,但這個補法是整頁一起補;問題是同一頁裡其實有些區塊根本不需要互動。

15. #13 Partial pre-rendering 30:28–31:42

SSR 全頁預渲染、rehydration 全頁補互動,兩端之間還有折衷:partial pre-rendering 讓同一頁同時採用不同渲染策略——盡量靜態預渲染,只把真正動態的區塊留給客戶端渲染與 hydration,由框架依互動需求決定。目標仍是優化關鍵渲染路徑:盡快出現、盡快可互動。代價是複雜度很高,通常交給 Next.js 或 Nuxt 處理,適合對效能極敏感的電商。

同一頁面上靜態區塊與動態「洞」的分區示意,這是 PPR 與 SSR 的唯一視覺差別
30:55 · 同一頁面上靜態區塊與動態「洞」的分區示意,這是 PPR 與 SSR 的唯一視覺差別
承上 承上:整頁一起 hydrate 太粗糙,因為同一頁裡有些區塊根本不需要互動、有些內容也不需要每次重算。

推理作者的推進方式是把粒度變細:既然 SSR 是整頁預渲染、rehydration 是整頁補互動,那自然的下一步就是讓同一頁同時採用不同策略——能靜態預渲染的盡量靜態,真正動態的部分才留給客戶端。決定權交給框架,依你標示的互動需求自動分區。他強調目標仍然沒變,還是那條關鍵渲染路徑:盡快出現、盡快可互動。同時他誠實地標出代價——這是本片最複雜的渲染策略,通常得靠 Next.js 或 Nuxt 這種框架才管得動。

AI 補充截圖把分區講得比口述清楚:一個 acme.com 的商品頁上,Navbar 標著 S(Static or Revalidated),Cart 與 Recommended 標著 D(Dynamic),Product Information 也是靜態的。這張圖點出一個口述沒說的關鍵——動態的通常是「跟這個使用者有關」的部分(購物車、個人化推薦),靜態的是「對所有人都一樣」的部分。這正是分界的實務判準。要補一個機制細節:真正的 partial pre-rendering 不是回退成兩次請求,而是伺服器先立刻送出含有靜態外殼與佔位的回應,再用串流把動態區塊的內容補進同一個連線裡,所以使用者看到的是骨架先出現、內容逐塊填入。這需要串流式 SSR 與 Suspense 邊界的支援,也是它「複雜」的真正來源。另外,S 標籤上寫的是「Static or Revalidated」,意味著靜態內容也可以有 TTL、在背景重新產生——這其實就是第 4 段的快取模式被套用到渲染結果上。

術語:static rendering

partial pre-rendering

部分預渲染(PPR)

在同一個頁面裡對不同區塊套用不同渲染策略:靜態的先送,動態的串流補上。

它把「這一頁是靜態還是動態」這個二選一,換成「這一頁的每一塊分別是什麼」。實作上伺服器先回傳含佔位的靜態外殼,動態區塊在同一個回應中以串流方式陸續送達,所以只需要一次請求。代價是心智負擔與除錯難度:你必須清楚每個元件的資料來源是否依賴請求本身(cookie、header、搜尋參數),一旦依賴就會被判定為動態。

相關術語: server-side rendering (更細的粒度)、static rendering (組成之一)

出處:第 15 段「#13 Partial pre-rendering」

static rendering

靜態渲染

在建置期或首次請求後產生 HTML 並快取,之後所有使用者共用同一份。

靜態渲染是 SSR 的極端省力版本:渲染只做一次,之後就是發靜態檔,可以直接放在 CDN 邊緣。它的適用前提是「內容對所有人相同」。加上重新驗證(revalidation)機制後,它還能有 TTL——過期後在背景重新產生一份,使用者永遠拿到快取版本,這就是 Next.js 的 ISR。

相關術語: TTL (沿用其機制)

出處:第 15 段「#13 Partial pre-rendering」

Next.js

Next.js 框架

建構在 React 之上的全端框架,內建路由、SSR、靜態產生與伺服器端資料抓取。

它的價值在於把本片後半這些渲染策略變成預設值或一行設定,而不是自己拼裝。理解它的關鍵是分清 App Router(以 React Server Components 為核心)與舊的 Pages Router,兩者的資料抓取與快取模型差異很大。Vue 生態的對應物是 Nuxt,Svelte 是 SvelteKit。

相關術語: partial pre-rendering (其實作平台)

出處:第 15 段「#13 Partial pre-rendering」

留給下一段 分區之後有個更激進的問題浮出來:那些完全不需要互動的區塊,是不是連 JavaScript 都不必送?

16. #14 伺服器元件 31:42–33:00

既然可以分區,那不需要互動的元件連 JavaScript 都不必送。Server components 檢查元件樹裡誰在監聽事件、誰會改變狀態;不需要互動的就只送 markup、零 JavaScript、不做 hydration,bundle 因此更小。這是框架層的機制:Next.js 預設全部在伺服器渲染,除非標成 client component,那部分的 JavaScript 才會被抽出送到客戶端並 hydrate。效果是 SSR 讓初次渲染快,server components 讓 rehydration 也快。

元件樹上區分互動/非互動節點的標記圖,是判斷哪些元件變成 server component 的依據
32:10 · 元件樹上區分互動/非互動節點的標記圖,是判斷哪些元件變成 server component 的依據
承上 承上:既然頁面能依區塊分策略,那不需要互動的那些區塊自然該被追問——它們是不是連 JavaScript 都不用送?

推理作者把判準說得很具體:檢查元件樹裡每個節點,它有沒有在監聽使用者事件、需不需要對事件反應、會不會改變狀態。答案全是否的,就只送 markup、零 JavaScript、也不做 hydration,bundle 因此更小。他接著指出這是框架層的預設值反轉——Next.js 預設全部在伺服器渲染,除非你明確標成客戶端元件。最後他把兩件事對起來作為收束:SSR 讓初次渲染快,server components 讓 rehydration 也快,一前一後把第 13、14 段的兩個瓶頸都補上了。

AI 補充截圖是「Client Component Tree」:A、B、D、E、F 是灰色,C、G、K、J 是黃色並用黃線相連。這張圖說明了一條口述略過但非常重要的規則——互動性會沿著樹往下傳染:C 一旦被標成客戶端元件,它底下的 G、K、J 就全部進入客戶端範圍。這是實務上決定標記位置的關鍵:`"use client"` 要盡量往葉節點放,標在根附近等於整棵樹都變成客戶端元件,好處歸零。另一個作者沒提的重點是伺服器元件真正的第二個好處:它們在伺服器上執行,可以直接讀資料庫或用只存在於伺服器的密鑰,資料抓取不必再繞一趟 API,而且這些程式碼與相依套件永遠不會出現在客戶端 bundle 裡(一個只在伺服器元件用到的 Markdown 解析套件,客戶端一個位元組都不會下載)。限制則是伺服器元件不能用 state、effect 或事件處理器,也不能被序列化成 props 傳給客戶端元件的內容必須是可序列化的資料。

server components

伺服器元件

只在伺服器上執行、只送出渲染結果而不送 JavaScript 到客戶端的元件。

它與 SSR 的差別常被混淆:SSR 是把元件在伺服器上先跑一次產出 HTML,那份元件的 JavaScript 之後仍會送到客戶端做 hydration;server component 的程式碼則根本不會離開伺服器。所以它省的不是首屏時間,而是 bundle 體積與 hydration 成本。代價是它不能有任何互動能力,也不能使用 state 或瀏覽器 API。

相關術語: client component (互補角色)、rehydration (為減少它)

出處:第 16 段「#14 伺服器元件」

client component

客戶端元件

被明確標記為需要在瀏覽器執行的元件,其 JavaScript 會被送到客戶端並 hydrate。

在 Next.js App Router 裡用檔案頂端的 `"use client"` 標記。要記住的規則是它會往下傳染:被標記元件的整棵子樹都算客戶端。因此常見的優化手法是把互動部分抽成盡可能小的葉節點元件,讓其餘部分留在伺服器;也可以把伺服器元件當成 children 傳進客戶端元件,藉此穿過傳染邊界。

相關術語: server components (互補角色)

出處:第 16 段「#14 伺服器元件」

zero JavaScript

零 JavaScript

某個元件最終只貢獻 HTML 與 CSS,不增加任何客戶端 bundle 體積。

這是伺服器元件的賣點,也是一個有用的檢查標準:問「這塊 UI 需要多少 JavaScript 才能運作」,答案是零的部分就不該讓它進 bundle。同樣的思路在其他框架裡叫 islands architecture(Astro、Qwik)——預設全部是靜態 HTML,只有明確標記的「島」才載入 JavaScript。

相關術語: bundle splitting (更徹底版本)

出處:第 16 段「#14 伺服器元件」

留給下一段 到這裡單一應用內部能拆的都拆完了;最後一個尺度的問題是——當一個前端大到一個團隊放不下時該怎麼辦。

17. #15 微前端與收尾 33:00–35:42

最後一個概念把尺度從一頁拉到整個組織。微前端把 UI 拆成多個獨立應用:以 Walmart 為例,頂部搜尋列、分類、Deals、精選商品各自是一支應用,可以用不同框架,由 shell 統一注入全域狀態(位置、語言、深色模式、主題、認證)並維持視覺一致。每個微前端是公司裡的小公司,有自己的 PM、BFF、微服務,能獨立部署。代價是執行期載入讓速度稍慢,換來的是開發速度與團隊可擴展性。結尾作者建議接著看前端架構模式與前端轉後端的主題。

shell 注入全域狀態、各微前端接 BFF 與微服務的架構圖
34:10 · shell 注入全域狀態、各微前端接 BFF 與微服務的架構圖
承上 承上:單一應用內部能拆的都拆完了,最後要面對的是尺度問題——當一個前端大到一個團隊放不下時怎麼辦。

推理作者把拆分的邏輯從技術層拉到組織層:以 Walmart 首頁為例,頂部搜尋列、分類、Deals、精選商品各自可以是獨立應用,甚至可以用不同框架,由一個 shell 統一注入全域狀態(位置、語言、深色模式、主題、認證)並維持視覺一致。他點出這個架構真正在優化的東西不是效能——執行期載入反而讓速度稍慢——而是開發速度與團隊可擴展性:每個微前端像公司裡的小公司,有自己的 PM、BFF 與微服務,能獨立部署。最後他把整支影片收在一句話上:知道這些概念還不夠,重點是看見它們彼此的關係。

AI 補充截圖標題就叫「The Shell」:Walmart 首頁被彩色框線切成幾塊——黃框的搜尋列、綠框的分類列、紅框的商品區、藍框的推薦區,外圍那圈灰底就是 shell 本身。畫面把 shell 的角色講得很清楚:它是容器,不是內容。要補作者略過的三個實務重點。第一,微前端的代價比他說的「稍慢」更具體:每個微前端各自打包意味著 React 之類的共用相依可能被下載多次,所以實務上一定要做相依共享(Module Federation 的核心功能就是這個)。第二,「可以用不同框架」在技術上成立,在實務上幾乎總是錯的選擇——那會讓共用元件、狀態同步與人員流動全部變貴;這個能力真正的價值是漸進遷移,而不是長期並存。第三,判斷該不該用微前端的標準是組織而非技術:如果你只有一個團隊,微前端帶來的協調成本會遠大於收益,這是它最常被誤用的地方。最後回到作者的收尾建議——這 15 個概念的排列本身就是一條推理鏈:量測指向瓶頸,瓶頸指向優化,優化到極限後改變架構,架構改變後再改變組織。

術語:frontend monolith

micro frontends

微前端

把一個前端應用拆成多個可獨立開發與部署的子應用,由容器組合起來。

它是微服務思路在前端的對應物,解決的也是同一類問題:團隊規模大到協調成本超過整合成本時,把邊界切開讓各團隊自主。切分的正確依據是業務領域(搜尋、結帳、推薦)而不是技術層或畫面位置。主要成本有三個:共用相依的重複載入、跨應用的狀態與樣式一致性、以及整合測試變難。

相關術語: shell (必要的容器)、frontend monolith (被拆解對象)

出處:第 17 段「#15 微前端與收尾」

shell

外殼(容器應用)

負責載入、排列各微前端,並提供共用狀態與樣式的容器應用。

shell 該管的是所有微前端都需要、而且必須一致的東西:認證、語言與地區、主題、全域導覽、以及跨應用的路由。它該盡量薄——一旦 shell 開始承載業務邏輯,它就變成新的瓶頸,每個團隊發版都要動到它,微前端的自主性就消失了。

相關術語: micro frontends (組合其成員)

出處:第 17 段「#15 微前端與收尾」

BFF

前端專用後端

為單一前端量身打造的後端層,把多個微服務的資料聚合成該前端需要的形狀。

BFF 存在的理由是通用 API 與特定畫面之間的落差:一個畫面需要三個微服務的資料,若讓瀏覽器分別打三次請求,網路來回與錯誤處理都變複雜。BFF 在伺服器端聚合好再回一份。它的擁有者應該是前端團隊而不是後端團隊,這是它與一般 API gateway 最大的差別。行動版與網頁版通常各有一個 BFF。

相關術語: micro frontends (各自配備)

出處:第 17 段「#15 微前端與收尾」

frontend monolith

前端單體

所有功能都在同一個程式庫、同一次建置、同一次部署的前端應用。

單體不是貶義詞,它在多數情況下是正確的預設:共用程式碼容易、重構安全、只有一條部署流程。它變成問題的訊號很具體——建置時間長到影響開發節奏、不同團隊的變更互相阻塞、一次發版要協調多個團隊。在這些訊號出現之前就拆,通常會付出比省下的更多的代價。

相關術語: micro frontends (其對照組)

出處:第 17 段「#15 微前端與收尾」

留給下一段 總結收束。

4. 總結

作者從一個觀察出發:300 多位資深前端工程師的共同點不是框架,而是同一組心智模型。於是他先立地基——critical rendering path 說明瀏覽器如何從 HTML 走到像素,並指出 head 裡的資源會阻擋渲染。有了路徑才能量它,Core Web Vitals 的 LCP/INP/CLS 分別掛在路徑的不同位置上。接著沿著路徑逐段開刀:HTTP 快取消掉重複下載、CDN 縮短物理距離、內容協商壓小位元組、lazy loading 讓東西一開始就不送、bundle splitting 把 JavaScript 依路由切開、critical CSS 只內聯首屏需要的樣式。效能面到此為止,主題轉向資料建模:essential state 要求只留無法推導的最小狀態,reducer pattern 把散落的變更收斂成一次純函式計算,windowing 則把 DOM 節點數壓下來,補上前面留著沒解的 INP。再往下是質疑渲染的位置:SSR 把首次渲染搬回伺服器解決白畫面,rehydration 補回互動性,partial pre-rendering 讓同一頁分區採用不同策略,server components 更進一步對非互動區塊連 JavaScript 都不送。最後 micro frontends 把切分的尺度從一頁拉到整個組織——用執行期的一點速度損失,換團隊的獨立部署與開發速度。整條鏈的形狀是:量測指向瓶頸 → 瓶頸指向優化 → 優化到極限就改變架構 → 架構改變後再改變組織。

勘誤總整理

確定錯誤/已過時見仁見智(取決於版本或情境)

段落原話(transcript 逐字)說明
2. #1 關鍵渲染路徑
1:14
「with the JavaScript that it will find in the hat section of the HTML document it will build the DOM」DOM 是瀏覽器解析 HTML 標記本身建出來的,不是用 head 裡的 JavaScript 建的。head 裡的同步 script 反而會暫停 HTML 解析、延後 DOM 建構。作者這裡把 CSS→CSSOM 的對應關係誤套到 JS→DOM 上(同一張投影片的箭頭其實也是 HTML→DOM)。
依據: HTML Living Standard, 8.1 Parsing HTML documents;MDN「Critical rendering path」
4. #3 HTTP 快取與 TTL
9:09
「set that max age to 3 million milliseconds」Cache-Control 的 max-age 單位是「秒」,不是毫秒。畫面上的值是 30541039 秒(約 353 天),與同一張截圖裡 Expires 標示的 2026-09-29 相符;若當成毫秒只有約 8.5 小時,前後就矛盾了。數量級也念錯了:30541039 是三千萬而非三百萬。
依據: RFC 9111 §5.2.2.1(max-age 以 delta-seconds 表示)
3. #2 Core Web Vitals
3:14
「the LCP and the CLS are basically measured during the initial render」LCP 確實只看初次載入到使用者第一次互動為止,但 CLS 是整個頁面生命週期持續累積的(以 session window 計算),捲動過程中晚載入的元素造成的位移一樣算分。把 CLS 說成只在初次渲染量測會讓人漏掉最常見的扣分來源。
依據: web.dev「Cumulative Layout Shift (CLS)」;CLS 自 2021 年 6 月改為 session window 演算法
6. #4 內容協商與壓縮
11:03
「broadly with broadly being actually the most efficient one」Brotli 對文字資源的壓縮率確實通常優於 gzip,但說它「最有效率」已經不完整:畫面上瀏覽器送出的 Accept-Encoding 就包含 zstd。Zstandard 自 2024 年起被 Chrome 123+ 與 Firefox 126+ 支援,壓縮率接近 Brotli 而壓縮/解壓速度快數倍,動態內容場景反而更適合。「最有效率」也要看比的是壓縮率還是速度。
依據: RFC 8878(Zstandard);Chrome 123 起支援 Content-Encoding: zstd(2024-03)
12. #10 Windowing 與可視區觀察
25:38
「if you go after the thousand HTML elements even the browser starts to suffer」沒有「一千個節點」這樣的固定門檻,作者自己也說「不記得確切數字」。實際成本取決於節點的樣式複雜度、是否觸發排版、以及每次更新影響的範圍——幾千個簡單節點可能毫無問題,而幾百個帶陰影與濾鏡的節點就會卡。Lighthouse 的參考值是 DOM 總數超過 1400 才警告、深度超過 32 層才提醒,與影片說的數量級不同。
依據: Lighthouse「Avoid an excessive DOM size」稽核門檻(警告 ~1400 節點)
14. #12 Rehydration
29:55
「run the hydrate command on the client」React 18(2022 年 3 月)起改用 `hydrateRoot(container, <App/>)`,舊的 `ReactDOM.hydrate` 已標記為棄用,在 React 19 被移除。概念完全相同,但照著舊 API 名稱去查文件或寫程式會踩空。
依據: React 18 release notes;React 19 移除 ReactDOM.hydrate

5. 推薦三個下一步

1. 往下挖深:伺服器端渲染與 hydration 的實作細節

影片把 SSR、rehydration、PPR、server components 四段講成一條線,但每一段的實作坑(hydration error 的成因、串流 SSR 的 Suspense 邊界、`"use client"` 的傳染規則)都只點到為止,作者自己也兩度說「想看的話留言」。

YouTube 搜尋:React server components explained hydration error Next.js debug streaming SSR Suspense partial prerendering Next.js

2. 往旁邊對照:其他前端架構模式與微前端的替代方案

微前端在影片裡是唯一的組織級解法,但它有明確的適用前提(多團隊)與代價(相依重複、整合測試)。看過 monorepo、module federation、islands architecture 這些對照組,才知道什麼時候不該用微前端。

YouTube 搜尋:frontend architecture patterns micro frontends module federation monorepo vs micro frontends islands architecture Astro Qwik

3. 往上應用:前端系統設計面試

這 15 個模型的實際考場是系統設計面試——面試官要的正是「先畫出渲染路徑再說要動哪一段」這種答法。把概念換成一場 45 分鐘的設計題,才知道自己是真的懂還是只記得名詞。

YouTube 搜尋:frontend system design interview design news feed frontend web performance interview questions senior frontend interview mock

📄 全部影片 · 主題區: 前端底層與系統設計