文章目录
-
- [一、Pinia 相关介绍](#一、Pinia 相关介绍)
-
- [1.1 什么是 Pinia?](#1.1 什么是 Pinia?)
- [1.2 为什么要用 Pinia?](#1.2 为什么要用 Pinia?)
- [1.3 Pinia 存什么?](#1.3 Pinia 存什么?)
- [二、Pinia 基础使用](#二、Pinia 基础使用)
-
- [2.1 项目引入:四步初始化](#2.1 项目引入:四步初始化)
- [2.2 定义仓库:两种写法](#2.2 定义仓库:两种写法)
-
- [写法一:Option 对象式(类 Vuex 风格)](#写法一:Option 对象式(类 Vuex 风格))
- [写法二:Setup 函数式(组合式 API 风格)](#写法二:Setup 函数式(组合式 API 风格))
- [2.3 组件内使用仓库](#2.3 组件内使用仓库)
- 三、待办任务列表实战案例
-
- [3.1 页面搭建(组件化思路)](#3.1 页面搭建(组件化思路))
- [3.2 Pinia 落地流程](#3.2 Pinia 落地流程)
- [3.3 功能分步实现](#3.3 功能分步实现)
-
- [1)新增待办 ------ TodoHeader.vue](#1)新增待办 —— TodoHeader.vue)
- [2)删除待办 ------ TodoMain.vue](#2)删除待办 —— TodoMain.vue)
- 3)单选与全选双向联动
- [4)底部统计与清除已完成 ------ TodoFooter.vue](#4)底部统计与清除已完成 —— TodoFooter.vue)
- [5)筛选 + 列表数据切换](#5)筛选 + 列表数据切换)
- 全文总结
- 核心知识点复盘
一、Pinia 相关介绍
1.1 什么是 Pinia?
Pinia 是专为 Vue 生态打造的状态管理工具,用于实现跨组件通信 和多组件共享数据。它是 Vuex 的继任者,由 Vue 核心团队维护,现已成为 Vue3 官方推荐的状态管理方案。
在理解 Pinia 之前,先回顾 Vue 的两大基础特性:
- 数据驱动视图:数据变了,页面自动更新
- 组件化开发:页面拆成一个个独立组件,每个组件各司其职
问题来了:当组件 A、B、C、D 都需要读写同一份公共数据时,靠父子组件传值(props / emit)会非常繁琐且难以维护。Pinia 就是用来解决这个问题的 ------ 把公共数据抽到一个独立的"仓库"里,所有组件都能直接访问和修改,并且数据变更全程可追踪。
组件A ←→ Pinia仓库(公共数据) ←→ 组件B
组件C ←→ ←→ 组件D
1.2 为什么要用 Pinia?
使用 Pinia 有两个核心目的:
| 目的 | 说明 |
|---|---|
| 多组件数据同步 | 任意组件修改仓库数据后,所有引用该数据的组件都会自动更新 |
| 数据变更可追踪 | 配合 Vue DevTools 可回溯每一次数据变化,方便调试 |
1.3 Pinia 存什么?
一句话:存储多个组件共用的公共数据。比如:
- 用户登录状态(token、用户信息)
- 购物车商品列表
- 页面主题配置
- 多步骤表单的中间数据
组件私有的、不共享的数据,依然放在组件自身的 ref / reactive 里,不需要放进 Pinia。
二、Pinia 基础使用
2.1 项目引入:四步初始化
任何项目使用 Pinia,都需要走完这固定四步:
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
// 第1步:导入 createPinia 方法
import { createPinia } from 'pinia'
// 第2步:创建 Pinia 实例
const pinia = createPinia()
const app = createApp(App)
// 第3步:全局挂载
app.use(pinia)
// 第4步:挂载应用
app.mount('#app')
注意 :
app.use(pinia)必须在app.mount('#app')之前执行,否则组件内调用仓库时会报错。
2.2 定义仓库:两种写法
引入仓库定义函数,然后创建并导出仓库:
js
import { defineStore } from 'pinia'
export const useXxxStore = defineStore('仓库唯一id', Setup函数 或 Option对象)
defineStore 有三个注意要点:
- 仓库 id 必须全局唯一,不能与其他仓库重名
- 导出变量命名遵循
useXxxStore规范,这是 Pinia 的约定,也方便识别 - 第二个参数支持两种写法:Setup 函数式 和 Option 对象式
写法一:Option 对象式(类 Vuex 风格)
这种方式通过 state、getters、actions 三大核心配置来组织代码,和 Vue2 Options API 风格一致:
js
// store/stock.js
import { defineStore } from 'pinia'
export const useStockStore = defineStore('stock', {
// state:存放共享原始数据(必须是函数,返回一个对象)
state: () => ({
stock: 100
}),
// getters:基于 state 计算衍生数据(等效组件内的 computed)
getters: {
doubleStock: (state) => state.stock * 2
},
// actions:修改共享数据的函数,支持同步和异步操作
actions: {
addStock() {
// 通过 this 访问 state 和 getters
this.stock++
},
subStock() {
this.stock--
}
}
})
关键点解析:
state必须写成箭头函数返回对象 的形式state: () => ({ ... }),确保每个组件调用时拿到独立的数据副本getters接收state作为第一个参数,可直接通过state.xxx访问数据actions内部通过this访问 state 和 getters,支持同步和异步逻辑
下表对比三个配置项的功能和对应关系:
| 配置项 | 作用 | 对应 Vue 组件内 |
|---|---|---|
state |
存放原始共享数据 | data() |
getters |
基于 state 计算衍生值 | computed |
actions |
定义修改数据的函数 | methods |
写法二:Setup 函数式(组合式 API 风格)
直接复用 Vue3 组合式 API 的写法,更灵活、也更受 Vue3 开发者青睐:
js
// store/stock.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useStockStore = defineStore('stock', () => {
// 用 ref/reactive 声明响应式数据(等同于 state)
const stock = ref(20)
// 用 computed 计算衍生值(等同于 getters)
const doubleStock = computed(() => {
return stock.value * 2
})
// 普通函数修改数据(等同于 actions)
function addStock() {
stock.value++
}
function subStock() {
stock.value--
}
// 最后 return 暴露出去,组件才能访问
return {
stock,
doubleStock,
addStock,
subStock
}
})
两种写法如何选择? 如果你熟悉 Vue3 组合式 API,推荐 Setup 函数式;如果你从 Vuex 迁移过来或偏好 Options API,选 Option 对象式。两者功能完全等价。
2.3 组件内使用仓库
组件内使用仓库统一分三步:
html
<!-- components/Add.vue -->
<script setup>
// 第1步:导入仓库
import { useStockStore } from '@/store/stock'
// 第2步:调用函数,获取仓库实例
const stockStore = useStockStore()
</script>
<template>
<div class="add">
<h3>Add 组件</h3>
<!-- 第3步:通过实例读取数据和调用方法 -->
<p>已知库存数: {{ stockStore.stock }}</p>
<button @click="stockStore.addStock()">库存 +1</button>
</div>
</template>
再来一个减法组件,验证跨组件共享:
html
<!-- components/Sub.vue -->
<script setup>
import { useStockStore } from '@/store/stock'
const stockStore = useStockStore()
</script>
<template>
<div class="sub">
<h3>Sub 组件</h3>
<p>已知库存数: {{ stockStore.stock }}</p>
<button @click="stockStore.subStock()">库存 -1</button>
</div>
</template>
在 App.vue 中汇总:
html
<script setup>
import Add from './components/Add.vue'
import Sub from './components/Sub.vue'
import { useStockStore } from '@/store/stock'
const stockStore = useStockStore()
</script>
<template>
<h1>根组件</h1>
库存总数:
<input type="number" v-model.number="stockStore.stock" />
<p>stock 的翻倍值 = {{ stockStore.doubleStock }}</p>
<hr />
<Add />
<hr />
<Sub />
</template>
效果验证 :在 Add 组件中点击 "+1",Sub 组件显示的数字会同步更新;在输入框中直接修改库存数,所有组件的显示也会同步。这就是 Pinia 的核心能力 ------ 一处修改,处处响应。
三、待办任务列表实战案例
以上是基础语法,接下来通过一个完整的 Todo List(待办事项) 项目,把 Pinia 真正用起来。本章节同时给出 Option 和 Setup 两种写法的代码,方便对比学习。
3.1 页面搭建(组件化思路)
先将页面拆分为 3 个子组件:
| 组件 | 文件名 | 职责 |
|---|---|---|
| TodoHeader | TodoHeader.vue |
输入框,新增待办事项 |
| TodoMain | TodoMain.vue |
展示待办列表,单选/全选操作 |
| TodoFooter | TodoFooter.vue |
统计未完成数量,筛选切换,清除已完成 |
在 App.vue 中引入组装:
html
<script setup>
import './assets/style.css'
import TodoHeader from './componnets/TodoHeader.vue'
import TodoMain from './componnets/TodoMain.vue'
import TodoFooter from './componnets/TodoFooter.vue'
</script>
<template>
<div class="todoapp">
<TodoHeader />
<TodoMain />
<TodoFooter />
</div>
</template>
3.2 Pinia 落地流程
按照前面的四步法初始化 Pinia(安装 → 引入 → 创建 → 挂载),然后创建 todo 业务仓库。
Option 对象式仓库
js
// store/todo.js
import { defineStore } from 'pinia'
// 定义筛选类型常量,避免组件内硬编码字符串
export const TODO_TYPE = {
all: 'all', // 全部
unfinished: 'unfinished', // 未完成
finished: 'finished' // 已完成
}
export const useTodoStore = defineStore('todo', {
state: () => ({
// 当前筛选类型,默认显示全部
type: TODO_TYPE.all,
// 共享的待办列表
todos: [
{ id: 1, text: 'Buy milk', finished: false },
{ id: 2, text: 'Buy eggs', finished: true },
{ id: 3, text: 'Buy bread', finished: false }
]
}),
getters: {
// 判断是否全部选中(用于全选复选框)
isAll: (state) => state.todos.every((item) => item.finished),
// 未完成任务列表(用于底部统计)
unfinishedTodos: (state) => state.todos.filter((item) => !item.finished),
// 根据筛选类型,计算当前需要展示的列表
showTodos: (state) => {
switch (state.type) {
case TODO_TYPE.all:
return state.todos
case TODO_TYPE.unfinished:
return state.todos.filter((item) => !item.finished)
case TODO_TYPE.finished:
return state.todos.filter((item) => item.finished)
}
}
},
actions: {
// 新增待办
addTodo(text) {
this.todos.push({
text,
id: this.todos.length + 1,
finished: false
})
},
// 删除待办
delTodo(i) {
if (window.confirm('确认删除么')) {
this.todos.splice(i, 1)
}
},
// 批量切换完成状态(全选/取消全选)
toggleTodo(flag) {
this.todos.forEach((item) => (item.finished = flag))
},
// 清除所有已完成任务
clearTodo() {
this.todos = this.todos.filter((item) => !item.finished)
},
// 设置筛选类型
setType(type) {
this.type = type
}
}
})
Setup 函数式仓库(对比写法)
js
// store/todo.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const TODO_TYPE = {
all: 'all',
unfinished: 'unfinished',
finished: 'finished'
}
export const useTodoStore = defineStore('todo', () => {
const todos = ref([
{ id: 1, text: 'Buy milk', finished: false },
{ id: 2, text: 'Buy eggs', finished: true },
{ id: 3, text: 'Buy bread', finished: false }
])
const type = ref(TODO_TYPE.all)
const isAll = computed(() => todos.value.every((item) => item.finished))
const unfinishedTodos = computed(() => todos.value.filter((item) => !item.finished))
const showTodos = computed(() => {
switch (type.value) {
case TODO_TYPE.all:
return todos.value
case TODO_TYPE.unfinished:
return todos.value.filter((item) => !item.finished)
case TODO_TYPE.finished:
return todos.value.filter((item) => item.finished)
}
})
const addTodo = (text) => {
todos.value.push({ text, id: todos.value.length + 1, finished: false })
}
const delTodo = (i) => {
if (window.confirm('确认删除么')) {
todos.value.splice(i, 1)
}
}
const toggleTodo = (flag) => {
todos.value.forEach((item) => (item.finished = flag))
}
const clearTodo = () => {
todos.value = todos.value.filter((item) => !item.finished)
}
const setType = (selectType) => {
type.value = selectType
}
// Setup 写法的关键:最后必须 return 所有需要暴露的变量和方法
return {
todos, type, isAll, unfinishedTodos, showTodos,
addTodo, delTodo, toggleTodo, clearTodo, setType
}
})
两种写法的核心区别:
- Option 写法:
state中数据通过this.xxx访问,结构清晰分层- Setup 写法:
ref数据需要通过.value访问,最后return暴露;优点是可以用 JS 原生语法组织逻辑(比如把相关功能抽成独立函数)
3.3 功能分步实现
下面以 Option 写法为例,逐一实现各项功能。
1)新增待办 ------ TodoHeader.vue
html
<script setup>
import { useTodoStore } from '@/store/todo'
const todoStore = useTodoStore()
// 监听回车键,触发新增
const onEnter = (e) => {
const text = e.target.value.trim() // 去除首尾空格
if (!text) return alert('任务名称不能为空') // 非空校验
todoStore.addTodo(text) // 调用仓库新增方法
e.target.value = '' // 清空输入框
}
</script>
<template>
<header class="header">
<h1>todos</h1>
<input
@keydown.enter="onEnter"
class="new-todo"
placeholder="What needs to be finished?"
autofocus
/>
</header>
</template>
代码思路 :用户在输入框按回车 → 触发 onEnter → 校验输入内容 → 调用仓库的 addTodo 方法 → 仓库往 todos 数组 push 一条新记录 → 所有引用该数组的组件自动更新。
2)删除待办 ------ TodoMain.vue
删除逻辑在仓库 actions 中已定义好了,组件只需调用即可:
html
<button class="destroy" @click="todoStore.delTodo(index)"></button>
这里传入的 index 来自 v-for 循环的索引,仓库中通过 splice(i, 1) 删除对应项。删除前弹出确认框,防止误操作。
3)单选与全选双向联动
这是本案例最核心、最容易出错的功能点,涉及两条数据流:
流程图解:
单选(勾选某一条)
↓ getters.isAll 自动计算
→ 所有项都 finished=true 时 isAll=true
→ 全选复选框自动勾上(get)
全选(点击全选框)
↓ v-model 触发 set
→ 调用 actions.toggleTodo(flag)
→ 遍历所有项,批量赋 finished=flag(set)
单选 ↔ 全选 形成闭环联动
TodoMain.vue 关键代码:
html
<script setup>
import { computed } from 'vue'
import { useTodoStore } from '@/store/todo'
const todoStore = useTodoStore()
// 带 get/set 的完整计算属性,是实现双向联动的关键
const isAllSelected = computed({
// get:读取时自动触发 → 返回仓库 isAll 的值 → 控制全选框状态
get() {
return todoStore.isAll
},
// set:全选框被点击时触发 → 接收新的勾选状态 → 批量更新所有项
set(flag) {
todoStore.toggleTodo(flag)
}
})
</script>
<template>
<section class="main">
<!-- 全选框通过 v-model 绑定带 get/set 的计算属性 -->
<input
v-model="isAllSelected"
id="toggle-all"
class="toggle-all"
type="checkbox"
/>
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li
v-for="(item, index) in todoStore.showTodos"
:key="item.id"
:class="{ completed: item.finished }"
>
<div class="view">
<!-- 单选框直接 v-model 绑定 finished 字段 -->
<input v-model="item.finished" class="toggle" type="checkbox" />
<label>{{ item.text }}</label>
<button class="destroy" @click="todoStore.delTodo(index)"></button>
</div>
</li>
</ul>
</section>
</template>
为什么用带 get/set 的计算属性? 因为全选框需要"读取"仓库的计算结果,又需要"写入"触发批量更新。普通
computed只有 get,无法响应 v-model 的双向绑定;写成computed({ get, set })才能让全选框和仓库数据形成闭环。
仓库中对应的 getter 和 action:
js
// getters:判断是否所有项都已勾选
isAll: (state) => state.todos.every((item) => item.finished),
// actions:遍历所有项,统一设置 finished 状态
toggleTodo(flag) {
this.todos.forEach((item) => (item.finished = flag))
}
4)底部统计与清除已完成 ------ TodoFooter.vue
html
<script setup>
import { useTodoStore, TODO_TYPE } from '@/store/todo'
const todoStore = useTodoStore()
</script>
<template>
<footer class="footer">
<!-- 统计未完成任务数:直接用 getter 返回的数组取 length -->
<span class="todo-count">
<strong>{{ todoStore.unfinishedTodos.length }}</strong> item left
</span>
<!-- 筛选标签:点击切换 type,动态绑定 selected 类名实现高亮 -->
<ul class="filters">
<li>
<a
@click="todoStore.setType(TODO_TYPE.all)"
href="#/"
:class="{ selected: todoStore.type === TODO_TYPE.all }"
>All</a>
</li>
<li>
<a
@click="todoStore.setType(TODO_TYPE.unfinished)"
href="#/active"
:class="{ selected: todoStore.type === TODO_TYPE.unfinished }"
>Active</a>
</li>
<li>
<a
@click="todoStore.setType(TODO_TYPE.finished)"
href="#/completed"
:class="{ selected: todoStore.type === TODO_TYPE.finished }"
>Completed</a>
</li>
</ul>
<!-- 一键清除已完成任务 -->
<button class="clear-completed" @click="todoStore.clearTodo()">
Clear completed
</button>
</footer>
</template>
功能解析:
- 统计 :
unfinishedTodos是 getters 中filter的结果,取.length即可得到未完成任务数量。因为是计算属性,数据变化时自动更新。 - 清除已完成 :调用
clearTodo(),内部用filter过滤掉finished: true的项并重新赋值,触发响应式更新。 - 筛选高亮 :仓库
state.type记录当前筛选类型,点击 a 标签时调用setType()切换;模板中通过:class="{ selected: todoStore.type === TODO_TYPE.xxx }"动态添加高亮样式。
5)筛选 + 列表数据切换
这个功能的核心在于 getters 中的 showTodos:
js
showTodos: (state) => {
switch (state.type) {
case TODO_TYPE.all:
return state.todos // 全量
case TODO_TYPE.unfinished:
return state.todos.filter((item) => !item.finished) // 未完成
case TODO_TYPE.finished:
return state.todos.filter((item) => item.finished) // 已完成
}
}
工作流程:用户点击筛选标签 → setType() 修改 state.type → showTodos 计算属性自动重新计算 → TodoMain 中 v-for="item in todoStore.showTodos" 拿到新数据 → 列表重渲染。
要点 :我们没有 真的删除数据或创建新数组,只是基于
type+ 原始todos计算出一个派生视图。原始数据始终完整保留,筛选只是"换个角度看数据"。这种模式在 Pinia 中非常常见。
全文总结
本文从零开始,系统梳理了 Pinia 的核心知识体系:
- 概念层:Pinia 是什么、解决什么问题、适用于什么场景
- 语法层:四步项目引入、仓库定义的两种写法(Option 对象式 vs Setup 函数式)、组件内三步入库
- 实战层:以 Todo List 为例,完整演示了组件化拆分 → 仓库设计 → 新增/删除/单选全选联动/统计筛选等功能的实现,以及两种仓库写法的对比
核心知识点复盘
| 知识点 | 关键内容 |
|---|---|
| Pinia 定位 | Vue3 官方状态管理工具,替代 Vuex |
| 四步引入 | import → createPinia() → app.use(pinia) → mount |
defineStore 参数 |
仓库唯一 id + Setup 函数 或 Option 对象 |
| Option 三大件 | state(数据)→ getters(计算)→ actions(修改) |
| Setup 写法 | ref/reactive → computed → 普通函数 → 最后 return 暴露 |
| 组件使用三步骤 | 导入仓库 → 调用获取实例 → 读取数据/调用方法 |
| 全选联动 | 带 get/set 的 computed + getters 计算全选状态 + actions 批量更新 |
| 筛选模式 | state 记录筛选类型,getters 根据类型动态过滤,不修改原始数据 |
本文基于 Vue3 + Pinia 实战项目整理,代码完整可运行,适合作为学习复盘和技术分享的参考资料。