第六章:SSR 与服务端集成
在前几章中,我们探讨了 Pinia 的核心机制和插件系统。现在,让我们深入了解 Pinia 在服务端渲染(SSR)环境中的应用,包括状态脱水(Dehydration)与注水(Rehydration)机制,以及如何避免 SSR 状态污染。
SSR 支持的挑战
在服务端渲染应用中,状态管理面临一个核心挑战:服务端是单例环境,而客户端是多例环境。如果处理不当,会导致用户状态泄露:
typescript
// 问题示例:SSR 中的状态污染
// 服务端是单例,所有请求共享同一个 store 实例
const userStore = useUserStore()
// 请求 A 设置用户信息
userStore.setUser({ id: 1, name: 'Alice' })
// 请求 B 在请求 A 完成前设置用户信息
userStore.setUser({ id: 2, name: 'Bob' })
// 请求 A 获取用户信息时,可能得到的是 Bob 的信息
const userA = userStore.user // 可能是 { id: 2, name: 'Bob' } 而不是 Alice问题:服务端渲染时,如何避免状态跨请求共享?
这个问题的核心在于 Pinia 实例的生命周期管理。在 SSR 环境中,我们需要为每个请求创建独立的 Pinia 实例:
typescript
// 错误的做法:复用 Pinia 实例
let pinia // 全局单例
export default {
async setup() {
if (!pinia) {
pinia = createPinia()
}
const userStore = useUserStore(pinia)
await userStore.fetchUser()
return { userStore }
}
}
// 正确的做法:为每个请求创建新的 Pinia 实例
export default {
async setup() {
// 为每个请求创建新的实例
const pinia = createPinia()
const userStore = useUserStore(pinia)
await userStore.fetchUser()
return { userStore }
}
}解决方案:createPinia() 返回的 pinia 实例必须是请求级别的
在 SSR 应用中,正确的做法是为每个请求创建独立的 Pinia 实例。这可以通过在请求处理流程中创建新的实例来实现:
typescript
// 服务端入口示例 (server.js)
import { createPinia } from 'pinia'
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'
export async function render(url, manifest) {
// 为每个请求创建新的 Pinia 实例
const pinia = createPinia()
// 创建应用实例
const { app } = createApp()
// 将 Pinia 实例安装到应用
app.use(pinia)
// 获取需要的 store 并预加载数据
const userStore = useUserStore(pinia)
await userStore.fetchUser()
// 渲染应用
const renderedHtml = await renderToString(app)
// 获取初始状态用于注水
const initialState = pinia.state.value
return {
html: renderedHtml,
initialState
}
}状态脱水(Dehydration):将服务端计算的 state 注入 HTML
状态脱水是指将服务端计算出的状态序列化并注入到 HTML 中,供客户端使用:
typescript
// 服务端渲染时的状态脱水
import { serialize } from 'v8'
export async function render(url) {
const pinia = createPinia()
const { app } = createApp()
app.use(pinia)
// 预加载数据
const userStore = useUserStore(pinia)
await userStore.fetchUser()
// 渲染应用
const html = await renderToString(app)
// 获取并序列化状态
const initialState = pinia.state.value
const serializedState = serialize(initialState)
// 将状态注入到 HTML 中
const htmlWithState = `
<html>
<head>
<!-- 其他头部内容 -->
</head>
<body>
<div id="app">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(serializedState)}
</script>
</body>
</html>
`
return htmlWithState
}更安全的序列化方式(防止 XSS 攻击):
typescript
// 安全的序列化方式
function serializeState(state) {
return JSON.stringify(state)
.replace(/</g, '\\u003c') // 防止 XSS
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/"/g, '\\u0022')
}
// 在服务端
const serializedState = serializeState(pinia.state.value)
const htmlWithState = `
<html>
<body>
<div id="app">${html}</div>
<script>
window.__INITIAL_STATE__ = ${serializedState}
</script>
</body>
</html>
`状态注水(Rehydration):客户端启动前,从 window.__PINIA__ 恢复状态
在客户端,我们需要在应用启动前从 window 对象中恢复服务端计算的状态:
typescript
// 客户端入口示例 (client.js)
import { createPinia } from 'pinia'
import { createApp } from './main'
export async function hydrate() {
// 创建 Pinia 实例
const pinia = createPinia()
// 从 window 对象恢复状态
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
pinia.state.value = JSON.parse(window.__INITIAL_STATE__)
}
// 创建应用实例
const { app } = createApp()
// 安装 Pinia
app.use(pinia)
// 挂载应用
app.mount('#app')
}完整的 SSR 实现示例
typescript
// main.js - 通用入口
import { createApp as vueCreateApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp(initialState) {
// 创建应用实例
const app = vueCreateApp(App)
// 创建 Pinia 实例
const pinia = createPinia()
// 如果提供了初始状态,则恢复状态
if (initialState) {
pinia.state.value = initialState
}
// 安装插件
app.use(pinia)
return { app, pinia }
}
// server.js - 服务端入口
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'
export async function render(url, manifest) {
// 为每个请求创建新的实例
const { app, pinia } = createApp()
// 预加载数据
const userStore = useUserStore(pinia)
if (url.startsWith('/user/')) {
await userStore.fetchUser()
}
// 渲染应用
const html = await renderToString(app)
// 状态脱水
const initialState = pinia.state.value
// 返回 HTML 和初始状态
return {
html,
initialState
}
}
// client.js - 客户端入口
import { createApp } from './main'
function sanitizeState(state) {
try {
return typeof state === 'string' ? JSON.parse(state) : state
} catch (e) {
console.error('Failed to parse initial state:', e)
return {}
}
}
// 从服务端获取初始状态
const initialState = typeof window !== 'undefined'
? sanitizeState(window.__INITIAL_STATE__)
: undefined
// 创建并启动应用
const { app } = createApp(initialState)
app.mount('#app')避免客户端重新请求数据
通过正确的 SSR 实现,我们可以避免客户端重复请求已经由服务端获取的数据:
typescript
// stores/user.js
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const loading = ref(false)
async function fetchUser(userId) {
// 如果已经有用户数据,避免重复请求
if (user.value && user.value.id === userId) {
return user.value
}
loading.value = true
try {
const userData = await api.getUser(userId)
user.value = userData
return userData
} finally {
loading.value = false
}
}
return { user, loading, fetchUser }
})
// 在组件中使用
export default {
async setup(props) {
const userStore = useUserStore()
// 在 SSR 中已经获取了数据,客户端不会再请求
await userStore.fetchUser(props.userId)
return { userStore }
}
}SSR 中的插件处理
在 SSR 环境中,某些插件可能需要特殊处理:
typescript
// SSR 安全的插件示例
function ssrSafePlugin(context) {
const { store } = context
// 仅在客户端添加某些功能
if (typeof window !== 'undefined') {
// 客户端特有的功能
return {
$localStorage: {
save() {
localStorage.setItem(`pinia_${store.$id}`, JSON.stringify(store.$state))
},
load() {
const data = localStorage.getItem(`pinia_${store.$id}`)
if (data) {
store.$patch(JSON.parse(data))
}
}
}
}
}
// 服务端返回空对象或服务端安全的功能
return {}
}小结
在本章中,我们深入探讨了 Pinia 在 SSR 环境中的应用:
- SSR 面临的核心挑战是状态跨请求共享问题
- 解决方案是为每个请求创建独立的 Pinia 实例
- 状态脱水(Dehydration)是将服务端状态序列化并注入 HTML
- 状态注水(Rehydration)是客户端从 HTML 中恢复状态
- 通过正确实现可以避免客户端重复请求数据
- 某些插件需要特殊处理以确保 SSR 安全
在下一章中,我们将探讨 Pinia 与 Vue3 生态的深度集成,包括 Devtools 集成、TypeScript 类型增强等内容。
思考题:
- 你在 SSR 项目中是否遇到过状态污染问题?是如何解决的?
- 对于大型应用,如何优化 SSR 状态序列化的性能?