SSR 数据传递
了解 Nuxt 如何在服务端和客户端之间传递数据。这是 SSR 应用最核心的机制——服务端渲染的 HTML 需要数据,但客户端 Hydration 时不能重复请求,否则会闪烁和性能浪费。
Payload 机制——SSR 数据传递的核心
SSR 时,服务端获取的数据通过 Payload 传递给客户端,避免重复请求。Payload 是嵌入在 HTML 中的 JavaScript 对象。
工作流程
服务端 客户端
━━━━ ━━━━
1. useFetch 获取数据
↓
2. 数据缓存到 nuxtApp.payload
↓
3. 序列化 → __NUXT__ 对象 → 4. 读取 Payload
↓ ↓
5. 嵌入 HTML <script> 标签 → 5. useFetch 直接使用
(不再请求!)
↓
6. 后续客户端导航
→ 在客户端发起新请求关键理解
- 步骤 1-3:在服务端执行。
useFetch在服务端获取数据后,将数据存入nuxtApp.payload.data。 - 步骤 4-5:在客户端执行。
useFetch发现 Payload 中已有数据,直接使用,不再发起请求。 - 步骤 6:客户端导航(从 A 页面到 B 页面)时,Payload 中没有 B 页面的数据,需要发起新请求。
这就是 SSR 的优势
服务端获取一次,客户端直接使用,零重复请求。
HTML 中的 Payload
查看页面源码(Ctrl+U),可以看到类似这样的 <script> 标签:
<script>
window.__NUXT__ = {
data: {
'/api/users': [{ id: 1, name: 'Alice' }],
'app-config': { theme: 'dark', locale: 'zh-CN' },
},
state: {
counter: 42,
sidebarOpen: false,
},
error: null,
}
</script>Payload 的内容
data:所有useFetch/useAsyncData获取的数据(按 key 存储)state:所有useState创建的状态(按 key 存储)error:全局错误信息
INFO
️ Payload 在客户端可见!不要将敏感信息(密码、token)放入 Payload 详见下方"安全注意事项"
序列化——数据如何变成字符串
Nuxt 使用 devalue 进行序列化。与 JSON.stringify 不同,devalue 支持更多 JavaScript 类型:
支持的类型
| 类型 | 示例 | 说明 |
|---|---|---|
| ✅ 基本类型 | string、number、boolean | 完美支持 |
| ✅ 对象和数组 | { name: 'Alice' }、[1, 2, 3] | 完美支持 |
| ✅ Date 对象 | new Date() | 自动还原为 Date 对象 |
| ✅ Map / Set | new Map()、new Set() | 自动还原 |
| ✅ undefined | undefined | JSON.stringify 会丢失,devalue 保留 |
| ✅ 正则表达式 | /pattern/g | 自动还原 |
| ✅ Infinity | Infinity、-Infinity | JSON.stringify 会变成 null |
| ✅ BigInt | BigInt(9007199254740991) | 自动还原 |
| ❌ 函数 | () => {} | 会丢失 |
| ❌ Symbol | Symbol('foo') | 会丢失 |
| ❌ DOM 元素 | document.body | 会丢失 |
| ❌ 类实例 | new MyClass() | 方法丢失,只保留属性 |
INFO
️ 最常见的问题:在 transform 中返回了包含函数或类实例的对象 导致客户端反序列化后丢失
// ❌ transform 返回了包含方法的对象
const { data } = await useFetch('/api/users', {
transform: (users) => users.map(u => ({
...u,
getFullName() { return `${u.firstName} ${u.lastName}` }, // 函数会丢失!
})),
})
// ✅ 用计算属性代替方法
const { data } = await useFetch('/api/users', {
transform: (users) => users.map(u => ({
...u,
fullName: `${u.firstName} ${u.lastName}`, // 直接计算,不存函数
})),
})自定义序列化
方案 1:在 transform 中转换为普通对象(推荐)
最简单的方式——在 transform 中把自定义类型转为可序列化的普通对象:
class User {
constructor(public id: number, public name: string) {}
greet() { return `Hello, ${this.name}` }
}
const { data } = await useFetch('/api/users', {
transform: (users) => users.map(u => ({
id: u.id,
name: u.name,
// 不传递 greet() 方法
})),
})
// 如果客户端需要 greet(),创建一个计算属性
const greeting = computed(() => `Hello, ${data.value?.name}`)方案 2:使用插件自定义序列化
如果需要在整个应用中处理自定义类型的序列化:
// app/plugins/payload.ts
export default defineNuxtPlugin((nuxtApp) => {
// 自定义还原函数
if (import.meta.client) {
nuxtApp.hook('app:created', () => {
// 在 Payload 数据还原后处理
})
}
})Payload 提取
将 Payload 提取为单独的 JSON 文件,提高 CDN 缓存效率:
export default defineNuxtConfig({
experimental: {
payloadExtraction: true,
},
})构建后会生成 _payload.json 文件,可以被 CDN 缓存。
payloadExtraction: 'client'(v4.4+)
export default defineNuxtConfig({
experimental: {
payloadExtraction: 'client',
},
})| 模式 | HTML 中 | _payload.json | 首屏速度 | CDN 缓存 |
|---|---|---|---|---|
true | 引用外部文件 | ✅ 生成 | 需额外请求 | ✅ |
'client'(v4.4+) | 内联 Payload | ✅ 生成 | 更快(无额外请求) | ✅ |
false | 内联 | ❌ 不生成 | 快 | ❌ |
payloadExtraction: 'client' 的优势
- 初始 HTML 中内联完整 Payload → 首屏渲染不需要等待额外请求
- 同时生成
_payload.json→ 客户端导航时可以利用缓存 - 运行时 LRU 缓存 → 重复访问时从内存读取
推荐
如果你的站点使用 CDN 缓存(如 Cloudflare、EdgeOne),开启 payloadExtraction: 'client'。
手动操作 Payload
useNuxtApp —— 访问 Payload 数据
const nuxtApp = useNuxtApp()
// Payload 数据
nuxtApp.payload.data // { '/api/users': [...], 'app-config': {...} }
nuxtApp.payload.state // { counter: 42, sidebarOpen: false }
nuxtApp.payload.error // 全局错误
// 是否正在 Hydration
nuxtApp.isHydrating // boolean——首次加载时为 true
// 静态数据缓存(客户端导航后仍存在)
nuxtApp.static.data // 客户端缓存的数据nuxtApp.payload.data vs nuxtApp.static.data
payload.data:从服务端传递过来的数据(Hydration 时使用,之后可能被清除)static.data:客户端的持久缓存(页面导航后仍存在)
在 getCachedData 中同时检查两者
getCachedData(key, nuxtApp) {
return nuxtApp.payload.data[key] || nuxtApp.static.data[key]
}useHydration —— Hydration 生命周期
在 Hydration 过程中执行回调,适合需要在服务端和客户端之间传递非标准数据的场景:
const { data } = useHydration({
server() {
// 服务端执行——返回需要传递给客户端的数据
return { serverTime: Date.now() }
},
client(data) {
// 客户端 Hydration 时执行——接收服务端返回的 data
console.log('服务端时间:', data.serverTime)
},
})useHydration 的使用场景
- 在服务端计算某个值,传递给客户端使用(避免客户端重复计算)
- 传递服务端特有的信息(如请求头中的语言偏好)
INFO
️ 传递的数据也受序列化限制 不能包含函数、Symbol 等
Payload 大小优化
Payload 过大会影响首屏加载速度——因为 Payload 内嵌在 HTML 中,浏览器需要下载并解析。
问题诊断
// 在插件中打印 Payload 大小
export default defineNuxtPlugin((nuxtApp) => {
if (import.meta.client) {
const size = JSON.stringify(nuxtApp.payload).length
console.log(`Payload 大小: ${(size / 1024).toFixed(1)} KB`)
}
})INFO
️ Payload 大小建议
- < 50 KB:健康
- 50-200 KB:需要注意
200 KB:需要优化
优化策略
1. 使用 pick 只提取需要的字段
// ❌ 返回所有字段(包含 password、secret 等)
const { data } = await useFetch('/api/user/1')
// ✅ 只提取需要的字段
const { data } = await useFetch('/api/user/1', {
pick: ['id', 'name', 'email'],
})2. 使用 transform 精简数据
// ❌ 返回完整的嵌套结构
const { data } = await useFetch('/api/articles')
// ✅ 只提取列表需要的字段
const { data } = await useFetch('/api/articles', {
transform: (response) => response.items.map(a => ({
id: a.id,
title: a.title,
author: a.author.name, // 扁平化嵌套字段
})),
})3. 非关键数据使用 server: false
// 不需要 SEO 的数据,不在 SSR 时请求
const { data } = await useFetch('/api/recommendations', {
server: false, // 不放入 Payload
})4. 分页加载而非一次加载所有
// ❌ 加载所有数据
const { data } = await useFetch('/api/users')
// ✅ 分页加载
const page = ref(1)
const { data } = await useFetch('/api/users', {
query: { page, size: 20 },
watch: [page],
})安全注意事项
Payload 在客户端可见
Payload 嵌入在 HTML 中,任何人都可以通过"查看源代码"看到:
// ❌ 敏感信息放入 Payload
const { data } = await useFetch('/api/user/full')
// data 包含 password、token 等,都会暴露在 HTML 中
// ✅ 使用独立的 API 返回公开信息
const { data } = await useFetch('/api/user/profile')
// 只返回 name、email、avatar 等公开信息INFO
️ 安全规则
- 永远不要在 Payload 中包含密码、token、密钥等敏感信息
- API 路由应该区分公开和私有接口
- 使用
pick或transform过滤掉敏感字段 - 敏感操作(如管理后台)使用
server: false避免数据进入 Payload
避免在 Payload 中暴露 API 结构
// ❌ 直接暴露数据库结构
const { data } = await useFetch('/api/users')
// data 可能包含 id、createdAt、updatedAt、deletedAt 等内部字段
// ✅ 使用 transform 按需返回
const { data } = await useFetch('/api/users', {
transform: (users) => users.map(u => ({
name: u.name,
avatar: u.avatar,
})),
})避免常见问题
1. Hydration 不匹配
服务端和客户端的数据不一致:
// ❌ 服务端和客户端结果不同(如当前时间)
const { data } = await useFetch('/api/now')
// ✅ 方案 1:使用 server: false
const { data } = await useFetch('/api/now', { server: false })
// ✅ 方案 2:用 ClientOnly 包裹
<ClientOnly>
<CurrentTime />
</ClientOnly>Hydration 不匹配的根本原因
服务端渲染的 HTML 与客户端渲染的结果不同。常见原因:
- 使用了
Date.now()或Math.random() - 依赖了浏览器 API(
window.innerWidth) - 数据在服务端和客户端获取的结果不同
2. 数据过大导致首屏慢
// ❌ 返回太多数据
const { data } = await useFetch('/api/all-users') // 10 万用户
// ✅ 分页加载
const { data } = await useFetch('/api/users', {
query: { page: 1, size: 20 },
pick: ['id', 'name'],
})3. 类实例方法丢失
// ❌ 类实例的方法在反序列化后会丢失
class User {
constructor(public name: string) {}
greet() { return `Hello, ${this.name}` }
}
const { data } = await useFetch('/api/user', {
transform: (u) => new User(u.name), // ❌ greet() 方法在客户端会丢失
})
// ✅ 用普通对象 + 计算属性
const { data } = await useFetch('/api/user', {
transform: (u) => ({ name: u.name }), // 普通对象,可序列化
})
const greeting = computed(() => `Hello, ${data.value?.name}`)知识脉络
useFetch → useAsyncData → $fetch → 懒加载获取 → 数据获取选项 → 缓存与刷新
│
└─→ 你在这里:SSR 数据传递
│
├─→ 相关:核心概念 → 渲染模式(SSR 原理)
│
└─→ 相关:核心概念 → Nuxt 生命周期(Hydration 过程)