Vue+Inertia SSR场景下如何实现Pinia状态从服务端到客户端同步?
Laravel 10 + Vue3 + Pinia SSR 状态同步问题
问题背景
我有一个基于Laravel 10+Vue3的网店项目,使用Inertia传递props、Ziggy处理路由、Pinia做状态管理、Vite编译资源,初始搭建用Laravel Breeze。现在正在实现SSR,目标是通过Pinia在服务端加载数据后序列化注入HTML,客户端直接复用状态避免重复API请求,但尝试多种方法均失败:
- 在ssr.js中请求Store数据不合理,因为依赖Layout组件的props,且希望组件按需发起API请求
- 组件内无法通过script标签定义window变量
- 用data存储序列化数据会在客户端加载时被重置
现有核心配置
vite.config.js
import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import vue from '@vitejs/plugin-vue'; import legacy from '@vitejs/plugin-legacy' export default defineConfig({ plugins: [ laravel({ input: 'resources/js/app.js', ssr: 'resources/js/ssr.js', refresh: true, }), vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false, }, }, }), legacy({ targets: ['defaults', '> 0.05%'], }), ], });
Layout.vue(核心逻辑)
<template> <header-component :pages="pages" :page="page" :page_details="page_details"></header-component> <main class="py-4"> <div class="container"> <template v-if="!isLoading && !errorContent" v-for="(view, view_index) in page.views"> <component v-if="view.template" v-bind:is="view.template.component" :view="view" :page_details="page_details" :key="`${componentKey}-${view_index}`"></component> </template> </div> </main> </template> <script> import axios from 'axios'; import {useMenuStore} from "../js/stores/menu.js"; export default { name: "Layout", props: ['page_details'], data() { return { page: [], errorContent: '', componentKey: 0, isLoading: true } }, methods: { async getPage() { let url = '/'+this.page_details.url_path; this.$i18n.locale = url.replace(/^\/([^\/]*)\.*$/, '$1'); let path_send = decodeURIComponent(url.replace('/'+this.$i18n.locale, '')); await axios.post(this.page_details.api_url +'/route/search', { lang: this.$i18n.locale, path: path_send }) .then((response) => { this.isLoading = false if (!response.data.page || !response.data.page.published) throw 404; this.page = response.data.page }).catch(() => { this.errorContent = '<article><div class="content"><h1>404 | Not found</h1></div></article>' }); }, async getMenu() { await this.menuStore.fetchMenu(this.page_details.api_url); }, }, async serverPrefetch() { await this.getPage() await this.getMenu() }, mounted() { this.getPage() this.getMenu() }, computed: { menuStore() { return useMenuStore(); }, pages() { return this.menuStore.pages; } } } </script>
解决方案步骤
1. 服务端收集Pinia状态并传递
修改ssr.js,每个请求创建独立的Pinia实例,渲染完成后收集状态并注入到Inertia的page props中:
import {createSSRApp, defineAsyncComponent, h} from 'vue'; import { renderToString } from '@vue/server-renderer'; import { createInertiaApp } from '@inertiajs/vue3'; import createServer from '@inertiajs/vue3/server'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m'; import i18n from "@/i18n.js"; import { createPinia } from 'pinia' // 每个请求创建新的Pinia实例,避免跨请求状态污染 createServer((page) => { const pinia = createPinia() return createInertiaApp({ page, render: async (app) => { const html = await renderToString(app); // 收集Pinia状态并添加到page props page.props.piniaState = pinia.state.value; return html; }, title: (title) => `${title}`, resolve: (name) => resolvePageComponent(`../vue/${name}.vue`, import.meta.glob('../vue/**/*.vue')), setup({ App, props, plugin }) { return createSSRApp({ render: () => h(App, props) }) .use(plugin) .use(ZiggyVue, { ...page.props.ziggy, location: new URL(page.props.ziggy.location), }) .component('HeaderComponent', require('../vue/HeaderComponent.vue').default) .component('LoadingComponent', require('../vue/LoadingComponent.vue').default) .component('ErrorComponent', require('../vue/ErrorComponent.vue').default) .component('FooterComponent', defineAsyncComponent(() => import("../vue/FooterComponent.vue"))) .component('Text', defineAsyncComponent(() => import("../vue/components/PlainText.vue"))) .use(pinia) .use(i18n); }, }) });
2. 在Blade模板中注入Pinia状态到全局
修改app.blade.php,将服务端传递的Pinia状态注入为window全局变量:
<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> @routes @vite(['resources/js/app.js', "resources/vue/{$page['component']}.vue"]) @inertiaHead </head> <body> @inertia <script> // 注入服务端Pinia状态到客户端全局 window.__PINIA_STATE__ = {{ json_encode($page['props']['piniaState'] ?? []) }}; </script> </body> </html>
3. 客户端恢复Pinia状态
修改app.js,创建Pinia实例后直接恢复服务端传递的状态:
import './bootstrap'; import '../scss/app.scss'; import { createApp, h } from 'vue'; import { createInertiaApp } from '@inertiajs/vue3'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { defineAsyncComponent } from "vue"; import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m'; import i18n from "@/i18n.js"; import { createPinia } from 'pinia' const pinia = createPinia() // 恢复服务端传递的Pinia状态 if (typeof window !== 'undefined' && window.__PINIA_STATE__) { pinia.state.value = window.__PINIA_STATE__; } // 组件注册逻辑不变 const createAsyncComponent = (loader) => ({ loader, loadingComponent: require('../vue/LoadingComponent.vue').default, errorComponent: require('../vue/ErrorComponent.vue').default, delay: 0, timeout: 3000, }); const FooterComponent = defineAsyncComponent(createAsyncComponent(() => import("../vue/FooterComponent.vue"))); const PlainText = defineAsyncComponent(createAsyncComponent(() => import("../vue/components/PlainText.vue"))); createInertiaApp({ title: (title) => `${title}`, resolve: (name) => resolvePageComponent(`../vue/${name}.vue`, import.meta.glob('../vue/**/*.vue')), setup({ el, App, props, plugin }) { return createApp({ render: () => h(App, props) }) .use(plugin) .use(ZiggyVue, Ziggy) .use(i18n) .component('HeaderComponent', require('../vue/HeaderComponent.vue').default) .component('LoadingComponent', require('../vue/LoadingComponent.vue').default) .component('ErrorComponent', require('../vue/ErrorComponent.vue').default) .component('FooterComponent', FooterComponent) .component('Text', PlainText) .use(pinia) .mount(el); }, progress: { color: '#4B5563', }, });
4. 修改Layout.vue避免重复请求
在mounted钩子中判断数据是否已存在,避免客户端重复发起API请求:
<script> // ... 其余代码不变 mounted() { // 仅当服务端未加载page数据时,客户端才请求 if (typeof window !== 'undefined' && !this.page.length) { this.getPage() } // 仅当Pinia中无菜单数据时才请求 if (this.menuStore.pages.length === 0) { this.getMenu() } }, // ... 其余代码不变 </script>
关键注意事项
- Pinia实例隔离:服务端必须为每个请求创建独立的Pinia实例,绝对不能复用全局实例,否则会导致跨请求状态污染
- 状态序列化:确保Pinia状态仅包含可JSON序列化的数据,不要存储函数、循环引用等无法序列化的内容
- 重复请求拦截:客户端必须先判断状态是否存在,再决定是否发起API请求
内容的提问来源于stack exchange,提问作者Joy-Kevin
相关产品推荐
相关产品推荐

