Nuxt: 状态管理 Pinia
最后更新:2026-08-26
Alice 把商品加入购物车后,切换到另一个页面购物车就空了——每个页面都有独立的组件状态。Bob 发现用户登录状态在 SSR 和客户端之间不一致。Charlie 需要 Pinia 来管理跨组件、跨页面的共享状态。
1. 你将学到
- @pinia/nuxt 模块安装与自动配置
- Store 定义:选项式与 Setup 语法
- SSR 状态脱水与水合机制
- StoreToRefs 响应式解构与 $patch/$reset
- MegaShop 购物车增删改查 + 总价计算
2. 一个消费者的真实故事
(1) 痛点:购物车状态"丢失"
Alice 在 MegaShop 首页加了 3 件商品到购物车,点击进入商品详情页后,购物车图标显示 0 件——状态没共享。Bob 也遇到类似问题:登录后刷新页面,登录状态消失了。
(2) Pinia 状态管理的解法
Pinia 让购物车和用户状态脱离组件生命周期,成为全局共享的响应式数据:
TYPESCRIPT
// composables/useCartStore.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.quantity, 0))
return { items, total }
})
(3) 收益:状态跨页面持久
Alice 的购物车在所有页面保持一致,Bob 的登录状态 SSR/客户端同步,不再出现状态丢失问题。
3. Pinia 安装与配置
(1) 安装步骤
BASH
npm install @pinia/nuxt pinia
▶ 示例:nuxt.config.ts 配置
TYPESCRIPT
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
// Pinia is auto-configured, no extra setup needed
})
输出:
TEXT
📖 仅展示
// 执行成功
(2) Pinia vs Vuex vs useState 对比
| 维度 | Pinia | Vuex 4 | Nuxt useState |
|---|---|---|---|
| Vue 3 支持 | ✅ 原生 | ⚠️ 兼容模式 | ✅ Nuxt 专属 |
| TypeScript | ✅ 完整推导 | ❌ 需手动声明 | ✅ 泛型支持 |
| SSR 水合 | ✅ 自动 | ⚠️ 需配置 | ✅ 自动 |
| 代码分割 | ✅ 按需 | ❌ 全局 | ❌ 全局 |
| DevTools | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
| 体积 | ~1KB | ~6KB | 内置 |
| 适用场景 | 复杂状态 | 遗留迁移 | 简单共享 |
4. Store 定义方式
(1) 选项式 Store
▶ 示例:选项式购物车 Store
TYPESCRIPT
// stores/cart.ts
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
couponCode: '' as string
}),
getters: {
totalItems: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: (state) => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
formattedTotal(): string {
return new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(this.totalPrice)
}
},
actions: {
addItem(product: Product) {
const existing = this.items.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
this.items.push({ ...product, quantity: 1 })
}
},
removeItem(productId: number) {
this.items = this.items.filter(i => i.id !== productId)
},
updateQuantity(productId: number, quantity: number) {
const item = this.items.find(i => i.id === productId)
if (item) item.quantity = Math.max(0, quantity)
this.items = this.items.filter(i => i.quantity > 0)
},
clearCart() {
this.items = []
this.couponCode = ''
}
}
})
interface CartItem {
id: number; name: string; price: number; quantity: number; image: string
}
interface Product {
id: number; name: string; price: number; image: string
}
输出:
TEXT
📖 仅展示
// 执行成功
(2) Setup 语法 Store
▶ 示例:Setup 语法用户 Store
TYPESCRIPT
// stores/user.ts
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
const fullName = computed(() => user.value ? `${user.value.firstName} ${user.value.lastName}` : '')
// Actions
async function login(email: string, password: string) {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = response.user
}
function logout() {
user.value = null
}
async function fetchProfile() {
const profile = await $fetch('/api/user/profile')
user.value = profile
}
return {
user, isAuthenticated, fullName,
login, logout, fetchProfile
}
})
interface User {
id: number; email: string; firstName: string; lastName: string; role: 'customer' | 'admin'
}
输出:
TEXT
📖 仅展示
// 执行成功
(3) 两种方式对比
| 维度 | 选项式 | Setup 语法 |
|---|---|---|
| 语法 | state/getters/actions | ref/computed/function |
| TypeScript | ⚠️ 需声明接口 | ✅ 自动推导 |
| 灵活性 | ⚠️ 受限 | ✅ 可用任何 Composable |
| SSR | ✅ 支持 $reset | ⚠️ 无 $reset(需自行实现) |
| 适用场景 | 简单 Store | 复杂 Store/需 Composable |
5. SSR 脱水与水合
(1) Pinia SSR 状态流转
flowchart LR
A[Server: Store filled with data] --> B[Serialize state to HTML payload]
B --> C[Client: Read payload]
C --> D[Hydrate Store with server state]
D --> E[Client: Store ready, no re-fetch]
▶ 示例:SSR 中初始化 Store
TYPESCRIPT
// plugins/init-pinia.server.ts
export default defineNuxtPlugin(() => {
const cartStore = useCartStore()
// Initialize cart from cookie on server
const cartCookie = useCookie('cart-items')
if (cartCookie.value) {
cartStore.items = cartCookie.value
}
})
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例:Pinia vs useState SSR 差异
| 维度 | Pinia | useState |
|---|---|---|
| SSR 水合 | ✅ 自动 payload | ✅ 自动 payload |
| 多 Store | ✅ 独立命名空间 | ⚠️ 手动 key 管理 |
| DevTools | ✅ 可视化 | ❌ 无 |
| 持久化 | ✅ 插件支持 | ⚠️ 需手动 cookie |
| 复杂逻辑 | ✅ actions/getters | ❌ 只有 ref |
6. StoreToRefs 与批量操作
▶ 示例:StoreToRefs 响应式解构
VUE
<script setup lang="ts">
const cartStore = useCartStore()
// ✅ Reactive destructure - keeps reactivity
const { items, totalPrice, totalItems } = storeToRefs(cartStore)
// ❌ Direct destructure - loses reactivity
// const { items, totalPrice } = cartStore
// Actions can be destructured directly (no reactivity needed)
const { addItem, removeItem, clearCart } = cartStore
</script>
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例:$patch 批量更新
TYPESCRIPT
// Batch update with $patch - single reactivity trigger
const cartStore = useCartStore()
// Object style
cartStore.$patch({
couponCode: 'SAVE20',
items: [...cartStore.items, newItem]
})
// Function style (better for array mutations)
cartStore.$patch((state) => {
state.couponCode = 'SAVE20'
state.items.push(newItem)
state.items[0].quantity = 3
})
输出:
TEXT
📖 仅展示
// 执行成功
▶ 示例:购物车持久化插件
TYPESCRIPT
// plugins/pinia-persist.client.ts
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(({ store }) => {
// Load from localStorage on client
const saved = localStorage.getItem(`pinia-${store.$id}`)
if (saved) store.$patch(JSON.parse(saved))
// Save to localStorage on change
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
})
})
输出:
TEXT
📖 仅展示
// 执行成功
7. 综合示例:MegaShop 购物车系统
VUE
<!-- pages/cart.vue -->
<template>
<div class="cart-page">
<h1>Shopping Cart</h1>
<div v-if="items.length === 0" class="empty-cart">
<p>Your cart is empty</p>
<NuxtLink to="/products">Continue Shopping</NuxtLink>
</div>
<div v-else>
<div class="cart-items">
<div v-for="item in items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" />
<div class="details">
<h3>{{ item.name }}</h3>
<p>${{ item.price }} USD</p>
<div class="quantity">
<button @click="updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
</div>
<button @click="removeItem(item.id)" class="remove">Remove</button>
</div>
</div>
<div class="cart-summary">
<p>Items: {{ totalItems }}</p>
<p class="total">Total: {{ formattedTotal }}</p>
<button @click="clearCart" class="clear">Clear Cart</button>
<NuxtLink to="/checkout" class="checkout">Proceed to Checkout</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const cartStore = useCartStore()
const { items, totalItems, formattedTotal } = storeToRefs(cartStore)
const { removeItem, updateQuantity, clearCart } = cartStore
</script>
❓ 常见问题
Q Pinia Store 和 Composable 怎么选?
A 需要跨组件共享状态用 Store,组件内逻辑复用用 Composable。购物车/用户状态用 Store,价格格式化用 Composable。
Q 为什么解构 Store 会丢失响应式?
A 直接解构拿到的是值的拷贝,不是 ref。用 storeToRefs 解构保持响应式,actions 可以直接解构(函数不需要响应式)。
Q SSR 时 Store 数据客户端不一致怎么办?
A 确保不在 onMounted 之前修改 Store(SSR 阶段修改的值会被水合覆盖)。Pinia 的 SSR 水合是自动的,通常不需要手动处理。
Q Pinia 的 $reset 在 Setup 语法中不可用?
A Setup 语法没有 $reset。可以手动实现:定义 $reset action,或在 $patch 中重置所有 ref。
Q 多个页面同时修改 Store 会冲突吗?
A 客户端是单线程的,不会冲突。SSR 时每个请求有独立的 Pinia 实例,也不会冲突。
Q 购物车数据怎么在刷新后保留?
A 使用持久化插件(存 localStorage)或同步到 cookie。MegaShop 推荐用 cookie 同步,SSR 时也能读取。
📖 小节
- Pinia 是 Vue 3 官方状态管理方案,@pinia/nuxt 模块自动集成
- 选项式 Store 结构清晰(state/getters/actions),Setup 语法更灵活
- SSR 水合自动完成:服务端 Store 数据 → payload → 客户端 Store
- storeToRefs 解构保持响应式,$patch 批量更新只触发一次渲染
- MegaShop 用 Pinia 管理购物车/用户状态,配合 cookie 实现持久化
📝 作业
- 基础题(难度⭐):创建 useCartStore,实现 addItem 和 removeItem,在两个不同页面上操作购物车并验证状态共享
- 进阶题(难度⭐⭐):用 Setup 语法定义 useUserStore,实现 login/logout/fetchProfile,验证 SSR 水合是否正常
- 挑战题(难度⭐⭐⭐):实现购物车持久化(刷新后数据保留),对比 localStorage 插件与 cookie 同步两种方案的优劣
---|