Vue3 状态管理入门
前面学过用 props 传、用 provide/inject 注入、用组合式函数复用逻辑,但有个场景它们都不顺手,就是购物车。
购物车页要加减商品,顶部导航栏要显示总数,结算页要读出清单。
- 这三个组件分属不同路由页面,互相不是父子,props 传不过去。
- 组合式函数每次调用是独立副本,购物车页改的数量,导航栏那份看不到,也共享不了。
- provide/inject 也能传,在根组件 provide 一份,所有页面都是它的后代,都能 inject 到,配 TypeScript 能约束类型,连修改函数也可以和数据一起 provide(Vue3 依赖注入 的读写分离写法),小项目这么写够用,但逻辑靠各组件自觉组织,项目一大,谁 provide 的、改动从哪来,只能靠约定撑着,没有统一结构,也没有工具能看能查。
这份”所有页面共同需要、且谁都能改”的数据,需要更正经的全局方案,这种情况交给全局状态管理,Vue 生态的现代方案是 Pinia,它上手简单、类型友好、比老的方案轻量得多,是 Vue 官方推荐的默认选择。
安装与接入全局
npm install pinia
在 main.js 中创建并挂载:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')
定义第一个 Store
先别管 Store 这个新名词,写购物车逻辑你早就会了,Vue3 组合式函数 教过,把 ref、computed、函数装进一个函数再 return,开头的购物车照这个套路写,清单是 ref、总数是 computed、加减是函数:
// src/composables/useCart.js
import { ref, computed } from 'vue'
export function useCart() {
const items = ref([{ id: 1, name: '苹果', price: 5, count: 2 }])
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.count, 0)
)
const addItem = (goods) => {
const found = items.value.find(item => item.id === goods.id)
if (found) found.count++
else items.value.push({ ...goods, count: 1 })
}
const removeItem = (id) => {
const found = items.value.find(item => item.id === id)
if (found && found.count > 1) found.count--
}
return { items, totalCount, addItem, removeItem }
}
但它有个局限,Vue3 组合式函数 结尾也提醒过,每次调用 useCart() 函数体都重新跑一遍,items 是新建的一份,导航栏和购物车页各调一次,各拿各的,改不到一起去。
Pinia 的解法,就是给这个组合式函数包一层 defineStore,函数体一个字不用改,只是换个外壳,包完之后里面的数据变成全局唯一一份,谁调用都拿到同一个:
// src/stores/useCartStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// 函数体原样搬进来
const items = ref([{ id: 1, name: '苹果', price: 5, count: 2 }])
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.count, 0)
)
const addItem = (goods) => {
const found = items.value.find(item => item.id === goods.id)
if (found) found.count++
else items.value.push({ ...goods, count: 1 })
}
const removeItem = (id) => {
const found = items.value.find(item => item.id === id)
if (found && found.count > 1) found.count--
}
return { items, totalCount, addItem, removeItem }
})
和 useCart() 比,函数体一行没改,只变了两处外壳。function useCart() 换成 const useCartStore = defineStore('cart', () => ...),多出来的 'cart' 是 store 的名字,全局唯一,多个 store 靠它区分。
还是熟悉的 ref、computed、函数,Pinia 只是给它们起了三个角色名,方便统一称呼:
| 角色 | 写法 | 就是原来的 | 对应购物车 |
|---|---|---|---|
| state | ref |
响应式数据 | 清单 items |
| getters | computed |
派生计算 | 总数 totalCount |
| actions | 普通函数 | 修改逻辑 | addItem / removeItem |
明白这层对应,Store 就不神秘了,本质就是包了 defineStore 的全局组合式函数。
在组件里使用
Store 写好了,组件里用起来就三步,import 进来、useCartStore() 拿实例、模板里直接用。
导航栏要显示总数,只读不写:
<!-- NavBar.vue -->
<script setup>
import { useCartStore } from '../stores/useCartStore'
const cart = useCartStore()
</script>
<template>
<span>购物车({{ cart.totalCount }} 件)</span>
</template>
购物车页要改清单,点按钮调 action:
<!-- CartPage.vue -->
<script setup>
import { useCartStore } from '../stores/useCartStore'
const cart = useCartStore()
</script>
<template>
<ul>
<li v-for="item in cart.items" :key="item.id">
{{ item.name }} × {{ item.count }}
<button @click="cart.removeItem(item.id)">-</button>
</li>
</ul>
<button @click="cart.addItem({ id: 1, name: '苹果', price: 5 })">+ 苹果</button>
</template>
读和写都走同一个 cart,ref 的 .value 规则、模板自动解包这些之前学的照用,不需要额外记新规则。
解构要小心
useCartStore() 返回一个对象,直接解构会丢失响应式(和 reactive 解构一个道理):
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/useCartStore'
const cart = useCartStore()
// 要保住响应式,state/getters 用 storeToRefs 包着解构
const { items, totalCount } = storeToRefs(cart)
// actions 是普通函数,正常解构即可
const { addItem, removeItem } = cart
</script>
速查卡片
| 场景 | 写法 |
|---|---|
| 安装/接入 | npm i pinia + app.use(createPinia()) |
| 定义 store | defineStore('名字', () => { ... }) |
| state | ref(...) → 返回 |
| getters | computed(...) → 返回 |
| actions | 普通函数 → 返回 |
| 组件中使用 | const s = useXxxStore() |
| 响应式解构 | state/getters 用 storeToRefs,actions 直接解构 |