Skip to content

第六章: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 环境中的应用:

  1. SSR 面临的核心挑战是状态跨请求共享问题
  2. 解决方案是为每个请求创建独立的 Pinia 实例
  3. 状态脱水(Dehydration)是将服务端状态序列化并注入 HTML
  4. 状态注水(Rehydration)是客户端从 HTML 中恢复状态
  5. 通过正确实现可以避免客户端重复请求数据
  6. 某些插件需要特殊处理以确保 SSR 安全

在下一章中,我们将探讨 Pinia 与 Vue3 生态的深度集成,包括 Devtools 集成、TypeScript 类型增强等内容。


思考题

  1. 你在 SSR 项目中是否遇到过状态污染问题?是如何解决的?
  2. 对于大型应用,如何优化 SSR 状态序列化的性能?