从零开始捡起知识点(三) Pinia

0 阅读9分钟

Pinia 复习博客

大家好~ 最近在做毕设 没搞博客,今天复习 Vue3 生态,Pinia 作为官方推荐的状态管理工具,替代了之前的 Vuex,其简洁的 API、更好的 TypeScript 支持和更灵活的用法,成为 Vue3 项目的首选。

一、Pinia 基础认知

在复习用法之前,先明确 Pinia 的核心优势,搞懂它为什么能替代 Vuex,这也是面试中常考的点:

  • 简洁 API,无嵌套结构:Pinia 取消了 Vuex 中的 Mutations、Modules 嵌套,用 Store 直接管理状态,代码更简洁,上手成本低;

  • 完美支持 TypeScript:天生适配 TS,无需额外配置,能自动推导状态、方法的类型,避免类型报错,开发体验更优;

  • 灵活的状态管理:支持多个 Store 并存,无需像 Vuex 那样通过 Modules 拆分,每个 Store 独立管理,按需引入;

  • 轻量体积:体积极小(约 1KB),无多余依赖,不增加项目负担;

  • Vue3 原生支持:基于 Vue3 的 Composition API 设计,支持响应式特性,与 Vue3 生态无缝衔接,也支持 Options API 写法。

核心结论:Pinia 不是 Vuex 的升级,而是全新的状态管理方案,解决了 Vuex 存在的繁琐、TS 支持差等问题,是 Vue3 项目的最优解。

二、Pinia 从搭建到使用

这部分是复习的重点,我们按“安装 → 创建 Store → 访问/修改状态 → 核心特性”的顺序,一步步梳理,确保每个步骤都清晰可复现。

2.1 安装 Pinia

首先是安装,Vue3 项目中直接通过 npm 或 yarn 安装,命令如下(两种方式二选一):

# npm 安装
npm install pinia
# yarn 安装
yarn add pinia

安装完成后,需要在 main.js 中引入并使用 Pinia,初始化全局 Store 实例:

// main.js (Vue3 + Vite)
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 引入 createPinia
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia() // 创建 Pinia 实例
app.use(pinia) // 挂载到 Vue 应用

app.mount('#app')

这一步是基础,必须牢记:只有挂载 Pinia 实例,才能在项目中使用 Store。

2.2 创建 Store

Pinia 中,每个 Store 都是一个独立的模块,通过defineStore函数创建,该函数接收两个参数:

  1. Store 的唯一标识(string 类型,必须唯一,不能重复);

  2. Store 配置对象(包含 state、getters、actions)。

通常我们会在 src 目录下创建 store 文件夹,按功能拆分不同的 Store(如 userStore、cartStore),示例如下(以 userStore 为例):

// src/store/userStore.js
import { defineStore } from 'pinia'

// 方式1:Options API 写法(类似 Vuex 的 Options 写法,适合习惯 Vue2 的同学)
export const useUserStore = defineStore('user', {
  // 状态:存储数据的地方,类似 Vuex 的 state
  state: () => ({
    username: '张三',
    age: 20,
    isLogin: false,
    token: ''
  }),
  // 计算属性:类似 Vue 的 computed,依赖 state,自动缓存结果
  getters: {
    // 基础用法:直接访问 state
    fullInfo: (state) => `${state.username}${state.age}岁`,
    // 进阶:访问其他 getters(通过 this,需指定返回值类型,TS 必写)
    isAdult: function() {
      return this.age >= 18
    }
  },
  // 方法:修改状态、处理异步操作,类似 Vuex 的 actions(无 mutations)
  actions: {
    // 同步修改状态:直接修改 state(无需像 Vuex 那样通过 mutations)
    setLoginStatus(status) {
      this.isLogin = status
    },
    // 同步修改多个状态
    updateUserInfo({ username, age }) {
      this.username = username
      this.age = age
    },
    // 异步操作:如请求接口获取用户信息
    async login(username, password) {
      // 模拟接口请求
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ username, password })
      })
      const data = await res.json()
      // 修改状态
      this.token = data.token
      this.isLogin = true
      this.username = data.username
    }
  }
})

// 方式2:Composition API 写法(推荐,更灵活,适配 Vue3 语法)
export const useCartStore = defineStore('cart', () => {
  // 状态:用 ref/reactive 定义,类似 setup 中的响应式数据
  const cartList = ref([])
  const totalPrice = ref(0)

  // 计算属性:用 computed 定义,对应 Options 写法的 getters
  const cartCount = computed(() => cartList.value.length)

  // 方法:对应 Options 写法的 actions,同步/异步均可
  const addToCart = (goods) => {
    cartList.value.push(goods)
    // 同步更新总价格
    totalPrice.value += goods.price * goods.count
  }

  const clearCart = () => {
    cartList.value = []
    totalPrice.value = 0
  }

  // 必须返回需要暴露的状态、计算属性、方法
  return { cartList, totalPrice, cartCount, addToCart, clearCart }
})

