Vue 基础体系 · 第 67/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue SSR 水合诊断:不一致来源、客户端边界和调试方法

1. 先区分 SSR、渲染和水合

**SSR(Server-Side Rendering,服务端渲染)**是指 Vue 组件树先在服务器上执行,生成 HTML 字符串,再把 HTML 返回给浏览器。浏览器不需要等待 JavaScript 执行,就可以先看到页面结构和文本。

**CSR(Client-Side Rendering,客户端渲染)**则是浏览器下载 JavaScript 后,由 Vue 在客户端创建 DOM。传统 CSR 的初始页面通常只有一个空容器,例如:

<div id="app"></div>

**水合(Hydration)**是 SSR 页面到达浏览器后,Vue 使用同一套组件和状态重新执行一次客户端渲染,并将已有的服务端 DOM 与客户端虚拟 DOM 关联起来的过程。

水合不是“重新把页面渲染一遍”的简单别名。它至少要完成两件事:

  1. 检查服务端已经生成的 DOM 是否符合客户端首次渲染的预期;
  2. 将事件监听器、组件实例、响应式依赖和 DOM 节点建立关联。

因此,SSR 页面初始加载的基本路径是:

sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    participant V as Vue 客户端运行时

    B->>S: 请求页面
    S->>S: 执行组件与数据获取
    S-->>B: HTML + 初始状态
    B->>B: 解析 HTML,形成 DOM
    B->>V: 下载并执行客户端 JavaScript
    V->>V: 使用初始状态执行首次渲染
    V->>B: 水合已有 DOM
    V->>B: 绑定事件和响应式更新

Vue SSR 指南所要求的核心前提是:服务端渲染结果和客户端首次渲染结果应当一致。这里的“一致”不是要求两个环境的每个内部对象都相同,而是要求它们对同一份初始输入产生等价的 DOM 结构、文本和关键属性。


2. 水合一致性的形式化条件

设:

  • II:页面的初始输入,包括路由、请求数据、用户状态和组件 props;
  • EsE_s:服务端执行环境;
  • EcE_c:客户端执行环境;
  • RR:Vue 组件渲染函数;
  • HH:服务端生成的 HTML;
  • PP:浏览器 HTML 解析器;
  • Ds=P(H)D_s = P(H):浏览器把服务端 HTML 解析后的实际 DOM;
  • Vc=R(I,Ec)V_c = R(I, E_c):客户端首次渲染得到的虚拟 DOM。

水合期望满足:

DsDOM(Vc)D_s \equiv \operatorname{DOM}(V_c)

其中 \equiv 表示在水合所关心的范围内等价,通常包括:

  • 元素节点的类型和层级;
  • 文本节点内容;
  • 关键属性;
  • 列表节点的顺序和数量;
  • 组件边界对应的 DOM 结构。

服务端生成 HTML 本身还受到浏览器解析器影响,因此不能只比较字符串:

Hs=HcH_s = H_c

不是必要条件;更重要的是:

P(Hs)DOM(Vc)P(H_s) \equiv \operatorname{DOM}(V_c)

例如,服务端输出了非法嵌套:

<p>
  外层文本
  <div>内层块</div>
</p>

浏览器可能会自动关闭 p,实际 DOM 可能变成:

<p>外层文本</p>
<div>内层块</div>
<p></p>

即使 Vue 的服务端字符串和客户端虚拟 DOM 都“看起来符合组件代码”,浏览器已经把服务端 HTML 改造成了另一棵 DOM 树,水合仍然可能失败。

2.1 “同一份输入”比“同一个组件”更重要

下面的组件在两个环境中执行的是同一个组件,但结果不一定相同:

<script setup lang="ts">
const id = Math.random()
const now = new Date().toISOString()
</script>

<template>
  <p>请求编号:{{ id }}</p>
  <p>生成时间:{{ now }}</p>
</template>

服务端和客户端分别执行:

服务端:请求编号 0.148,生成时间 2025-01-01T10:00:00.000Z
客户端:请求编号 0.972,生成时间 2025-01-01T10:00:01.200Z

组件代码相同,但输入并不相同,因为 Math.random()new Date() 每次执行都会产生新结果。因此:

R(I,Es)R(I,Ec)R(I, E_s) \ne R(I, E_c)

水合不一致不是 Vue 无法处理普通响应式数据,而是两个渲染过程违反了确定性条件。


3. 水合不一致的主要来源

