Skip to content

SSR 数据传递

了解 Nuxt 如何在服务端和客户端之间传递数据。这是 SSR 应用最核心的机制——服务端渲染的 HTML 需要数据,但客户端 Hydration 时不能重复请求,否则会闪烁和性能浪费。

Payload 机制——SSR 数据传递的核心

SSR 时,服务端获取的数据通过 Payload 传递给客户端,避免重复请求。Payload 是嵌入在 HTML 中的 JavaScript 对象。

工作流程

text
服务端                              客户端
━━━━                                ━━━━

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> 标签:

html
<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 类型:

支持的类型

类型示例说明
✅ 基本类型stringnumberboolean完美支持
✅ 对象和数组{ name: 'Alice' }[1, 2, 3]完美支持
✅ Date 对象new Date()自动还原为 Date 对象
✅ Map / Setnew Map()new Set()自动还原
✅ undefinedundefinedJSON.stringify 会丢失,devalue 保留
✅ 正则表达式/pattern/g自动还原
✅ InfinityInfinity-InfinityJSON.stringify 会变成 null
✅ BigIntBigInt(9007199254740991)自动还原
❌ 函数() => {}会丢失
❌ SymbolSymbol('foo')会丢失
❌ DOM 元素document.body会丢失
❌ 类实例new MyClass()方法丢失,只保留属性

INFO

最常见的问题:在 transform 中返回了包含函数或类实例的对象 导致客户端反序列化后丢失

ts
// ❌ 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 中把自定义类型转为可序列化的普通对象:

ts
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:使用插件自定义序列化

如果需要在整个应用中处理自定义类型的序列化:

ts
// app/plugins/payload.ts
export default defineNuxtPlugin((nuxtApp) => {
  // 自定义还原函数
  if (import.meta.client) {
    nuxtApp.hook('app:created', () => {
      // 在 Payload 数据还原后处理
    })
  }
})

Payload 提取

将 Payload 提取为单独的 JSON 文件,提高 CDN 缓存效率:

ts
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: true,
  },
})

构建后会生成 _payload.json 文件,可以被 CDN 缓存。

payloadExtraction: 'client'(v4.4+)

ts
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: 'client',
  },
})
模式HTML 中_payload.json首屏速度CDN 缓存
true引用外部文件✅ 生成需额外请求
'client'(v4.4+)内联 Payload✅ 生成更快(无额外请求)
false内联❌ 不生成

payloadExtraction: 'client' 的优势

  1. 初始 HTML 中内联完整 Payload → 首屏渲染不需要等待额外请求
  2. 同时生成 _payload.json → 客户端导航时可以利用缓存
  3. 运行时 LRU 缓存 → 重复访问时从内存读取

推荐

如果你的站点使用 CDN 缓存(如 Cloudflare、EdgeOne),开启 payloadExtraction: 'client'

手动操作 Payload

useNuxtApp —— 访问 Payload 数据

ts
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 中同时检查两者

ts
getCachedData(key, nuxtApp) {
return nuxtApp.payload.data[key] || nuxtApp.static.data[key]
}

useHydration —— Hydration 生命周期

在 Hydration 过程中执行回调,适合需要在服务端和客户端之间传递非标准数据的场景:

ts
const { data } = useHydration({
  server() {
    // 服务端执行——返回需要传递给客户端的数据
    return { serverTime: Date.now() }
  },
  client(data) {
    // 客户端 Hydration 时执行——接收服务端返回的 data
    console.log('服务端时间:', data.serverTime)
  },
})

useHydration 的使用场景

  • 在服务端计算某个值,传递给客户端使用(避免客户端重复计算)
  • 传递服务端特有的信息(如请求头中的语言偏好)

INFO

传递的数据也受序列化限制 不能包含函数、Symbol 等

Payload 大小优化

Payload 过大会影响首屏加载速度——因为 Payload 内嵌在 HTML 中,浏览器需要下载并解析。

问题诊断

ts
// 在插件中打印 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 只提取需要的字段

ts
// ❌ 返回所有字段(包含 password、secret 等)
const { data } = await useFetch('/api/user/1')

// ✅ 只提取需要的字段
const { data } = await useFetch('/api/user/1', {
  pick: ['id', 'name', 'email'],
})

2. 使用 transform 精简数据

ts
// ❌ 返回完整的嵌套结构
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

ts
// 不需要 SEO 的数据,不在 SSR 时请求
const { data } = await useFetch('/api/recommendations', {
  server: false,  // 不放入 Payload
})

4. 分页加载而非一次加载所有

ts
// ❌ 加载所有数据
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 中,任何人都可以通过"查看源代码"看到:

ts
// ❌ 敏感信息放入 Payload
const { data } = await useFetch('/api/user/full')
// data 包含 password、token 等,都会暴露在 HTML 中

// ✅ 使用独立的 API 返回公开信息
const { data } = await useFetch('/api/user/profile')
// 只返回 name、email、avatar 等公开信息

INFO

安全规则

  1. 永远不要在 Payload 中包含密码、token、密钥等敏感信息
  2. API 路由应该区分公开和私有接口
  3. 使用 picktransform 过滤掉敏感字段
  4. 敏感操作(如管理后台)使用 server: false 避免数据进入 Payload

避免在 Payload 中暴露 API 结构

ts
// ❌ 直接暴露数据库结构
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 不匹配

服务端和客户端的数据不一致:

ts
// ❌ 服务端和客户端结果不同(如当前时间)
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. 数据过大导致首屏慢

ts
// ❌ 返回太多数据
const { data } = await useFetch('/api/all-users')  // 10 万用户

// ✅ 分页加载
const { data } = await useFetch('/api/users', {
  query: { page: 1, size: 20 },
  pick: ['id', 'name'],
})

3. 类实例方法丢失

ts
// ❌ 类实例的方法在反序列化后会丢失
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}`)

知识脉络

text
useFetch → useAsyncData → $fetch → 懒加载获取 → 数据获取选项 → 缓存与刷新

  └─→ 你在这里:SSR 数据传递

        ├─→ 相关:核心概念 → 渲染模式(SSR 原理)

        └─→ 相关:核心概念 → Nuxt 生命周期(Hydration 过程)

基于 Nuxt 4 官方文档整理编写