重点注意:

  • Store 的命名规范:useXXXStore(如 useUserStore),遵循 Composition API 的命名习惯;

  • Options API 和 Composition API 写法可任选,推荐 Composition API(更灵活,更适配 Vue3);

  • actions 中可以直接修改 state,无需像 Vuex 那样写 mutations,这是 Pinia 最简洁的地方。

2.3 访问/修改 Store 中的状态

创建好 Store 后,在组件中引入并使用,核心分为“访问状态”“修改状态”两种场景,我们分别梳理:

2.3.1 访问状态和 getters

在组件中引入 Store 后,直接访问即可,示例(Vue3 单文件组件):

<template>
  <div>
    <h2>用户名:{{ userStore.username }}</h2>
    <h3>用户信息:{{ userStore.fullInfo }}</h3>
    <h3>是否成年:{{ userStore.isAdult }}</h3>
  </div>
</template>

<script setup>
// 引入 Store
import { useUserStore } from '@/store/userStore'

// 创建 Store 实例(注意:必须调用 useXXXStore(),不能直接使用导入的函数)
const userStore = useUserStore()

// 访问状态和 getters(直接通过实例访问)
console.log(userStore.age) // 20
console.log(userStore.fullInfo) // 张三,20岁
</script>

注意:不能直接解构 Store 实例(会失去响应式),如果需要解构,需使用 Pinia 提供的 storeToRefs 函数:

import { useUserStore } from '@/store/userStore'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
// 解构状态,保持响应式
const { username, age } = storeToRefs(userStore)
// 解构 getters,同样保持响应式
const { fullInfo } = storeToRefs(userStore)
2.3.2 修改状态:三种方式

Pinia 提供了多种修改状态的方式,按需选择,重点掌握前两种:

  1. 直接修改(最简单,推荐用于简单场景)const userStore = useUserStore() // 直接修改单个状态 userStore.age = 21 // 直接修改多个状态 userStore.username = '李四' userStore.isLogin = true

  2. 通过 actions 修改(推荐用于复杂场景、异步操作)const userStore = useUserStore() // 调用同步 action userStore.setLoginStatus(true) userStore.updateUserInfo({ username: '李四', age: 22 }) // 调用异步 action userStore.login('admin', '123456')重点:异步操作必须写在 actions 中,不能直接在组件中修改状态(规范问题,便于维护)。

  3. **通过 patch修改(批量修改,适合一次性修改多个状态)constuserStore=useUserStore()//方式1:对象形式(批量修改多个状态)userStore.patch 修改(批量修改,适合一次性修改多个状态)**: `const userStore = useUserStore() // 方式1:对象形式(批量修改多个状态) userStore.patch({ username: '李四', age: 22, isLogin: true }) // 方式2:函数形式(适合复杂修改,如数组操作) userStore.$patch((state) => { state.age += 1 state.cartList.push({ id: 1, name: '商品1' }) })`

三、Pinia 进阶技巧:复习重点难点

这部分是 Pinia 的核心难点,也是面试高频考点,重点复习“Store 持久化”“Store 间通信”“调试工具”三个知识点。

3.1 Store 持久化:避免页面刷新状态丢失

Pinia 本身不支持状态持久化(页面刷新后,状态会恢复初始值),实际开发中常用 pinia-plugin-persistedstate 插件实现持久化,步骤如下:

  1. 安装插件: `npm install pinia-plugin-persistedstate

或 yarn add pinia-plugin-persistedstate`

  1. 在 main.js 中配置插件: `import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入插件 import App from './App.vue'

const app = createApp(App) const pinia = createPinia() pinia.use(piniaPluginPersistedstate) // 使用插件 app.use(pinia) app.mount('#app')`

  1. 在 Store 中开启持久化(配置 persist: true): `// Options API 写法 export const useUserStore = defineStore('user', { state: () => ({ /* 状态 / }), getters: { / 计算属性 / }, actions: { / 方法 */ }, persist: true // 开启持久化,默认存储到 localStorage })

// 进阶:自定义持久化配置(如存储到 sessionStorage、指定需要持久化的状态) persist: { key: 'userStore', // 存储的 key,默认是 Store 的唯一标识 storage: sessionStorage, // 存储位置,可选 localStorage(默认)、sessionStorage paths: ['username', 'token'] // 只持久化 username 和 token,其他状态不持久化 }`