3.1 非确定性值:时间、随机数和递增标识

最直接的来源是把每次执行都会变化的值直接放入模板:

<script setup lang="ts">
const createdAt = new Date()
const randomColor = `hsl(${Math.random() * 360} 80% 50%)`
</script>

<template>
  <time>{{ createdAt.toLocaleString() }}</time>
  <div :style="{ color: randomColor }">内容</div>
</template>

即使服务端和客户端在同一秒执行,toLocaleString() 也可能因时区、语言环境不同而输出不同结果。

方案一:把值作为服务端状态传给客户端

如果时间和随机数属于页面初始数据,就应该在服务端生成一次,并序列化到客户端。以 Nuxt 为例,可以使用 useState 保存 SSR 期间生成、并在客户端复用的状态:

<script setup lang="ts">
const requestId = useState('request-id', () => crypto.randomUUID())
const generatedAt = useState('generated-at', () => new Date().toISOString())
</script>

<template>
  <p>请求编号:{{ requestId }}</p>
  <time :datetime="generatedAt">{{ generatedAt }}</time>
</template>

这里的关键不是 useState 的名字,而是状态生命周期:

  1. 服务端首次渲染时初始化状态;
  2. Nuxt 将状态放入页面 payload;
  3. 客户端读取同一份 payload;
  4. 客户端首次渲染使用已经存在的值,而不是再次执行不同的初始化逻辑。

Nuxt 的 useState 是 Nuxt 能力,不是 Vue 核心 API。使用其他 SSR 框架时,需要采用该框架的状态序列化机制,或者显式将状态放入安全的 JSON 数据块中。

方案二:让值只在挂载后生成

如果随机颜色只是装饰,不属于 SEO 内容,也不需要服务端输出,可以延迟到客户端:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const randomColor = ref<string | null>(null)

onMounted(() => {
  randomColor.value = `hsl(${Math.random() * 360} 80% 50%)`
})
</script>

<template>
  <div :style="randomColor ? { color: randomColor } : undefined">
    内容
  </div>
</template>

服务端和客户端首次渲染都没有颜色;水合完成后,onMounted 才修改颜色。代价是服务端初始结果不包含该效果,并且可能出现一次布局或视觉变化。


3.2 浏览器专属 API 出现在服务端执行路径

SSR 时组件会在 Node.js 或其他服务端运行时执行。以下对象通常只存在于浏览器:

  • window
  • document
  • localStorage
  • navigator
  • matchMedia
  • IntersectionObserver

下面的代码会在 SSR 阶段直接抛错:

const theme = localStorage.getItem('theme')

即使把它改成条件表达式,也要注意模板分支本身可能造成水合不一致:

<script setup lang="ts">
const isMobile = window.innerWidth < 768
</script>

<template>
  <MobileNav v-if="isMobile" />
  <DesktopNav v-else />
</template>

服务端没有 window,而且服务端也无法可靠知道浏览器视口宽度。更稳妥的写法是让服务端和客户端首次渲染使用同一默认分支,再在挂载后读取浏览器状态:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const isMobile = ref(false)
const ready = ref(false)

onMounted(() => {
  isMobile.value = window.matchMedia('(max-width: 767px)').matches
  ready.value = true
})
</script>

<template>
  <nav v-if="!ready" aria-label="导航">
    <a href="/menu">菜单</a>
  </nav>

  <MobileNav v-else-if="isMobile" />
  <DesktopNav v-else />
</template>

初始阶段 ready 在服务端和客户端都是 false,因此结构一致。挂载后才根据浏览器环境切换。

不过,CSS 媒体查询通常比 JavaScript 判断视口更适合处理响应式布局

.desktop-nav {
  display: block;
}

.mobile-nav {
  display: none;
}

@media (max-width: 767px) {
  .desktop-nav {
    display: none;
  }

  .mobile-nav {
    display: block;
  }
}

这样可以保留稳定的 SSR 结构,避免让视口信息参与水合期的组件分支。


3.3 服务端和客户端使用了不同的语言环境、时区或平台能力

以下代码并不一定安全:

<template>
  <span>{{ price.toLocaleString() }}</span>
</template>

服务端可能运行在 UTC、en-US 环境,浏览器可能运行在 Asia/Shanghaizh-CN 环境。对于数字格式化,分隔符可能不同;对于日期格式化,日期甚至可能跨天。

需要把格式化规则显式化:

