SSR 最佳实践
一、设计决策
本库不提供任何 SSR 专用 API。
经过对 Pinia、Angular TransferState、Nuxt useAsyncData 等方案的完整调研,最终结论如下:
- 数据序列化与客户端恢复是外部框架(Nuxt)已经解决好的问题,本库无需重复实现
- Nuxt 的
useAsyncData内部封装了onServerPrefetch+ payload 机制,天然具备服务端序列化和客户端水合能力 - 本库保持轻量,不引入
@Serializable、@Persist、snapshot/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 SSR | useAsyncData 是 Nuxt 专属 API | 用户自行实现 onServerPrefetch + HTML 注入 |
| Root 服务在 Nuxt 插件中异步初始化 | 插件中 useAsyncData 需要 callWithNuxt 包裹,有一定心智成本 | 参考场景三,或降级到 app.vue 中初始化 |
| 服务状态在多个异步操作后逐步形成 | useAsyncData 假设一次 fetch 就能得到完整状态 | 合并为单次请求,或分拆为多个 useAsyncData 调用 |
五、关键约束
使用本库在 Nuxt SSR 项目中必须遵守以下约束,否则 hydration 无法正确工作:
- 需要 SSR 水合的数据,必须通过
useAsyncData(或useFetch)获取,不能在 setup 中裸写await fetch() useAsyncData的 key 必须全局唯一,同类服务的多个实例需要在 key 中加入区分标识符- 有用户相关状态的服务必须用
declareAppProviders,不能放入declareRootProviders - 服务类本身不需要实现任何 SSR 相关方法,保持纯粹的业务逻辑
六、与 Pinia SSR 的对比
| 维度 | Pinia + @pinia/nuxt | use-vue-service + Nuxt |
|---|---|---|
| 框架提供的 SSR API | pinia.state.value 自动序列化 | 无,依赖 useAsyncData |
| 数据水合方式 | 自动(插件统一处理) | 手动赋值给 service 属性 |
| 配置成本 | 安装 @pinia/nuxt 插件即可 | 无需额外配置 |
| 组件外数据更新的 SSR 支持 | ✅ Pinia 插件统一处理 | ⚠️ 需要用户自写 Nuxt 插件 |
| 多实例服务的 SSR | N/A(store 是全局单例) | 通过 useAsyncData key 区分 |
| 服务类代码侵入性 | 无 | 无 |
Pinia 的 @pinia/nuxt 在自动化程度上高于本库的方案,代价是需要遵守"响应式状态必须放在 state 函数里"的规范。本库的方案更灵活,代价是 hydration 需要用户在组件层显式赋值。