3.2 Store 间通信:多个 Store 相互访问

实际项目中,多个 Store 之间可能需要相互访问(如 userStore 需要用到 cartStore 的数据),Pinia 中无需额外配置,直接在一个 Store 中引入另一个 Store 即可:

// src/store/userStore.js
import { defineStore } from 'pinia'
import { useCartStore } from './cartStore' // 引入其他 Store

export const useUserStore = defineStore('user', {
  actions: {
    // 在 userStore 的 action 中访问 cartStore
    checkCart() {
      const cartStore = useCartStore() // 创建 cartStore 实例
      console.log('购物车数量:', cartStore.cartCount)
      // 也可以调用其他 Store 的 actions
      cartStore.clearCart()
    }
  }
})

重点:在 Store 中访问另一个 Store 时,必须在方法内部创建实例(不能在 Store 外部创建,否则会导致循环引用)。

3.3 调试工具:Pinia DevTools

Pinia 支持 Vue DevTools 调试,能实时查看 Store 的状态、跟踪 actions 的调用,配置简单:

  • 确保 Vue DevTools 已安装(浏览器插件或 VS Code 插件);

  • Pinia 会自动集成 DevTools,无需额外配置,启动项目后,打开 DevTools,切换到“Pinia”标签,即可查看所有 Store 的状态和操作记录。

调试技巧:在 actions 中使用 console.log 或 DevTools 的“时间线”,可快速定位状态修改的来源,排查问题。

四、常见问题 & 易错点复习

这部分整理了复习和开发中最容易出错的几个点,重点规避:

  1. Store 实例必须调用创建:导入 useXXXStore 后,必须调用 const store = useXXXStore(),不能直接使用导入的函数(否则无法访问状态);

  2. 解构 Store 必须用 storeToRefs:直接解构 Store 实例会失去响应式,如const { username } = userStore 是错误的,必须用storeToRefs

  3. actions 中不能用箭头函数:如果用箭头函数,this 会指向 undefined,无法访问 Store 的 state 和其他方法,必须用普通函数;

  4. 持久化插件的配置问题:开启持久化后,状态修改会自动同步到本地存储,但注意:如果状态是引用类型(如数组、对象),修改内部属性时,也会触发持久化(无需额外操作);

  5. Store 的唯一标识不能重复:每个 Store 的第一个参数(标识)必须唯一,否则会导致状态冲突,无法正常使用。

六、Pinia 面试

  1. 问题1:Pinia 和 Vuex 的区别是什么?(高频必问)

答:① 结构:Pinia 取消 Mutations、Modules 嵌套,单个 Store 独立,更简洁;Vuex 需通过 Modules 拆分,有 Mutations/Actions/Getters/State 多层嵌套。② TS 支持:Pinia 天生适配 TS,自动类型推导;Vuex 需额外配置,类型推导繁琐。③ 用法:Pinia 可直接在 Actions 中修改 State,无需 Mutations;Vuex 必须通过 Mutations 修改 State(Actions 不能直接修改)。④ 体积:Pinia 约 1KB,轻量;Vuex 体积更大。⑤ 兼容性:Pinia 仅支持 Vue3;Vuex 支持 Vue2 和 Vue3(Vuex4 适配 Vue3)。

  1. 问题2:Pinia 中如何创建 Store?有几种写法?

答:通过 defineStore 函数创建,接收两个参数:Store 唯一标识(string)、配置对象/回调函数。有两种写法:① Options API 写法:配置对象包含 state、getters、actions,类似 Vuex Options 写法;② Composition API 写法:回调函数中用 ref/reactive 定义状态,computed 定义计算属性,函数定义方法,最后返回需要暴露的内容(推荐)。

  1. 问题3:Pinia 中如何访问 Store 状态?直接解构会有什么问题?如何解决

答:① 访问方式:引入 useXXXStore 后,调用该函数创建实例,通过实例直接访问(如 userStore.username)。② 问题:直接解构 Store 实例(如 const { username } = userStore)会失去响应式。③ 解决:使用 Pinia 提供的 storeToRefs 函数解构,可保持响应式(如 const { username } = storeToRefs(userStore))。

  1. 问题4:Pinia 中修改状态有几种方式?分别适用于什么场景?