const formatter = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY',
})

const text = formatter.format(1234.5)

日期则应明确指定时区:

const formatter = new Intl.DateTimeFormat('zh-CN', {
  timeZone: 'Asia/Shanghai',
  dateStyle: 'medium',
  timeStyle: 'short',
})

如果需求是“显示用户本地时间”,服务端通常无法知道用户时区。此时应在服务端输出稳定的 UTC 字符串或占位文本,客户端挂载后再转换,而不能假设 Node.js 的时区等于用户时区。


3.4 初始数据在服务端和客户端重复请求但结果不同

常见错误是服务端为了生成 HTML 请求一次数据,客户端启动后又立即请求一次:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const products = ref<Product[]>([])

onMounted(async () => {
  products.value = await fetch('/api/products').then(r => r.json())
})
</script>

如果服务端模板已经渲染了商品列表,客户端首次渲染却从空数组开始,水合时就会看到:

服务端:<li>商品 A</li><li>商品 B</li>
客户端首次渲染:没有 <li>

正确的 SSR 数据流应当是:

服务端请求数据
    ↓
服务端渲染列表
    ↓
将列表数据放入序列化 payload
    ↓
客户端读取同一份列表数据
    ↓
客户端首次渲染相同列表
    ↓
后续才进行刷新或重新验证

在 Nuxt 中,可以使用 useAsyncData

<script setup lang="ts">
interface Product {
  id: string
  name: string
}

const { data, error, status, refresh } = await useAsyncData<Product[]>(
  'products',
  () => $fetch('/api/products'),
)
</script>

<template>
  <p v-if="status === 'pending'">加载中</p>
  <p v-else-if="error">商品加载失败</p>

  <ul v-else>
    <li v-for="product in data ?? []" :key="product.id">
      {{ product.name }}
    </li>
  </ul>

  <button type="button" @click="refresh">刷新</button>
</template>

Nuxt 会处理 SSR 数据和客户端 payload 的衔接;具体缓存、去重和重新验证行为受 Nuxt 版本及配置影响。这里的关键是:模板初始状态和服务端渲染状态必须来自同一份可传输数据。

错误状态也必须一致。假设服务端请求失败并渲染了错误提示,但客户端首次初始化为“加载中”,仍然会产生结构差异。服务端应将成功、失败、加载结果一起纳入可序列化的初始状态。


3.5 只在客户端存在的依赖被服务端导入

有些第三方库在模块顶层就访问 window

// chart.ts
const canvas = document.createElement('canvas')
export function draw() {
  // ...
}

即使组件只在 onMounted 中调用 draw,下面的静态导入仍可能在 SSR 加载模块时执行顶层代码:

import { draw } from './chart'

可以把导入也放到客户端生命周期中:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const canvas = ref<HTMLCanvasElement | null>(null)

onMounted(async () => {
  const { draw } = await import('./chart')

  if (canvas.value) {
    draw(canvas.value)
  }
})
</script>

<template>
  <canvas ref="canvas" width="600" height="300"></canvas>
</template>

这解决的是“服务端执行失败”问题。如果第三方组件还会在服务端和客户端生成不同的 DOM,则仍需要客户端边界或稳定的服务端占位结构。


3.6 HTML 结构非法导致浏览器修复 DOM

Vue 模板看起来嵌套正确,不代表最终 HTML 合法。常见风险包括:

<template>
  <p>
    一段文本
    <div>块级内容</div>
  </p>
</template>

还包括:

  • tabletbodytrtd 的错误嵌套;
  • 交互元素互相嵌套,例如 button 内放 button
  • 重复的 id
  • 服务端模板拼接出未转义的 HTML。

诊断这类问题时,不能只看 Vue 单文件组件。要比较:

  1. 服务端返回的原始 HTML;
  2. 浏览器解析后的 Elements DOM;
  3. 客户端首次渲染的组件结构。

原始 HTML 可以通过浏览器的“查看网页源代码”获取;Elements 面板显示的是浏览器修复后的 DOM,两者不一定相同。


3.7 列表键、排序和条件分支不稳定

列表水合不仅关心节点数量,也关心顺序和节点身份:

<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

如果服务端按数据库默认顺序返回,客户端又在初始化时按 name 排序,则节点顺序不同。无稳定 key 时,后续更新还可能把错误的组件实例复用于另一个列表项。

排序应当显式指定:

