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 关联起来的过程。
水合不是“重新把页面渲染一遍”的简单别名。它至少要完成两件事:
- 检查服务端已经生成的 DOM 是否符合客户端首次渲染的预期;
- 将事件监听器、组件实例、响应式依赖和 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. 水合一致性的形式化条件
设:
- :页面的初始输入,包括路由、请求数据、用户状态和组件 props;
- :服务端执行环境;
- :客户端执行环境;
- :Vue 组件渲染函数;
- :服务端生成的 HTML;
- :浏览器 HTML 解析器;
- :浏览器把服务端 HTML 解析后的实际 DOM;
- :客户端首次渲染得到的虚拟 DOM。
水合期望满足:
其中 表示在水合所关心的范围内等价,通常包括:
- 元素节点的类型和层级;
- 文本节点内容;
- 关键属性;
- 列表节点的顺序和数量;
- 组件边界对应的 DOM 结构。
服务端生成 HTML 本身还受到浏览器解析器影响,因此不能只比较字符串:
不是必要条件;更重要的是:
例如,服务端输出了非法嵌套:
<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() 每次执行都会产生新结果。因此:
水合不一致不是 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 的名字,而是状态生命周期:
- 服务端首次渲染时初始化状态;
- Nuxt 将状态放入页面 payload;
- 客户端读取同一份 payload;
- 客户端首次渲染使用已经存在的值,而不是再次执行不同的初始化逻辑。
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 或其他服务端运行时执行。以下对象通常只存在于浏览器:
windowdocumentlocalStoragenavigatormatchMediaIntersectionObserver
下面的代码会在 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/Shanghai、zh-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>
还包括:
table、tbody、tr、td的错误嵌套;- 交互元素互相嵌套,例如
button内放button; - 重复的
id; - 服务端模板拼接出未转义的 HTML。
诊断这类问题时,不能只看 Vue 单文件组件。要比较:
- 服务端返回的原始 HTML;
- 浏览器解析后的 Elements DOM;
- 客户端首次渲染的组件结构。
原始 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 内容参与水合。
需要区分三个概念:
- 客户端执行边界:代码只在浏览器加载或执行;
- 客户端渲染边界:组件的真实内容不由服务端输出;
- 数据边界:服务端和客户端共享序列化初始数据,后续浏览器再独立刷新。
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>
然后按以下顺序恢复:
- 静态文本;
- 服务端数据列表;
- 时间和格式化逻辑;
- 浏览器专属组件;
- 第三方库;
- 条件分支和权限分支。
一旦恢复某个区域后警告出现,就检查该区域首次渲染依赖的全部输入,而不是只盯着警告指出的 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. 有意不一致与抑制警告
某些内容天然无法在服务端确定,例如用户本地时间。修复优先级应当是:
- 传递确定的服务端输入;
- 延迟客户端专属内容;
- 使用稳定的占位内容;
- 最后才考虑抑制已知且局部的不一致。
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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Nuxt Server Routes:Nitro、API、运行时配置、缓存和部署
- 下一篇:Vue 与 Nuxt SEO:Meta、结构化数据、Canonical、站点地图和渲染
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论