答:3种方式。① 直接修改:最简单,适用于简单场景(单个/少量状态修改);② 通过 Actions 修改:适用于复杂场景、异步操作(如接口请求后修改状态),便于维护;③ 通过 $patch 修改:适用于批量修改多个状态,可传入对象(简单批量修改)或函数(复杂修改,如数组操作)。

  1. 问题5:Pinia 中 Actions 和 Getters 的区别是什么?

答:① Getters:类似 Vue 的 computed,依赖 State,自动缓存结果,用于对 State 进行计算处理,不能修改 State;② Actions:类似 Vue 的 methods,可同步/异步操作,用于修改 State、处理业务逻辑(如接口请求),可以修改 State。

  1. 问题6:Pinia 如何实现 Store 持久化?核心原理是什么?

答:① 实现方式:使用 pinia-plugin-persistedstate 插件,步骤:安装插件 → main.js 中配置 pinia.use(插件) → 在 Store 中配置 persist: true(或自定义配置)。② 核心原理:监听 Store 状态变化,将状态同步存储到 localStorage/sessionStorage,页面刷新时从本地存储中读取状态,恢复到 Store 中。

  1. 问题7:Pinia 中多个 Store 之间如何通信?

答:无需额外配置,在一个 Store 中引入另一个 Store 即可。重点:必须在 Store 的 Actions 内部创建另一个 Store 的实例(不能在 Store 外部创建),避免循环引用,然后通过实例访问其状态、调用其方法。

  1. 问题8:Pinia 中 Actions 为什么不能用箭头函数?

答:因为箭头函数没有自己的 this,会继承外部上下文的 this(通常是 undefined),导致无法访问 Store 内部的 state、getters、actions,所以必须使用普通函数,确保 this 指向 Store 实例。

  1. 问题9:Pinia 如何调试?

答:Pinia 自动集成 Vue DevTools,无需额外配置。步骤:① 安装 Vue DevTools 浏览器插件;② 启动项目,打开 DevTools,切换到“Pinia”标签;③ 可实时查看所有 Store 的状态、跟踪 Actions 调用记录,也可手动修改状态调试。

  1. 问题10:Pinia 中 Store 的唯一标识有什么作用?如果重复会有什么问题?

答:① 作用:作为 Store 的唯一标识,用于区分不同的 Store,Pinia 内部通过该标识管理 Store 实例,避免混淆。② 问题:如果标识重复,会导致多个 Store 实例冲突,状态覆盖,无法正常使用(比如修改一个 Store 的状态,另一个标识相同的 Store 状态也会被修改)。

七、练习案例

练习案例1:用户登录 + 状态持久化(基础实战)

需求:实现用户登录(模拟接口),登录成功后保存用户信息和 token,页面刷新后状态不丢失;实现退出登录,清除用户状态和本地存储。

// 1. 创建 userStore(src/store/userStore.js)
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    username: '',
    token: '',
    isLogin: false
  }),
  getters: {
    // 计算用户登录状态文本
    loginStatusText: (state) => state.isLogin ? `已登录(${state.username})` : '未登录'
  },
  actions: {
    // 模拟登录接口
    async login(username, password) {
      // 模拟接口请求延迟
      await new Promise(resolve => setTimeout(resolve, 1000))
      // 模拟登录成功返回数据
      if (username === 'admin' && password === '123456') {
        this.username = username
        this.token = 'fake-token-123456'
        this.isLogin = true
        return { code: 200, message: '登录成功' }
      } else {
        return { code: 400, message: '账号或密码错误' }
      }
    },
    // 退出登录
    logout() {
      this.username = ''
      this.token = ''
      this.isLogin = false
    }
  },
  // 开启持久化,只持久化 token 和 username
  persist: {
    paths: ['token', 'username']
  }
})

// 2. 编写登录组件(src/components/Login.vue)
// <template>
//   <div class="login-container">
//     <h2>用户登录</h2>
//     <div class="form-item">
//       <label>账号:</label>
//       <input v-model="username" type="text" placeholder="请输入账号">
//     </div>
//     <div class="form-item">
//       <label>密码:</label>
//       <input v-model="password" type="password" placeholder="请输入密码">
//     </div>
//     <button @click="handleLogin">登录</button>
//     <button @click="handleLogout" v-if="userStore.isLogin">退出登录</button>
//     <p class="status">{{ userStore.loginStatusText }}</p>
//   </div>
// </template>

// <script setup>
// import { ref } from 'vue'
// import { useUserStore } from '@/store/userStore'

// const userStore = useUserStore()
// const username = ref('')
// const password = ref('')

// const handleLogin = async () => {
//   if (!username.value || !password.value) {
//     alert('请输入账号和密码')
//     return
//   }
//   const res = await userStore.login(username.value, password.value)
//   alert(res.message)
//   if (res.code === 200) {
//     username.value = ''
//     password.value = ''
//   }
// }