const sortedItems = computed(() =>
  [...items.value].sort((a, b) => a.id.localeCompare(b.id)),
)

不要依赖:

  • 对象属性的业务顺序;
  • 数据库未声明的默认排序;
  • 不同运行时对字符串排序的隐含差异;
  • 服务端和客户端各自执行不同的过滤条件。

权限、登录状态和实验分组也属于条件分支输入。服务端依据请求 Cookie 认为用户已登录,而客户端启动时从尚未恢复的 localStorage 认为用户未登录,就会得到不同菜单结构。应在 SSR 阶段确定身份,并将脱敏后的用户状态传给客户端。


4. 客户端边界:哪些内容不参加 SSR 水合

“客户端边界”指的是:明确某一部分组件、数据或依赖只能在浏览器中初始化,不要求它作为普通 SSR 内容参与水合。

需要区分三个概念:

  1. 客户端执行边界:代码只在浏览器加载或执行;
  2. 客户端渲染边界:组件的真实内容不由服务端输出;
  3. 数据边界:服务端和客户端共享序列化初始数据,后续浏览器再独立刷新。

Vue 核心提供 SSR API 和生命周期,但没有一个等价于 Nuxt <ClientOnly> 的通用内置组件。具体边界能力通常由上层框架提供。

4.1 使用 onMounted 建立最小客户端边界

在纯 Vue SSR 应用中,可以用挂载状态控制浏览器专属内容:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const mounted = ref(false)

onMounted(() => {
  mounted.value = true
})
</script>

<template>
  <section>
    <h2>服务端可渲染的标题</h2>

    <div v-if="mounted">
      <BrowserOnlyWidget />
    </div>

    <div v-else aria-hidden="true">
      图表加载中
    </div>
  </section>
</template>

这里的 v-if="mounted" 在服务端和客户端首次渲染时都为假,因此水合安全。onMounted 之后才创建 BrowserOnlyWidget

这种方式的代价是:

  • 客户端专属内容没有 SSR HTML;
  • 可能出现内容延迟和布局偏移;
  • 该内容不能依赖服务端输出实现搜索引擎可见性;
  • 客户端边界过大时,会退化成局部 CSR,增加交互等待。

因此,边界应尽量包住真正依赖浏览器的部分,而不是整个页面。

4.2 Nuxt 的 <ClientOnly>

在 Nuxt 中可以直接使用:

<template>
  <main>
    <h1>报告详情</h1>

    <ClientOnly fallback-tag="div" fallback="图表加载中">
      <ClientChart />
    </ClientOnly>
  </main>
</template>

其语义是:

  • 服务端渲染 fallback
  • 客户端水合期间保持可预测的占位;
  • 客户端挂载后渲染 ClientChart

ClientOnly 是 Nuxt 提供的能力,不应写成纯 Vue 应用可以直接使用的 Vue 核心 API。它适合图表、地图、编辑器、依赖 DOM 测量的组件,但不应被用来掩盖本来应该修复的数据不一致。

例如,商品价格在服务端和客户端不同,直接包在 <ClientOnly> 中虽然可能不再报告该区域的水合警告,却会失去 SSR 内容。这是绕开问题,而不是修复价格数据流。


5. Vue SSR 应用中的生命周期和错误路径

使用 Vue SSR API 时,服务端和客户端应分别创建应用实例,不能把带请求状态的应用实例在多个请求之间复用。

服务端入口通常类似:

// entry-server.ts
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'

export async function render(url: string) {
  const app = createSSRApp(App)

  // 根据 url 获取路由、请求数据,并注入到本次请求的 app 上下文
  // 省略具体路由实现

  const html = await renderToString(app)
  return html
}

客户端入口:

// entry-client.ts
import { createSSRApp } from 'vue'
import App from './App.vue'

const app = createSSRApp(App)

app.mount('#app')

客户端入口必须使用 createSSRApp。普通 CSR 使用的 createApp 会按客户端创建新 DOM 的路径运行,不能作为 SSR 水合入口。

服务端请求级数据不能放入模块级可变变量:

// 错误风险示例
let currentUser: User | null = null

Node.js 进程会处理多个请求。如果请求 A 和请求 B 共享这个变量,就可能把 A 的用户数据渲染到 B 的页面中。这不仅会造成水合不一致,也是严重的数据泄露风险。

