状态管理
# 05.状态管理
# 目录介绍
# 5.1 状态管理概述
# 5.1.1 为什么需要状态管理
当多个组件需要共享状态时,Props层层传递变得笨重,状态管理提供集中化的解决方案:
没有状态管理(Props 钻透):
App → Layout → Sidebar → UserInfo (逐层传递 user 数据)
App → Layout → Content → Header (又要逐层传递 user 数据)
使用状态管理(Pinia):
┌──────────────────┐
│ Pinia Store │ ← 集中管理状态
│ { user, cart } │
└──────────────────┘
↕ ↕
UserInfo Header ← 任何组件直接读写,无需Props传递
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
适合使用状态管理的场景:
- 用户登录信息(多个页面需要)
- 购物车数据(多个组件需要读写)
- 全局配置(主题、语言、权限)
- 多组件共享的列表数据
# 5.1.2 Pinia介绍
Pinia 是 Vue 官方推荐的状态管理库,是 Vuex 的继任者:
| 对比项 | Pinia | Vuex |
|---|---|---|
| 版本适配 | Vue 2/3 | Vue 2(Vuex3)/3(Vuex4) |
| TypeScript | 完善支持 | 支持较弱 |
| Mutation | 无(直接修改State) | 必须通过Mutation |
| 模块化 | 天然模块化(多Store) | 需要手动配置module |
| 体积 | ~1KB | ~10KB |
| DevTools | 完善支持 | 完善支持 |
# 5.1.3 安装和配置Pinia
npm install pinia
1
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia()) // 注册 Pinia
app.mount('#app')
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
# 5.1.4 综合案例与思考
思考题:
- 什么时候应该使用 Pinia,什么时候用组件本地状态就够了?
- Pinia 为什么去掉了 Vuex 的 Mutation?直接修改State不会有问题吗?
- Pinia 的"天然模块化"是什么意思?和 Vuex 的 module 有什么区别?
# 5.2 定义Store
# 5.2.1 Setup Store写法
类似组件的 <script setup>,使用组合式API风格定义 Store(推荐):
// src/stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// ref() → state
const count = ref(0)
const name = ref('计数器')
// computed() → getters
const doubleCount = computed(() => count.value * 2)
const displayText = computed(() => `${name.value}: ${count.value}`)
// function → actions
function increment() {
count.value++
}
function decrement() {
if (count.value > 0) count.value--
}
async function incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
count.value++
}
// 必须返回所有需要暴露的状态和方法
return { count, name, doubleCount, displayText, increment, decrement, incrementAsync }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 5.2.2 Option Store写法
类似 Vue 2 的选项式API:
// src/stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state:返回初始状态的函数
state: () => ({
count: 0,
name: '计数器'
}),
// getters:类似计算属性
getters: {
doubleCount: (state) => state.count * 2,
displayText(): string {
return `${this.name}: ${this.count}` // 可以用 this 访问
}
},
// actions:方法(可以是异步的)
actions: {
increment() {
this.count++ // 通过 this 访问 state
},
async fetchData() {
const response = await fetch('/api/count')
this.count = await response.json()
}
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 5.2.3 State状态
// Setup Store 中的 State
export const useUserStore = defineStore('user', () => {
// 基本类型
const name = ref('')
const age = ref(0)
const isLoggedIn = ref(false)
// 复杂类型
const profile = ref<{
avatar: string
email: string
roles: string[]
} | null>(null)
// 数组
const permissions = ref<string[]>([])
// 重置状态
function reset() {
name.value = ''
age.value = 0
isLoggedIn.value = false
profile.value = null
permissions.value = []
}
return { name, age, isLoggedIn, profile, permissions, reset }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 5.2.4 Getters计算属性
export const useCartStore = defineStore('cart', () => {
const items = ref<Array<{ id: number; name: string; price: number; quantity: number }>>([])
// 基础 getter
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
// 依赖其他 getter
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const shipping = computed(() => subtotal.value >= 99 ? 0 : 10)
const total = computed(() => subtotal.value + shipping.value)
// 返回函数的 getter(接受参数)
const getItemById = computed(() => {
return (id: number) => items.value.find(item => item.id === id)
})
return { items, totalItems, subtotal, shipping, total, getItemById }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 5.2.5 Actions方法
export const useAuthStore = defineStore('auth', () => {
const token = ref('')
const user = ref<any>(null)
const loading = ref(false)
const error = ref('')
const isLoggedIn = computed(() => !!token.value)
// 同步 action
function setToken(newToken: string) {
token.value = newToken
localStorage.setItem('token', newToken)
}
// 异步 action
async function login(username: string, password: string) {
loading.value = true
error.value = ''
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
if (!response.ok) throw new Error('登录失败')
const data = await response.json()
token.value = data.token
user.value = data.user
localStorage.setItem('token', data.token)
} catch (e: any) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
}
return { token, user, loading, error, isLoggedIn, setToken, login, logout }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 5.2.6 综合案例与思考
综合案例:完整的待办事项Store
// stores/todo.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
interface Todo {
id: number
text: string
done: boolean
createdAt: Date
}
export const useTodoStore = defineStore('todo', () => {
const todos = ref<Todo[]>([])
const filter = ref<'all' | 'active' | 'done'>('all')
let nextId = 1
// Getters
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active': return todos.value.filter(t => !t.done)
case 'done': return todos.value.filter(t => t.done)
default: return todos.value
}
})
const stats = computed(() => ({
total: todos.value.length,
active: todos.value.filter(t => !t.done).length,
done: todos.value.filter(t => t.done).length
}))
// Actions
function addTodo(text: string) {
todos.value.push({
id: nextId++,
text: text.trim(),
done: false,
createdAt: new Date()
})
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
function setFilter(newFilter: 'all' | 'active' | 'done') {
filter.value = newFilter
}
return {
todos, filter, filteredTodos, stats,
addTodo, toggleTodo, removeTodo, clearDone, setFilter
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<!-- TodoApp.vue -->
<template>
<div class="todo-app">
<h2>待办事项</h2>
<input v-model="newText" @keyup.enter="add" placeholder="添加新任务" />
<div class="filters">
<button v-for="f in ['all', 'active', 'done']" :key="f"
:class="{ active: store.filter === f }"
@click="store.setFilter(f as any)">
{{ { all: '全部', active: '未完成', done: '已完成' }[f] }}
</button>
</div>
<ul>
<li v-for="todo in store.filteredTodos" :key="todo.id">
<input type="checkbox" :checked="todo.done" @change="store.toggleTodo(todo.id)" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="store.removeTodo(todo.id)">删除</button>
</li>
</ul>
<p>总计 {{ store.stats.total }} | 未完 {{ store.stats.active }} | 完成 {{ store.stats.done }}</p>
<button v-if="store.stats.done > 0" @click="store.clearDone">清除已完成</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
const store = useTodoStore()
const newText = ref('')
const add = () => {
if (newText.value.trim()) {
store.addTodo(newText.value)
newText.value = ''
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
思考题:
- Setup Store 和 Option Store 哪种写法更好?各有什么优缺点?
- Store 中的 getters 和组件中的 computed 有什么区别?为什么要放在 Store 里?
- 异步 action 中的错误处理应该怎么设计?错误信息放在 Store 里还是组件里?
# 5.3 使用Store
# 5.3.1 在组件中使用
<template>
<p>{{ counterStore.count }}</p>
<p>{{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment()">+1</button>
</template>
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
// 调用 useXxxStore() 获取 store 实例
const counterStore = useCounterStore()
// 可以直接访问 state、getters、actions
console.log(counterStore.count) // state
console.log(counterStore.doubleCount) // getter
counterStore.increment() // action
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 5.3.2 解构Store
直接解构 Store 会丢失响应式,需要用 storeToRefs:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ 错误:解构后 count 不再是响应式
// const { count, doubleCount } = store
// ✅ 正确:使用 storeToRefs 保持响应式
const { count, doubleCount, displayText } = storeToRefs(store)
// 注意:actions(方法)不需要 storeToRefs,直接解构
const { increment, decrement } = store
</script>
<template>
<p>{{ count }}</p>
<p>{{ doubleCount }}</p>
<button @click="increment">+1</button>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 5.3.3 修改State
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// 方式一:直接修改
store.count++
// 方式二:$patch 批量修改(推荐)
store.$patch({
count: store.count + 1,
name: '新名称'
})
// 方式三:$patch 函数形式(适合数组操作)
store.$patch((state) => {
state.count++
state.name = '新名称'
})
// 方式四:通过 action 修改(最推荐)
store.increment()
// 方式五:$reset 重置到初始状态(仅 Option Store 支持)
// store.$reset()
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 5.3.4 订阅变化
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// 订阅 state 变化
store.$subscribe((mutation, state) => {
console.log('变化类型:', mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log('变化的 key:', mutation.events)
console.log('新状态:', state)
// 可以在这里做持久化
localStorage.setItem('counter', JSON.stringify(state))
})
// 订阅 action 调用
store.$onAction(({ name, args, after, onError }) => {
console.log(`Action "${name}" 被调用,参数:`, args)
after((result) => {
console.log(`Action "${name}" 执行完成,返回:`, result)
})
onError((error) => {
console.error(`Action "${name}" 失败:`, error)
})
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 5.3.5 综合案例与思考
综合案例:用户认证与多组件共享
// stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref<{ name: string; role: string } | null>(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(username: string, password: string) {
// 模拟登录
await new Promise(r => setTimeout(r, 1000))
token.value = 'mock-token-' + Date.now()
user.value = { name: username, role: username === 'admin' ? 'admin' : 'user' }
localStorage.setItem('token', token.value)
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
}
return { token, user, isLoggedIn, isAdmin, login, logout }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- Header.vue:显示用户信息 -->
<template>
<header>
<span v-if="isLoggedIn">欢迎, {{ user?.name }}</span>
<button v-if="isLoggedIn" @click="logout">退出</button>
<button v-else @click="$router.push('/login')">登录</button>
</header>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { isLoggedIn, user } = storeToRefs(authStore)
const { logout } = authStore
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
思考题:
- 为什么解构 Store 会丢失响应式?
storeToRefs做了什么? $patch批量修改和直接逐个修改,在性能上有什么区别?$subscribe和watch都能监听变化,各有什么优势?
# 5.4 Store进阶
# 5.4.1 Store间相互调用
// stores/user.ts
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useUserStore = defineStore('user', () => {
const profile = ref<any>(null)
async function fetchProfile() {
// 在一个 store 中使用另一个 store
const authStore = useAuthStore()
if (!authStore.isLoggedIn) {
throw new Error('未登录')
}
const response = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${authStore.token}` }
})
profile.value = await response.json()
}
return { profile, fetchProfile }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 5.4.2 状态持久化
使用 pinia-plugin-persistedstate 插件实现自动持久化:
npm install pinia-plugin-persistedstate
1
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
1
2
3
4
5
6
2
3
4
5
6
// 在 Store 中启用持久化
export const useSettingsStore = defineStore('settings', () => {
const theme = ref('light')
const language = ref('zh-CN')
const fontSize = ref(14)
return { theme, language, fontSize }
}, {
persist: {
key: 'app-settings', // localStorage 的 key
storage: localStorage, // 存储方式
pick: ['theme', 'language'] // 只持久化指定字段
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.4.3 插件机制
自定义 Pinia 插件扩展功能:
// plugins/logPlugin.ts
import type { PiniaPluginContext } from 'pinia'
// 日志插件:记录所有 action 的调用
export function logPlugin({ store }: PiniaPluginContext) {
store.$onAction(({ name, args, after, onError }) => {
const start = Date.now()
console.log(`[${store.$id}] Action "${name}" 开始`, args)
after((result) => {
console.log(`[${store.$id}] Action "${name}" 完成 (${Date.now() - start}ms)`, result)
})
onError((error) => {
console.error(`[${store.$id}] Action "${name}" 失败 (${Date.now() - start}ms)`, error)
})
})
}
// 注册插件
// pinia.use(logPlugin)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 5.4.4 综合案例与思考
综合案例:多Store协作的电商应用
// stores/product.ts
export const useProductStore = defineStore('product', () => {
const products = ref<any[]>([])
const loading = ref(false)
async function fetchProducts() {
loading.value = true
// 模拟API请求
await new Promise(r => setTimeout(r, 500))
products.value = [
{ id: 1, name: 'Vue教程', price: 99, stock: 10 },
{ id: 2, name: 'TS手册', price: 59, stock: 5 },
{ id: 3, name: 'Vite指南', price: 79, stock: 8 }
]
loading.value = false
}
return { products, loading, fetchProducts }
})
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<Array<{ id: number; name: string; price: number; quantity: number }>>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product: { id: number; name: string; price: number }) {
// 调用 productStore 检查库存
const productStore = useProductStore()
const p = productStore.products.find(p => p.id === product.id)
const cartItem = items.value.find(i => i.id === product.id)
const currentQty = cartItem ? cartItem.quantity : 0
if (p && currentQty >= p.stock) {
throw new Error('库存不足')
}
if (cartItem) {
cartItem.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, total, addItem, removeItem }
}, {
persist: true // 购物车持久化
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
思考题:
- Store 之间相互调用时,如何避免循环依赖?
- 状态持久化时,哪些数据适合持久化,哪些不适合?为什么
loading状态不应该持久化? - Pinia 插件能做什么?你能想到哪些实用的插件场景?
上次更新: 2026/06/10, 11:13:41