Skip to content

Hydration 與 INP:水合造成的互動延遲與部分水合

水合 Hydration 與 INP:互動延遲與部分水合

伺服器端渲染與靜態產生 讓瀏覽器拿到現成的 HTML,內容立刻看得到。但頁面要能 互動,還得執行一次 水合(Hydration)。

那段「看得到卻按不動」的空窗,就是 INP 變差的常見原因。

水合實際在做什麼

它不是「把 HTML 變成 DOM」

DOM 已經有了,瀏覽器解析 HTML 就建好了。水合做的是把那些 靜態的 DOM 節點接回應用程式

步驟做什麼
1下載並解析框架的執行時期與應用程式程式碼
2重新建立整棵元件樹(在記憶體裡)
3比對元件樹與既有的 DOM 節點
4把事件監聽掛到對應的節點上
5還原回應式狀態

第 2 到 5 步是 在主執行緒上跑的一個大型任務

為什麼影響 INP

水合是一個長任務

主執行緒同時負責執行 JavaScript 與更新畫面。水合執行期間:

  • 畫面 無法更新
  • 事件 無法處理

使用者這時候點按鈕,事件要排隊等水合跑完。頁面內容愈多、元件愈複雜,這段延遲愈長,大型頁面可能是數百毫秒的長任務。

這就是所謂的「未接管的島嶼」問題:畫面上一切正常,但什麼都按不動。

怎麼量測水合成本

js
// 在應用程式掛載前後量測
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 面板更完整

  1. CPU 節流(4x 或 6x),本機太快看不出問題。
  2. 錄下頁面載入。
  3. 找時間軸上首次繪製之後那個最長的任務,通常就是水合。
  4. 展開它看時間花在哪些函式上。

不節流的話水合可能只有 30 毫秒,在中階手機上卻是 300 毫秒。量測方式見 指標量測與 CrUX 數據

水合不一致

伺服器產生的 HTML 與用戶端第一次渲染的結果 不同 時,框架會發出警告:

Hydration completed but contains mismatches.

後果不只是警告

框架可能 丟棄整段伺服器產生的內容重新渲染。那意味著:

後果說明
白做工伺服器產生的 HTML 被扔掉
畫面閃動內容被換掉的瞬間
版面偏移重新渲染的結果尺寸可能不同
水合更慢多做了一次完整渲染

五個常見成因

成因為什麼會不一致
渲染時用當前時間伺服器與瀏覽器的時間點不同
渲染時用隨機數每次算出來都不同
讀取 windowdocument伺服器端沒有這些物件
依賴視窗寬度伺服器不知道使用者的螢幕
讀取 localStorage伺服器端沒有
vue
<script setup>
// 會不一致:伺服器與瀏覽器算出不同的值
const now = new Date().toLocaleString();
const id = Math.random();
const isMobile = window.innerWidth < 600;
</script>

正確的處理

把這些邏輯搬到 掛載之後

vue
<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 上就生效了,沒有不一致的問題,也不必等水合。

css
.mobile-only { display: none; }

@media (max-width: 600px) {
  .mobile-only { display: block; }
  .desktop-only { display: none; }
}

只在用戶端渲染的區塊

真的無法避免的情況(例如:依賴瀏覽器 API 的圖表),把那一塊排除在水合之外:

vue
<template>
  <!-- Nuxt:這個元件只在瀏覽器端渲染 -->
  <ClientOnly>
    <RealtimeChart />
    <template #fallback>
      <!-- 伺服器端與水合前顯示這個,記得預留高度避免版面偏移 -->
      <div class="chart-placeholder" />
    </template>
  </ClientOnly>
</template>

<style>
.chart-placeholder {
  aspect-ratio: 16 / 9;
}
</style>

一定要給 fallback 預留尺寸

沒有預留高度的話,圖表載入時會把下方內容往下推,那是一次 CLS

部分水合

不必一次水合整頁。三種常見策略:

策略做法適合
延後水合元件進入可視範圍才水合頁面下方的區塊
互動時水合使用者點了才水合收合區塊、對話框
閒置時水合主執行緒空閒時水合次要的互動元件

Nuxt 的延後水合設定:

vue
<template>
  <!-- 進入可視範圍才水合 -->
  <LazyHeavyChart hydrate-on-visible />

  <!-- 使用者互動才水合 -->
  <LazyCommentForm hydrate-on-interaction />

  <!-- 主執行緒閒置時才水合 -->
  <LazyRelatedPosts hydrate-on-idle />
</template>

先處理首屏之外的元件

