跟着 Demo 学 Pinia:两种仓库写法 + 完整 TodoList 复现

文章目录

    • [一、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 有三个注意要点:

  1. 仓库 id 必须全局唯一,不能与其他仓库重名
  2. 导出变量命名遵循 useXxxStore 规范,这是 Pinia 的约定,也方便识别
  3. 第二个参数支持两种写法:Setup 函数式 和 Option 对象式

写法一:Option 对象式(类 Vuex 风格)

这种方式通过 stategettersactions 三大核心配置来组织代码,和 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.typeshowTodos 计算属性自动重新计算 → TodoMain 中 v-for="item in todoStore.showTodos" 拿到新数据 → 列表重渲染。

要点 :我们没有 真的删除数据或创建新数组,只是基于 type + 原始 todos 计算出一个派生视图。原始数据始终完整保留,筛选只是"换个角度看数据"。这种模式在 Pinia 中非常常见。


全文总结

本文从零开始,系统梳理了 Pinia 的核心知识体系:

  1. 概念层:Pinia 是什么、解决什么问题、适用于什么场景
  2. 语法层:四步项目引入、仓库定义的两种写法(Option 对象式 vs Setup 函数式)、组件内三步入库
  3. 实战层:以 Todo List 为例,完整演示了组件化拆分 → 仓库设计 → 新增/删除/单选全选联动/统计筛选等功能的实现,以及两种仓库写法的对比

核心知识点复盘

知识点 关键内容
Pinia 定位 Vue3 官方状态管理工具,替代 Vuex
四步引入 importcreatePinia()app.use(pinia)mount
defineStore 参数 仓库唯一 id + Setup 函数 或 Option 对象
Option 三大件 state(数据)→ getters(计算)→ actions(修改)
Setup 写法 ref/reactivecomputed → 普通函数 → 最后 return 暴露
组件使用三步骤 导入仓库 → 调用获取实例 → 读取数据/调用方法
全选联动 get/setcomputed + getters 计算全选状态 + actions 批量更新
筛选模式 state 记录筛选类型,getters 根据类型动态过滤,不修改原始数据

本文基于 Vue3 + Pinia 实战项目整理,代码完整可运行,适合作为学习复盘和技术分享的参考资料。

相关推荐
明月_清风1 小时前
🤗 Hugging Face 模型上传完全指南:从本地到 Hub 的 4 种姿势
前端·后端·ai编程
To_OC1 小时前
从一个名字编辑组件开始,我把 React + TS 的数据流和副作用彻底搞明白了
前端·react.js·typescript
弹简特2 小时前
【Vue3速成】09-Element Plus 核心组件实战与原理解析
vue·element-plus
程序员黑豆2 小时前
Java入门第一步:从零开始编写你的第一个Hello World程序
java·前端·ai编程
凌涘2 小时前
前端路由(二):React Router 组件化路由实战
前端
鸽鸽2 小时前
别急着装第三方库,这些事浏览器原生就能做到——Web Native API 实战指南
前端
做前端的娜娜子2 小时前
从 0 到 1 打造一个配置可视化后台:一次前端配置中心化改造的实战复盘
前端·设计模式·数据可视化
南雨北斗3 小时前
vue3 RouterLink链接颜色修改的方法
前端
Richard.Wong3 小时前
Windows IIS 服务器部署 Vue3 前端项目详细流程
服务器·前端·windows