You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.05 04:02:35