Skip to content

SSR 最佳实践

一、设计决策

本库不提供任何 SSR 专用 API。

经过对 Pinia、Angular TransferState、Nuxt useAsyncData 等方案的完整调研,最终结论如下:

  • 数据序列化与客户端恢复是外部框架(Nuxt)已经解决好的问题,本库无需重复实现
  • Nuxt 的 useAsyncData 内部封装了 onServerPrefetch + payload 机制,天然具备服务端序列化和客户端水合能力
  • 本库保持轻量,不引入 @Serializable@Persistsnapshot/restore 等额外 API

二、核心原则

请求间状态隔离

在 SSR 环境中,多个并发请求共享同一个 Node.js 进程。按以下规则声明服务,天然避免状态污染:

服务类型声明方式原因
有用户相关状态(购物车、用户信息等)declareAppProviders每个请求对应一个 App 实例,天然隔离
无用户相关的全局状态(统计数据、运行时间等)declareRootProviders进程级单例,所有请求共享,无用户污染风险
无状态工具类(HTTP client、logger 等)declareRootProviders无状态,共享安全

数据水合

需要 SSR 水合的数据,必须通过 Nuxt 的 useAsyncData 获取,然后手动赋值给 service 属性。本库不接管序列化和恢复过程。


三、最佳实践

场景一:组件级服务的异步数据(最常见)

服务类只负责持有状态,数据获取逻辑在组件中通过 useAsyncData 完成:

ts
// services/user.service.ts
@Injectable()
export class UserService {
  currentUser: User | null = null
}
vue
<!-- components/UserProfile.vue -->
<script setup>
  const userService = useService(UserService)

  // useAsyncData 自动处理:
  // 服务端:调用 fetchFn → 写入 payload → 渲染 HTML
  // 客户端:从 payload 恢复 → 不重新请求
  const { data } = await useAsyncData('current-user', () =>
    $fetch('/api/user/me')
  )

  userService.currentUser = data.value
</script>

为什么 useAsyncData 的 key 要唯一? key 是服务端和客户端数据对应的桥梁,相同 key 的调用在客户端会直接读取服务端缓存的值,不重新发请求。同一个数据在不同组件中重复调用时,使用相同 key 即可复用缓存。

场景二:同一服务类在多个组件中各有实例

列表场景下,每个 <ItemCard> 有独立的 ItemService 实例,key 需要携带标识符区分:

vue
<!-- components/ItemCard.vue -->
<script setup>
  const props = defineProps<{ itemId: string }>()

  declareProviders([ItemService])
  const itemService = useService(ItemService)

  // key 中携带 itemId,保证每个实例的 payload 互不干扰
  const { data } = await useAsyncData(`item-${props.itemId}`, () =>
    $fetch(`/api/items/${props.itemId}`)
  )

  itemService.detail = data.value
</script>

场景三:App 级服务(Nuxt 插件中初始化)

需要在整个 App 范围内共享的数据,在 Nuxt 插件里完成初始化:

ts
// plugins/app-init.ts
export default defineNuxtPlugin(async (nuxtApp) => {
  const app = nuxtApp.vueApp
  declareAppProviders([UserService, CartService], app)

  // 在插件中使用 useAsyncData 初始化 App 级服务
  // callWithNuxt 确保 useAsyncData 在正确的 Nuxt 上下文中执行
  const userService = useAppService(UserService, app)
  const { data } = await callWithNuxt(nuxtApp, () =>
    useAsyncData('app-user', () => $fetch('/api/user/me'))
  )
  userService.currentUser = data.value
})

场景四:Root 级全局服务(无用户状态)

全局统计数据等无用户隔离要求的数据,在根组件 app.vue 中初始化:

vue
<!-- app.vue -->
<script setup>
  // ROOT_CONTAINER 的服务可以直接用 useRootService 获取
  const statsService = useRootService(SiteStatsService)

  // app.vue 是组件树的根,useAsyncData 在此处调用覆盖整个应用
  const { data } = await useAsyncData('site-stats', () =>
    $fetch('/api/stats')
  )

  statsService.visitCount = data.value.visitCount
  statsService.uptime = data.value.uptime
</script>

四、不适用场景

以下场景 useAsyncData 无法覆盖,本库也不提供原生解决方案:

场景说明推荐处理方式
非 Nuxt 的 Vite SSRuseAsyncData 是 Nuxt 专属 API用户自行实现 onServerPrefetch + HTML 注入
Root 服务在 Nuxt 插件中异步初始化插件中 useAsyncData 需要 callWithNuxt 包裹,有一定心智成本参考场景三,或降级到 app.vue 中初始化
服务状态在多个异步操作后逐步形成useAsyncData 假设一次 fetch 就能得到完整状态合并为单次请求,或分拆为多个 useAsyncData 调用

五、关键约束

使用本库在 Nuxt SSR 项目中必须遵守以下约束,否则 hydration 无法正确工作:

  1. 需要 SSR 水合的数据,必须通过 useAsyncData(或 useFetch)获取,不能在 setup 中裸写 await fetch()
  2. useAsyncData 的 key 必须全局唯一,同类服务的多个实例需要在 key 中加入区分标识符
  3. 有用户相关状态的服务必须用 declareAppProviders,不能放入 declareRootProviders
  4. 服务类本身不需要实现任何 SSR 相关方法,保持纯粹的业务逻辑

六、与 Pinia SSR 的对比

维度Pinia + @pinia/nuxtuse-vue-service + Nuxt
框架提供的 SSR APIpinia.state.value 自动序列化无,依赖 useAsyncData
数据水合方式自动(插件统一处理)手动赋值给 service 属性
配置成本安装 @pinia/nuxt 插件即可无需额外配置
组件外数据更新的 SSR 支持✅ Pinia 插件统一处理⚠️ 需要用户自写 Nuxt 插件
多实例服务的 SSRN/A(store 是全局单例)通过 useAsyncData key 区分
服务类代码侵入性

Pinia 的 @pinia/nuxt 在自动化程度上高于本库的方案,代价是需要遵守"响应式状态必须放在 state 函数里"的规范。本库的方案更灵活,代价是 hydration 需要用户在组件层显式赋值。