正确原则是:

  • 每个请求创建独立的 app、路由、状态容器;
  • 请求数据通过当前请求上下文传递;
  • 序列化到客户端的数据必须经过严格筛选和转义;
  • 不把 Token、密码或完整用户对象直接注入 HTML。

如果服务端数据请求失败,应定义一致的错误策略:

try {
  const data = await loadData()
  // 用成功状态渲染,并序列化 data
} catch (error) {
  // 记录服务端错误
  // 用确定的错误状态渲染
  // 将 error 状态而不是不可序列化的 Error 对象传给客户端
}

客户端首次渲染应读取同一个“成功”或“失败”状态,而不是无条件重新回到 pending


6. 调试水合不一致的系统方法

6.1 先记录第一条警告

开发环境常见警告包括:

Hydration node mismatch
Hydration text mismatch
Hydration children mismatch

第一条警告通常比后续警告更接近根因。一个父节点数量不一致,就可能导致其后的多个子节点全部被报告为异常。

可以在开发环境增加警告收集:

const app = createSSRApp(App)

if (import.meta.env.DEV) {
  app.config.warnHandler = (message, instance, trace) => {
    console.warn('[Vue warn]', message)
    console.warn(trace)
    console.warn('component:', instance)
  }
}

warnHandler 主要用于开发诊断,不应依赖它实现生产错误处理。

6.2 按三个版本比较 DOM

对发生问题的区域,依次比较:

第一份:服务端原始 HTML

使用“查看网页源代码”,而不是只看 Elements。检查:

  • 文本是否已经错误;
  • 列表是否已经缺项;
  • data-* 状态是否存在;
  • 是否有非法嵌套;
  • 是否因为错误状态渲染了 fallback。

第二份:浏览器解析后的 DOM

在控制台执行:

document.querySelector('#app')?.innerHTML

如果它和“查看网页源代码”不同,优先检查 HTML 语法和浏览器自动修复。

第三份:客户端首次渲染结果

在组件中临时打印:

console.log({
  mounted: false,
  data: data.value,
  locale: navigator.language,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})

不要只在 onMounted 后打印,因为水合已经完成;应记录组件 setup 和首次模板所使用的输入。

6.3 用二分法定位组件边界

可以暂时将页面替换成稳定的静态片段:

<template>
  <div id="debug-root">
    <h1>稳定标题</h1>
    <!-- 逐个恢复子组件 -->
  </div>
</template>

然后按以下顺序恢复:

  1. 静态文本;
  2. 服务端数据列表;
  3. 时间和格式化逻辑;
  4. 浏览器专属组件;
  5. 第三方库;
  6. 条件分支和权限分支。

一旦恢复某个区域后警告出现,就检查该区域首次渲染依赖的全部输入,而不是只盯着警告指出的 DOM 节点。

6.4 检查生产构建,而不只看开发环境

开发和生产可能存在差异:

  • import.meta.env.DEV 分支不同;
  • 构建工具替换环境变量;
  • 第三方库的开发/生产入口不同;
  • 开发模式下 HMR 改变执行顺序;
  • 压缩、代码分割和动态导入改变加载路径。

至少应验证:

npm run build
npm run preview

然后使用预览服务访问 SSR 页面。若项目使用 Nuxt,应使用项目定义的构建和预览命令,并确认服务端渲染模式没有被静态生成或纯客户端模式替代。


7. 一个可复现的不一致例子

下面的组件必然存在文本水合不一致:

<script setup lang="ts">
const value = Math.random()
</script>

<template>
  <p data-test="value">{{ value }}</p>
</template>

假设服务端输出:

<p data-test="value">0.123456</p>

客户端首次执行得到:

0.987654

Vue 发现:

服务端文本:0.123456
客户端文本:0.987654

在开发环境会报告警告,并可能修正 DOM。这里“最终页面看起来正常”并不表示代码正确,因为:

  • 事件和组件状态可能已经按另一棵树建立;
  • 复杂子树可能进行更昂贵的修复;
  • 某些不一致不会被完全修复;
  • 警告会掩盖真正的生产问题。

改成显式初始输入:

<script setup lang="ts">
const value = defineProps<{
  value: number
}>()
</script>

<template>
  <p data-test="value">{{ value.value }}</p>
</template>

父组件在服务端生成数字,并通过 SSR payload 让客户端获得同一数字,才满足一致性条件。

如果随机值只属于客户端动画,则使用挂载后的状态:

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const value = ref<number | null>(null)