判斷順序:

  1. 首屏可見且需要立刻互動(主導覽、搜尋框)→ 正常水合。
  2. 首屏可見但不急(輪播、統計數字)→ 閒置時水合。
  3. 首屏之外(留言區、相關文章、頁尾)→ 進入可視範圍才水合。

第 3 類通常佔了頁面的大部分,延後它們的收益最大。

島嶼架構

這是更徹底的做法

部分水合島嶼架構
預設行為整頁水合,挑一部分延後完全不水合
靜態內容仍在元件樹裡完全不進元件樹
框架執行時期一定要下載靜態頁面可以不下載
互動元件全部或延後只有標記為島嶼的才載入

島嶼架構的思路是反過來的:預設是靜態 HTML,只有明確標記的區塊才變成互動元件。

概念上像這樣:

html
<!-- 這些是純靜態 HTML,沒有任何 JavaScript -->
<header>...</header>
<article>
  <h1>文章標題</h1>
  <p>內文……</p>
</article>

<!-- 只有這個區塊是「島嶼」,會載入自己的程式碼 -->
<div data-island="comment-form">
  <!-- 互動元件 -->
</div>

<footer>...</footer>

內容型網站特別適合

一篇文章有 95% 是靜態文字,只有留言表單與分享按鈕需要互動。用島嶼架構的話,那 95% 完全不需要下載或執行任何 JavaScript,水合成本趨近於零

Astro 這類框架以此為預設模式;Nuxt 也提供了伺服器元件與島嶼模式。

減少水合成本的四個方向

方向做法
減少元件數量靜態內容不要包成元件
延後非首屏的水合用上面的部分水合策略
拆分程式碼路由層級的動態載入,減少初始下載量
切分長任務水合本身若無法避免,至少讓它可中斷

靜態內容包成元件是隱形的成本

vue
<!-- 一個純顯示的元件,卻要進元件樹、要被比對、要被水合 -->
<template>
  <p class="notice">這是一段固定的說明文字。</p>
</template>

這種元件沒有任何互動,包成元件只是為了重用樣式,用 CSS class 就好。元件數量直接影響水合的工作量。

什麼情況不用太擔心

不是每個伺服器端渲染的網站都有嚴重的水合問題。內容型網站(文件站、部落格、官網)頁面主要是編譯出來的靜態 HTML,沒有大量互動元件:

項目內容型網站的典型情況
內容編譯出來的靜態 HTML,元件數量少
互動元件側邊欄、搜尋、主題切換
自訂元件多半是純顯示的卡片與提示框

純顯示的元件長這樣:

vue
<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 節點。這種元件即使有幾十個,水合也很快。

真正需要擔心水合的是「每一頁有上百個帶狀態與事件的元件」那種應用程式。

檢查清單

項目標準
主控台沒有水合不一致的警告必備
渲染過程沒有用當前時間或隨機數必備
渲染過程沒有讀 windowlocalStorage必備
響應式版面用 CSS 而非 JavaScript 判斷建議
只在用戶端渲染的區塊有預留尺寸的 fallback必備
首屏之外的元件已延後水合建議
靜態內容沒有包成元件建議
已用 CPU 節流量測過水合耗時建議
INP 在 200 毫秒內目標

常見問題

水合到底在做什麼?

把伺服器產生的靜態 HTML 變成可互動的應用程式。框架要重新建立元件樹、比對既有的 DOM 節點、把事件監聽掛回去、並還原狀態。這段時間畫面看得到卻按不動。

為什麼水合會讓互動延遲變差?

因為它是一個在主執行緒上執行的大型任務。頁面內容愈多、元件愈複雜,這個任務愈長。長任務 執行期間任何互動都無法被處理,使用者按了就是沒反應。

什麼是水合不一致?

伺服器產生的 HTML 與用戶端第一次渲染的結果不同。框架會發出警告,並可能丟棄整段伺服器產生的內容重新渲染,那不只浪費,還可能造成畫面閃動與 版面偏移

常見的不一致原因有哪些?

在渲染過程中用了當前時間、隨機數、瀏覽器專屬的物件,或是依賴只有用戶端才有的資料例如:視窗寬度與本機儲存。這些在伺服器與瀏覽器上會算出不同的結果。

島嶼架構和部分水合差在哪?

部分水合是在原本整頁水合的架構裡挑一部分先做或延後做;島嶼架構則是預設完全不水合,只有標記為互動的區塊才載入對應的程式碼。後者的靜態內容連框架執行時期都不必下載。

延伸閱讀

參考資料:web.dev:Optimize long tasks