{T}

vite-plugin-ssr 实战集成

概述

本文以 Pinia 状态管理为例,讲解 vite-plugin-ssr 项目中的状态集成:服务端创建 Store、渲染后序列化状态、通过 passToClient 传递、客户端恢复状态,完成 SSR 状态闭环。

学习目标

  • 掌握 vite-plugin-ssr 项目中 Pinia 的集成方式
  • 理解 SSR 状态传递的完整链路(服务端 → HTML → 客户端)
  • 学会配置 passToClient 避免状态丢失
  • 掌握 Layout 布局与页面组合的实践模式

一、Pinia Store 定义

1.1 创建 Store

typescript
// stores/useCounter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
  },
  getters: {
    doubleCount: (state) => state.count * 2,
  },
})

1.2 组件中使用

Vue SFC
<!-- components/Counter.vue -->
<template>
  <div>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <button @click="counter.increment()">+1</button>
    <button @click="counter.decrement()">-1</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/useCounter'

const counter = useCounterStore()
</script>

二、SSR 状态传递

2.1 问题:状态丢失

Pinia 状态在服务端创建并渲染进 HTML,但客户端 Hydration 时重新初始化 Store,导致:

  • 计数器归零,与服务端 HTML 不一致
  • 触发 Hydration Mismatch 警告
  • 错误提示:initial store state is missing in the passToClient list

2.2 服务端:序列化状态

javascript
// renderer/_default.page.server.js
import { renderToString } from 'vue/server-renderer'
import { escapeInject } from 'vite-plugin-ssr/server'
import { createPinia } from 'pinia'
import { createApp } from './app'

// 声明需要传递到客户端的 pageContext 属性
export const passToClient = ['initialState', 'pageProps']

export { render }

async function render(pageContext) {
  const pinia = createPinia()
  const app = createApp(pageContext)
  app.use(pinia)

  const pageHtml = await renderToString(app)

  return {
    documentHtml: escapeInject`<!DOCTYPE html>
      <html>
        <body>
          <div id="app">${pageHtml}</div>
        </body>
      </html>`,
    pageContext: {
      // 渲染完成后,Pinia 中已有服务端数据
      initialState: pinia.state.value,
    },
  }
}

2.3 客户端:恢复状态

javascript
// renderer/_default.page.client.js
import { createPinia } from 'pinia'
import { createApp } from './app'

export { onRenderClient }

async function onRenderClient(pageContext) {
  const pinia = createPinia()

  // 恢复服务端序列化的状态
  if (pageContext.initialState) {
    pinia.state.value = pageContext.initialState
  }

  const app = createApp(pageContext)
  app.use(pinia)
  app.mount('#app')
}

2.4 状态传递链路

图表渲染中…

三、应用工厂

3.1 共享入口

typescript
// renderer/app.ts
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import App from './App.vue'

export function createApp(pageContext: { Page: any; pageProps?: any }) {
  const app = createSSRApp(App)
  const router = createRouter()

  app.use(router)
  app.provide('pageContext', pageContext)

  return { app, router }
}

3.2 根组件

Vue SFC
<!-- renderer/App.vue -->
<template>
  <Layout>
    <Page />
  </Layout>
</template>

<script setup lang="ts">
import { inject, computed } from 'vue'
import Layout from './Layout.vue'

const pageContext = inject('pageContext') as any
const Page = computed(() => pageContext.Page)
</script>

四、数据获取实践

4.1 页面级 data()

typescript
// pages/index.page.server.ts
import { useCounterStore } from '@/stores/useCounter'

export { data }

async function data(pageContext: any) {
  // 服务端数据预取
  const articles = await fetchArticles()
  return { articles }
}

4.2 页面组件消费

Vue SFC
<!-- pages/index.page.vue -->
<template>
  <div>
    <h1>首页</h1>
    <Counter />
    <ul>
      <li v-for="article in articles" :key="article.id">
        {{ article.title }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import Counter from '@/components/Counter.vue'

defineProps<{ articles: any[] }>()
</script>

五、项目脚本

json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build && vite build --ssr",
    "preview": "vite preview"
  }
}
命令说明
vite开发模式,SSR + HMR
vite build构建客户端 bundle
vite build --ssr构建服务端 bundle

常见问题

Q: 为什么 passToClient 不默认传递所有数据?

安全设计。服务端 pageContext 可能包含数据库连接、API 密钥等敏感信息,默认不传递可防止意外泄漏到浏览器。只显式声明客户端需要的属性。

Q: Pinia 状态恢复必须在 mount 之前吗?

是的。必须在 app.mount() 之前执行 pinia.state.value = initialState,否则组件初始化时读到的是空状态,导致 Hydration Mismatch。

Q: 如何处理客户端导航后的数据更新?

客户端导航不经过服务端 render,需在路由守卫或组件 onMounted 中发起客户端数据请求,或使用 Vike 的 onBeforeRender 客户端钩子。


延伸阅读