onMounted(() => {
  value.value = Math.random()
})
</script>

<template>
  <p data-test="value">
    {{ value === null ? '准备生成' : value }}
  </p>
</template>

这里服务端和客户端首次都输出“准备生成”,随机值只在水合完成后出现。


8. 有意不一致与抑制警告

某些内容天然无法在服务端确定,例如用户本地时间。修复优先级应当是:

  1. 传递确定的服务端输入;
  2. 延迟客户端专属内容;
  3. 使用稳定的占位内容;
  4. 最后才考虑抑制已知且局部的不一致。

Vue 3.5 引入了 data-allow-mismatch,允许对特定区域声明预期的不一致。该能力具有明确的版本要求,使用前应核对当前 Vue 版本和官方文档。它不是通用修复工具,不能替代状态同步、合法 HTML 或正确的客户端边界。

错误示例是把整个根节点标记为允许不一致:

<div id="app" data-allow-mismatch>
  ...
</div>

这会降低诊断能力,并可能隐藏真正的结构错误。若必须使用,应将范围限制在确实由时区、随机展示等因素导致的最小节点,并写明原因和后续客户端更新路径。


9. 常见误解与生产取舍

误解一:水合警告只是开发环境噪声

不正确。开发环境警告是 Vue 发现服务端和客户端初始结果不同的证据。生产环境可能减少日志,但不代表不一致消失。修复策略可能包括复用错误 DOM、重新创建节点或跳过部分检查,具体行为不应当被当作业务契约依赖。

误解二:加上 onMounted 就能修复所有问题

onMounted 只能处理真正属于客户端的内容。如果服务端数据本应被客户端复用,却在 onMounted 中重新请求,可能导致首屏闪烁、SEO 内容缺失和额外请求。它是客户端边界工具,不是 SSR 数据同步工具。

误解三:ClientOnly 越多越安全

客户端边界可以减少水合风险,但会牺牲:

  • 首屏 HTML 内容;
  • 搜索引擎可见性;
  • 首屏交互速度;
  • 无 JavaScript 或弱网环境下的可用性。

更合理的做法是让标题、文本、关键数据和基本布局继续 SSR,仅把依赖 DOM、Canvas、浏览器存储或视口测量的局部组件放到客户端。

误解四:只要最终 DOM 正确,水合就成功

水合还涉及组件实例、事件监听、状态和后续更新。一个节点即使最后显示正确,也可能在过程中经历了错误复用、事件未绑定到预期元素或组件状态错位。应以“首次客户端结果与服务端结果一致”为判断标准,而不是只看最终视觉效果。


10. 诊断决策路径

可以把一次水合故障按以下顺序处理:

flowchart TD
    A[出现 Hydration 警告或首屏异常] --> B{服务端原始 HTML 是否已经错误}
    B -- 是 --> C[检查服务端数据、模板分支和错误状态]
    B -- 否 --> D{浏览器解析后的 DOM 是否改变了 HTML}
    D -- 是 --> E[检查非法嵌套、table 结构和 HTML 注入]
    D -- 否 --> F{客户端首次输入是否不同}
    F -- 是 --> G[检查时间、随机数、时区、Cookie、localStorage 和请求数据]
    F -- 否 --> H{是否加载了浏览器专属依赖}
    H -- 是 --> I[延迟导入或建立最小客户端边界]
    H -- 否 --> J[检查列表排序、key、条件分支和构建环境]
    C --> K[重新构建并验证 SSR 与水合]
    E --> K
    G --> K
    I --> K
    J --> K

这条路径的核心是先判断故障发生在哪一层:

  • 服务端 HTML 已错:问题在服务端数据或 SSR 分支;
  • 浏览器解析后变了:问题通常是 HTML 结构;
  • 服务端 HTML 正确但客户端不同:问题在首次渲染输入;
  • 代码无法在服务端执行:问题在浏览器专属依赖;
  • 结果看似相同但交互异常:检查组件边界、列表 key 和 DOM 复用。

SSR 水合的稳定性最终依赖一个清晰的数据流:服务端为当前请求生成确定状态,状态以安全方式传给客户端,客户端首次渲染复用这份状态;真正无法在服务端确定的内容则被明确隔离到客户端边界内。只要能沿着这条数据流逐项比较输入、HTML、解析后的 DOM 和客户端首次结果,水合问题通常就能从“偶发的 Vue 警告”还原为可验证的因果链。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。