// const handleLogout = () => {
//   userStore.logout()
//   alert('退出登录成功')
// }
// </script>

// 3. 在 App.vue 中引入使用
// <template>
//   <div id="app">
//     <Login />
//   </div>
// </template>

// <script setup>
// import Login from './components/Login.vue'
// </script>

练习案例2:购物车功能(进阶实战)

需求:实现购物车添加商品、删除商品、清空购物车、计算购物车商品数量和总价格,两个 Store 通信(用户登录后才能添加商品)。

// 1. 复用前面的 userStore(已实现登录状态)
// 2. 创建 cartStore(src/store/cartStore.js)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './userStore'

export const useCartStore = defineStore('cart', () => {
  // 购物车列表(商品格式:{ id, name, price, count })
  const cartList = ref([])
  // 总价格(计算属性)
  const totalPrice = computed(() => {
    return cartList.value.reduce((sum, goods) => sum + goods.price * goods.count, 0)
  })
  // 商品总数(计算属性)
  const totalCount = computed(() => {
    return cartList.value.reduce((sum, goods) => sum + goods.count, 0)
  })

  // 添加商品(需判断用户是否登录)
  const addToCart = (goods) => {
    const userStore = useUserStore()
    if (!userStore.isLogin) {
      alert('请先登录再添加商品!')
      return
    }
    // 判断商品是否已在购物车中,已存在则增加数量,否则添加新商品
    const existingGoods = cartList.value.find(item => item.id === goods.id)
    if (existingGoods) {
      existingGoods.count += goods.count
    } else {
      cartList.value.push(goods)
    }
  }

  // 删除商品
  const removeFromCart = (goodsId) => {
    cartList.value = cartList.value.filter(item => item.id !== goodsId)
  }

  // 清空购物车
  const clearCart = () => {
    cartList.value = []
  }

  return { cartList, totalPrice, totalCount, addToCart, removeFromCart, clearCart }
})

// 3. 编写购物车组件(src/components/Cart.vue)
// <template>
//   <div class="cart-container">
//     <h2>我的购物车</h2>
//     <div class="cart-empty" v-if="cartStore.totalCount === 0">
//       购物车为空,快去添加商品吧~
//     </div>
//     <div class="cart-list" v-else>
//       <div class="cart-item" v-for="goods in cartStore.cartList" :key="goods.id">
//         <span>{{ goods.name }}</span>
//         <span>¥{{ goods.price.toFixed(2) }}</span>
//         <span>数量:{{ goods.count }}</span>
//         <button @click="cartStore.removeFromCart(goods.id)">删除</button>
//       </div>
//       <div class="cart-footer">
//         <span>商品总数:{{ cartStore.totalCount }}</span>
//         <span>总价格:¥{{ cartStore.totalPrice.toFixed(2) }}</span>
//         <button @click="cartStore.clearCart">清空购物车</button>
//       </div>
//     </div>
//   </div>
// </template>

// <script setup>
// import { useCartStore } from '@/store/cartStore'

// const cartStore = useCartStore()
// </script>

// 4. 编写商品列表组件(src/components/GoodsList.vue)
// <template>
//   <div class="goods-list">
//     <h2>商品列表</h2>
//     <div class="goods-item" v-for="goods in goodsList" :key="goods.id">
//       <span>{{ goods.name }}</span>
//       <span>¥{{ goods.price.toFixed(2) }}</span>
//       <button @click="addGoodsToCart(goods)">添加到购物车</button>
//     </div>
//   </div>
// </template>

// <script setup>
// import { ref } from 'vue'
// import { useCartStore } from '@/store/cartStore'

// const cartStore = useCartStore()
// // 模拟商品列表
// const goodsList = ref([
//   { id: 1, name: 'Vue3 实战教程', price: 59.9, count: 1 },
//   { id: 2, name: 'Pinia 入门到精通', price: 39.9, count: 1 },
//   { id: 3, name: 'TypeScript 进阶', price: 69.9, count: 1 }
// ])

// const addGoodsToCart = (goods) => {
//   cartStore.addToCart(goods)
// }
// </script>

// 5. 在 App.vue 中组合所有组件
// <template>
//   <div id="app">
//     <Login />
//     <GoodsList />
//     <Cart />
//   </div>
// </template>

// <script setup>
// import Login from './components/Login.vue'
// import GoodsList from './components/GoodsList.vue'
// import Cart from './components/Cart.vue'
// </script>