Hydration 與 INP:水合造成的互動延遲與部分水合
伺服器端渲染與靜態產生 讓瀏覽器拿到現成的 HTML,內容立刻看得到。但頁面要能 互動,還得執行一次 水合(Hydration)。
那段「看得到卻按不動」的空窗,就是 INP 變差的常見原因。
水合實際在做什麼
它不是「把 HTML 變成 DOM」
DOM 已經有了,瀏覽器解析 HTML 就建好了。水合做的是把那些 靜態的 DOM 節點接回應用程式:
| 步驟 | 做什麼 |
|---|---|
| 1 | 下載並解析框架的執行時期與應用程式程式碼 |
| 2 | 重新建立整棵元件樹(在記憶體裡) |
| 3 | 比對元件樹與既有的 DOM 節點 |
| 4 | 把事件監聽掛到對應的節點上 |
| 5 | 還原回應式狀態 |
第 2 到 5 步是 在主執行緒上跑的一個大型任務。
為什麼影響 INP
水合是一個長任務
主執行緒同時負責執行 JavaScript 與更新畫面。水合執行期間:
- 畫面 無法更新
- 事件 無法處理
使用者這時候點按鈕,事件要排隊等水合跑完。頁面內容愈多、元件愈複雜,這段延遲愈長,大型頁面可能是數百毫秒的長任務。
這就是所謂的「未接管的島嶼」問題:畫面上一切正常,但什麼都按不動。
怎麼量測水合成本
// 在應用程式掛載前後量測
performance.mark('hydration-start');
app.mount('#app');
performance.mark('hydration-end');
performance.measure('hydration', 'hydration-start', 'hydration-end');
const [measure] = performance.getEntriesByName('hydration');
console.log('水合耗時:', measure.duration, '毫秒');用 DevTools 的 Performance 面板更完整
- 開 CPU 節流(4x 或 6x),本機太快看不出問題。
- 錄下頁面載入。
- 找時間軸上首次繪製之後那個最長的任務,通常就是水合。
- 展開它看時間花在哪些函式上。
不節流的話水合可能只有 30 毫秒,在中階手機上卻是 300 毫秒。量測方式見 指標量測與 CrUX 數據 。
水合不一致
伺服器產生的 HTML 與用戶端第一次渲染的結果 不同 時,框架會發出警告:
Hydration completed but contains mismatches.五個常見成因
| 成因 | 為什麼會不一致 |
|---|---|
| 渲染時用當前時間 | 伺服器與瀏覽器的時間點不同 |
| 渲染時用隨機數 | 每次算出來都不同 |
讀取 window 或 document | 伺服器端沒有這些物件 |
| 依賴視窗寬度 | 伺服器不知道使用者的螢幕 |
讀取 localStorage | 伺服器端沒有 |
<script setup>
// 會不一致:伺服器與瀏覽器算出不同的值
const now = new Date().toLocaleString();
const id = Math.random();
const isMobile = window.innerWidth < 600;
</script>正確的處理
把這些邏輯搬到 掛載之後:
<script setup>
import { ref, onMounted } from "vue";
// 先給一個伺服器與瀏覽器都算得出來的初始值
const now = ref("");
const isMobile = ref(false);
onMounted(() => {
// 掛載後才是純瀏覽器環境
now.value = new Date().toLocaleString();
isMobile.value = window.innerWidth < 600;
});
</script>
<template>
<!-- 初始渲染是空字串,兩邊一致 -->
<p v-if="now">現在時間:{{ now }}</p>
</template>響應式版面優先用 CSS
「依裝置寬度顯示不同內容」用 媒體查詢 處理,不要用 JavaScript 判斷,CSS 在伺服器端渲染的 HTML 上就生效了,沒有不一致的問題,也不必等水合。
.mobile-only { display: none; }
@media (max-width: 600px) {
.mobile-only { display: block; }
.desktop-only { display: none; }
}只在用戶端渲染的區塊
真的無法避免的情況(例如:依賴瀏覽器 API 的圖表),把那一塊排除在水合之外:
<template>
<!-- Nuxt:這個元件只在瀏覽器端渲染 -->
<ClientOnly>
<RealtimeChart />
<template #fallback>
<!-- 伺服器端與水合前顯示這個,記得預留高度避免版面偏移 -->
<div class="chart-placeholder" />
</template>
</ClientOnly>
</template>
<style>
.chart-placeholder {
aspect-ratio: 16 / 9;
}
</style>一定要給 fallback 預留尺寸
沒有預留高度的話,圖表載入時會把下方內容往下推,那是一次 CLS 。
部分水合
不必一次水合整頁。三種常見策略:
| 策略 | 做法 | 適合 |
|---|---|---|
| 延後水合 | 元件進入可視範圍才水合 | 頁面下方的區塊 |
| 互動時水合 | 使用者點了才水合 | 收合區塊、對話框 |
| 閒置時水合 | 主執行緒空閒時水合 | 次要的互動元件 |
Nuxt 的延後水合設定:
<template>
<!-- 進入可視範圍才水合 -->
<LazyHeavyChart hydrate-on-visible />
<!-- 使用者互動才水合 -->
<LazyCommentForm hydrate-on-interaction />
<!-- 主執行緒閒置時才水合 -->
<LazyRelatedPosts hydrate-on-idle />
</template>先處理首屏之外的元件
判斷順序:
- 首屏可見且需要立刻互動(主導覽、搜尋框)→ 正常水合。
- 首屏可見但不急(輪播、統計數字)→ 閒置時水合。
- 首屏之外(留言區、相關文章、頁尾)→ 進入可視範圍才水合。
第 3 類通常佔了頁面的大部分,延後它們的收益最大。
島嶼架構
這是更徹底的做法
| 部分水合 | 島嶼架構 | |
|---|---|---|
| 預設行為 | 整頁水合,挑一部分延後 | 完全不水合 |
| 靜態內容 | 仍在元件樹裡 | 完全不進元件樹 |
| 框架執行時期 | 一定要下載 | 靜態頁面可以不下載 |
| 互動元件 | 全部或延後 | 只有標記為島嶼的才載入 |
島嶼架構的思路是反過來的:預設是靜態 HTML,只有明確標記的區塊才變成互動元件。
概念上像這樣:
<!-- 這些是純靜態 HTML,沒有任何 JavaScript -->
<header>...</header>
<article>
<h1>文章標題</h1>
<p>內文……</p>
</article>
<!-- 只有這個區塊是「島嶼」,會載入自己的程式碼 -->
<div data-island="comment-form">
<!-- 互動元件 -->
</div>
<footer>...</footer>內容型網站特別適合
一篇文章有 95% 是靜態文字,只有留言表單與分享按鈕需要互動。用島嶼架構的話,那 95% 完全不需要下載或執行任何 JavaScript,水合成本趨近於零。
Astro 這類框架以此為預設模式;Nuxt 也提供了伺服器元件與島嶼模式。
減少水合成本的四個方向
| 方向 | 做法 |
|---|---|
| 減少元件數量 | 靜態內容不要包成元件 |
| 延後非首屏的水合 | 用上面的部分水合策略 |
| 拆分程式碼 | 路由層級的動態載入,減少初始下載量 |
| 切分長任務 | 水合本身若無法避免,至少讓它可中斷 |
靜態內容包成元件是隱形的成本
<!-- 一個純顯示的元件,卻要進元件樹、要被比對、要被水合 -->
<template>
<p class="notice">這是一段固定的說明文字。</p>
</template>這種元件沒有任何互動,包成元件只是為了重用樣式,用 CSS class 就好。元件數量直接影響水合的工作量。
什麼情況不用太擔心
不是每個伺服器端渲染的網站都有嚴重的水合問題。內容型網站(文件站、部落格、官網)頁面主要是編譯出來的靜態 HTML,沒有大量互動元件:
| 項目 | 內容型網站的典型情況 |
|---|---|
| 內容 | 編譯出來的靜態 HTML,元件數量少 |
| 互動元件 | 側邊欄、搜尋、主題切換 |
| 自訂元件 | 多半是純顯示的卡片與提示框 |
純顯示的元件長這樣:
<template>
<a class="link-card" :href="href">
<span v-if="image" class="link-card__media">
<img :src="image" :alt="imgAlt" width="1200" height="400" loading="lazy">
</span>
<span class="link-card__title">{{ title }}</span>
</a>
</template>它沒有事件監聽、沒有狀態,水合的成本幾乎只是比對 DOM 節點。這種元件即使有幾十個,水合也很快。
真正需要擔心水合的是「每一頁有上百個帶狀態與事件的元件」那種應用程式。
檢查清單
| 項目 | 標準 |
|---|---|
| 主控台沒有水合不一致的警告 | 必備 |
| 渲染過程沒有用當前時間或隨機數 | 必備 |
渲染過程沒有讀 window 或 localStorage | 必備 |
| 響應式版面用 CSS 而非 JavaScript 判斷 | 建議 |
| 只在用戶端渲染的區塊有預留尺寸的 fallback | 必備 |
| 首屏之外的元件已延後水合 | 建議 |
| 靜態內容沒有包成元件 | 建議 |
| 已用 CPU 節流量測過水合耗時 | 建議 |
| INP 在 200 毫秒內 | 目標 |
常見問題
水合到底在做什麼?
把伺服器產生的靜態 HTML 變成可互動的應用程式。框架要重新建立元件樹、比對既有的 DOM 節點、把事件監聽掛回去、並還原狀態。這段時間畫面看得到卻按不動。
為什麼水合會讓互動延遲變差?
因為它是一個在主執行緒上執行的大型任務。頁面內容愈多、元件愈複雜,這個任務愈長。長任務 執行期間任何互動都無法被處理,使用者按了就是沒反應。
什麼是水合不一致?
伺服器產生的 HTML 與用戶端第一次渲染的結果不同。框架會發出警告,並可能丟棄整段伺服器產生的內容重新渲染,那不只浪費,還可能造成畫面閃動與 版面偏移 。
常見的不一致原因有哪些?
在渲染過程中用了當前時間、隨機數、瀏覽器專屬的物件,或是依賴只有用戶端才有的資料例如:視窗寬度與本機儲存。這些在伺服器與瀏覽器上會算出不同的結果。
島嶼架構和部分水合差在哪?
部分水合是在原本整頁水合的架構裡挑一部分先做或延後做;島嶼架構則是預設完全不水合,只有標記為互動的區塊才載入對應的程式碼。後者的靜態內容連框架執行時期都不必下載。