01

菠萝是什么?

从 Vuex 到 Pinia,理解现代状态管理的进化之路

为什么需要状态管理?

CODE

const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
// 其他组件无法访问这个 count!
            
PLAIN ENGLISH

创建一个计数值并初始化为 0

计算属性:每当 count 变化时,double 自动更新

定义一个方法来增加计数值

但这些数据只在这个组件内可见,其他组件无法共享

在 Vue 组件中,我们经常需要管理状态。但当组件变多时,状态管理就会变得混乱——组件 A 的数据组件 B 无法直接访问,必须通过 props 和 emit 层层传递。这就是 Pinia 的用武之地。

Pinia 的诞生

🍍

为什么叫 Pinia?

Pinia 是西班牙语 "piña"(菠萝)的英文发音。菠萝是由许多小花组成的复合果实,就像 Pinia 是由多个独立但又相互关联的 store 组成。

Pinia 是 Vuex 的继任者,专为 Vue 3 设计,提供了更简洁、更类型安全的 API。

Vuex vs Pinia:核心差异

如果你用过 Vuex,下面的对比能帮你快速理解 Pinia 带来了哪些改进:

🗑️

不再有 Mutations

Vuex 要求通过 mutation 修改 state,actions 再调 mutation。Pinia 直接在 action 中修改 state,代码量减少 30%。

📦

模块化天生支持

Vuex 需要 modules + namespaced 来拆分 store。Pinia 每个 defineStore 就是一个独立模块,无需配置。

🔷

TypeScript 原生支持

Vuex 的类型推断一直是痛点。Pinia 从设计之初就考虑了类型安全,自动推断无需手写类型。

CODE — Vuex 写法

const store = {
  state: { count: 0 },
  mutations: {
    SET_COUNT(state, val) { state.count = val }
  },
  actions: {
    increment({ commit, state }) {
      commit('SET_COUNT', state.count + 1)
    }
  }
}
            
PLAIN ENGLISH

Vuex 要求定义 state、mutations、actions 三层结构

mutation 是唯一能修改 state 的地方

action 必须通过 commit 调用 mutation,多一层间接性

这种模式虽然严格,但代码冗余,学习曲线陡峭

💡
迁移建议

如果你有现有的 Vuex 项目,不需要一夜之间全部迁移到 Pinia。两者可以共存——在 Vue 应用中同时注册 Vuex 和 Pinia,逐步替换即可。新功能用 Pinia,老代码保持不变。

Pinia 的生态系统

Pinia 不仅是一个状态管理库,它还拥有完整的开发者生态。了解这些工具能让你在实际项目中如虎添翼。

🔧

Vue DevTools 集成

Pinia 与 Vue DevTools 深度集成,你可以在 DevTools 中查看所有 store 的状态树、追踪 action 调用历史、甚至进行时间旅行调试——回到任何一个历史状态查看当时的数据快照。这是排查状态相关 bug 的利器。

🔌

Nuxt 模块 @pinia/nuxt

如果你使用 Nuxt 3,Pinia 提供了官方模块 @pinia/nuxt,自动完成 Pinia 的注册和导入,支持 SSR 场景下的状态水合(hydration),让你在服务端渲染项目中无缝使用 Pinia。

💾

持久化插件

社区提供的 pinia-plugin-persistedstate 插件可以自动将 store 状态保存到 localStorage 或 sessionStorage,页面刷新后状态不会丢失。支持选择性地持久化特定 store 或特定字段。

状态管理的历史演进

📖
扩展知识点:从前端状态管理的历史看 Pinia 的意义

前端状态管理经历了漫长的演进。最早的 jQuery 时代,状态直接散落在 DOM 中,用全局变量维护。2015 年 Redux 随 React 诞生,引入了单向数据流和纯函数 Reducer 的理念,虽然严谨但样板代码过多。Vuex 沿袭了 Redux 的思路,增加了 mutation 层来保证可追踪性,但也带来了更多的模板代码和更高的学习成本。Pinia 的出现标志着状态管理进入了"轻量化加类型安全"的新时代——它保留了单向数据流的核心优势,同时去除了不必要的仪式感。Pinia 证明了一件事:好的架构不一定意味着复杂的代码。当你理解了这段历史,就能更深刻地理解 Pinia 每一个设计决策背后的思考——为什么去掉 mutation,为什么默认支持 TypeScript,为什么用 Setup Store 作为底层统一形式。

🎯
扩展知识点:什么时候该用 Pinia,什么时候不该用?

并非所有场景都需要 Pinia。如果你的应用只有几个组件,状态传递不超过两层,直接用 Vue 的 props 和 emit 就够了。引入 Pinia 的时机是:当多个不相关的组件需要访问同一份数据、当组件层级太深导致 props 逐层传递变得难以维护、当你的应用需要在页面切换时保持某些状态不被销毁。常见的使用场景包括:全局用户信息(登录状态、权限)、购物车数据、主题配置、通知消息队列等。记住一个原则:Pinia 是解决跨组件状态共享问题的工具,不要为了用而用。

互动小测验

Pinia 和 Vuex 的关系是什么?

以下哪项是 Pinia 相比 Vuex 的核心优势?

02

Pinia 的核心角色

认识 Store、State、Getters 和 Actions 之间的关系

Store:Pinia 的核心

CODE

export const useUserStore = defineStore('user', {
  state: () => ({ name: 'Eduardo' }),
  getters: { uppercaseName: (state) => state.name.toUpperCase() },
  actions: { updateName(newName) { this.name = newName } }
})
            
PLAIN ENGLISH

定义一个名为 'user' 的 store

状态:存储一个名为 name 的属性

getter:将 name 转换为大写

action:提供更新 name 的方法

Store 是 Pinia 的核心概念,它是一个包含状态、getter 和 action 的容器。

State、Getters 和 Actions

📦

State 是什么?

State 是 store 中存储的数据,类似于组件中的 data。不同之处在于,state 是全局共享的。

🔍

Getters 是什么?

Getters 是基于 state 的计算属性,类似于组件中的 computed。

⚙️

Actions 是什么?

Actions 是 store 中的方法,用于修改 state 或执行异步操作,类似于组件中的 methods。

在组件中使用 Store

定义好 store 之后,最关键的是在组件中正确使用它。Pinia 提供了多种方式来访问和修改状态。

CODE

import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// 响应式解构——保持响应性
const { name, uppercaseName } = storeToRefs(userStore)

// 方法可以直接解构
const { updateName } = userStore

// 也可以直接修改 state
userStore.$patch({ name: 'Ana' })
            
PLAIN ENGLISH

从 store 文件导入 useUserStore

导入 storeToRefs 工具函数

调用 useUserStore() 获取 store 实例

用 storeToRefs 解构——这样解构出来的值仍然保持响应式

如果直接解构(const { name } = store),响应式会丢失

方法不需要 storeToRefs,直接解构即可

用 $patch 批量修改多个 state 字段

💡
新手常见坑

很多初学者直接用 const { name } = store 解构,发现数据不会更新。这是因为解构操作会把响应式引用"展开"成普通值。必须使用 storeToRefs 才能保持响应性——这就像你不能把一个活的水母从水里拿出来还能指望它活着一样。

Getters 的高级用法

Getters 不只能做简单的数据转换,它还能接受参数、组合其他 getter、甚至缓存计算结果。

CODE — Getters 进阶

export const useProductStore = defineStore('products', {
  state: () => ({
    products: [],
    filter: 'all'
  }),
  getters: {
    // 基础 getter:计算总数
    totalCount: (state) => state.products.length,
    // 带参数的 getter:通过返回函数实现
    getById: (state) => {
      return (id) => state.products.find(p => p.id === id)
    },
    // 组合其他 getter
    filteredProducts(state) {
      if (state.filter === 'active') {
        return state.products.filter(p => p.active)
      }
      return state.products
    }
  }
})
            
PLAIN ENGLISH

定义一个产品 store,包含产品列表和过滤器状态

基础 getter:直接返回产品总数

带参数的 getter:返回一个函数,调用时传入 id 来查找特定产品

注意:带参数的 getter 不会被缓存,每次调用都会重新计算

组合 getter:根据 filter 的值返回不同的过滤结果

📖
扩展知识点:Getter 缓存机制详解

Pinia 的 getter 默认带有缓存——只有当它依赖的 state 发生变化时才会重新计算。这意味着如果一个 getter 被多个组件引用,它只会计算一次,所有组件共享同一个计算结果。但如果你用返回函数的方式(getById 那种写法),缓存就会失效,因为每次调用都会创建新的函数执行环境。在性能敏感的场景中,你应该尽量使用不带参数的 getter,通过组合多个 getter 来实现复杂的查询逻辑,而不是用一个巨型函数接受各种参数。理解缓存机制是写出高性能 Pinia 代码的关键。

Action 的异步模式

📖
扩展知识点:在 Action 中处理异步操作的最佳实践

Action 天然支持 async 和 await,这让异步操作变得非常直观。但在实际项目中,你需要注意几个关键点:第一,action 中的错误应该被 try-catch 包裹,而不是让错误默默消失。第二,当多个组件可能同时触发同一个异步 action 时(比如页面加载时多个组件都去获取用户信息),应该使用去重机制避免重复请求。第三,长时间运行的异步操作应该提供 loading 状态,让用户知道系统正在工作。一个常见的模式是在 state 中维护 loading 和 error 字段,在 action 中正确地设置它们。

🎯
扩展知识点:$reset 方法与状态重置

Pinia 为每个 store 自动提供 $reset 方法,它能把 state 恢复到初始值。这在用户退出登录、切换账号等场景中特别有用。但要注意一个细节:如果你使用 Setup Store(函数式写法),Pinia 默认不提供 $reset 方法,因为函数式写法中 state 的初始值不像 Option Store 那样有明确的初始函数。解决方案是在 Setup Store 中手动定义一个 reset action,或者使用 Pinia 插件来统一添加 reset 功能。理解这个差异能帮你避免迁移到 Setup Store 时遇到的坑。

🔄

异步 Action 模式

使用 async/await 处理 API 调用,配合 loading 和 error 状态管理,确保用户获得良好的加载体验和错误提示。

🛡️

错误处理模式

在 action 中用 try-catch 包裹异步操作,将错误信息写入 error 状态字段,组件根据 error 状态显示错误提示。

🚀

请求去重模式

当多个组件可能同时触发同一请求时,通过 Promise 缓存实现去重——第一个请求完成前,后续调用复用同一个 Promise。

互动小测验

Actions 的主要作用是什么?

为什么从 store 解构数据时需要使用 storeToRefs?

03

状态怎么流动

数据在 createPinia → defineStore → 组件之间如何流转

三个关键角色

想象一个图书馆系统——管理员(createPinia)负责总控,书架(Store)存放数据,读者(Component)来查阅和修改。

🍍
createPinia
📦
Store
🧩
组件
点击「下一步」开始动画

createPinia 在做什么?

这段代码来自 packages/pinia/src/createPinia.ts——它是整个 Pinia 的起点。

CODE

export function createPinia(): Pinia {
  const scope = effectScope(true)
  const state = scope.run(() =>
    ref<Record<string, StateTree>>({})
  )!
  const pinia: Pinia = markRaw({
    install(app: App) {
      app.provide(piniaSymbol, pinia)
    },
    _s: new Map<string, StoreGeneric>(),
    state,
  })
  return pinia
}
            
PLAIN ENGLISH

创建一个作用域,用来统一管理所有 store 的响应式副作用

在这个作用域里创建一个全局 state 对象——所有 store 的数据都存在这里

用 markRaw 标记 pinia 对象本身不需要被 Vue 追踪变化

install 方法:Vue 调用 app.use(pinia) 时会执行这里

通过 provide 把 pinia 注入到整个应用——任何组件都能访问到它

_s 是一个 Map,存放所有已注册的 store 实例

state 是全局状态树,key 是 store id,value 是该 store 的 state

返回 pinia 实例

💡
Aha! Pinia 把所有 store 的 state 集中存储在一个大对象里。这样 Vue 的 DevTools 可以一次性看到全部状态,也方便做时间旅行调试。这就像图书馆管理员有一个总目录,可以瞬间查阅任何书架的信息。

当组件开始聊天……

想象你的 Vue 组件们在一个群聊里讨论怎么从 store 拿数据。

Store 之间的组合

在真实项目中,不同 store 之间经常需要互相引用。Pinia 让这种组合变得自然且直观。

CODE

import { useCartStore } './cart'

export const useCheckoutStore = defineStore('checkout', () => {
  const cart = useCartStore()

  const total = computed(() =>
    cart.items.reduce(
      (sum, item) => sum + item.price * item.qty,
      0
    )
  )

  async function checkout() {
    await apiSubmitOrder({ items: cart.items, total })
    cart.clear()
  }

  return { total, checkout }
})
            
PLAIN ENGLISH

在 checkout store 中导入 cart store

获取 cart store 实例

计算购物车总价:遍历每个商品,价格乘以数量再求和

结账函数:提交订单到后端

成功后清空购物车

把计算属性和方法暴露出去

📌
避免循环依赖

如果 store A 引用了 store B,而 store B 又引用了 store A,就会形成循环依赖。解决方案:把共享逻辑抽取到第三个 store,或者使用函数内部延迟引用(在 action 函数体内调用 useXxxStore(),而不是在 store 定义的顶层调用)。

SSR 场景下的 Pinia

📖
扩展知识点:服务端渲染中的状态管理

在 Nuxt 或其他 SSR 框架中使用 Pinia 时,需要特别注意状态的隔离问题。在服务端,多个用户共享同一个 Node.js 进程,如果 store 实例被复用,用户 A 的数据就可能泄露给用户 B。解决方案是:在每次请求中创建全新的 Pinia 实例,确保状态隔离。Nuxt 的 @pinia/nuxt 模块已经自动处理了这个问题,但如果你自己搭建 SSR 环境,就需要手动管理 Pinia 实例的生命周期。理解这个原理能帮你避免在生产环境中出现难以排查的数据串漏 bug。

🎯
扩展知识点:Pinia 与 Vue Router 的协作

在真实项目中,路由切换往往需要触发状态更新。比如从商品列表页跳转到详情页时,需要根据路由参数加载对应的商品数据。最佳实践是在组件的 setup 函数或路由守卫中调用 store 的 action,而不是在 action 内部直接依赖路由。这样做的好处是保持 store 的纯净性——store 不应该知道路由的存在,它只负责管理数据。如果需要根据路由参数初始化状态,应该在组件层面桥接路由和 store,让数据流向更加清晰可追踪。

📌
扩展知识点:大型项目中的 Store 组织策略

随着项目规模增长,store 的数量会越来越多。推荐的组织方式是按业务领域划分——每个领域一个目录,包含该领域的所有 store 和相关类型定义。例如 stores/ 目录下可以有 user/、cart/、product/ 等子目录。每个 store 文件保持小而聚焦,如果一个 store 超过 200 行,就应该考虑拆分。对于跨领域的共享逻辑,使用 composable 函数(组合式函数)而不是把所有东西都塞进 store。这种组织方式让团队成员可以独立开发不同领域,减少合并冲突。

🏗️

按领域划分 Store

将 store 按业务领域组织到不同目录,每个 store 专注一个领域。避免创建一个巨型 store 管理所有状态。

🧩

Composable 桥接模式

使用 composable 函数桥接 store 和组件,封装复杂的状态逻辑,让组件代码保持简洁。

🧪

Store 测试策略

单独测试 store 的 action 和 getter,使用 createPinia() 创建测试专用实例,隔离测试环境。

巩固一下

当多个组件需要共享同一份用户数据时,Pinia 把这些数据存在哪里?

04

Pinia 的巧妙设计

探索 defineStore、$patch、订阅机制和 storeToRefs 背后的智慧

Option Store vs Setup Store

Pinia 提供了两种定义 Store 的风格——就像写文章可以用提纲式(Options)或自由散文式(Setup)。

CODE — Option Store

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, name: 'Pinia' }),
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  actions: {
    increment() { this.count++ },
  },
})
            
PLAIN ENGLISH

用 Options 风格定义一个叫 counter 的 store

state:初始化两个数据字段

getter:根据 count 计算双倍值

action:提供修改 count 的方法

CODE — Setup Store

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Pinia')
  const doubleCount = computed(() => count.value * 2)
  function increment() { count.value++ }
  return { count, name, doubleCount, increment }
})
            
PLAIN ENGLISH

用 Setup 风格——就像写 Vue 3 的 setup() 函数

ref() 定义响应式数据(相当于 state)

computed() 定义计算属性(相当于 getters)

普通函数就是 action

最后把所有东西 return 出去

💡
Aha! 其实 Option Store 内部会被转换成 Setup Store——看源码中的 createOptionsStore 函数,它把 state/getters/actions 拆开后再调用 createSetupStore。所以 Setup Store 才是 Pinia 的"母语"。

SSR 与 Pinia:服务端渲染注意事项

如果你的项目使用 Nuxt 或手动配置 SSR,需要注意 Pinia 在服务端的特殊行为。在 SSR 环境中,每个请求都会创建一个新的 Pinia 实例,避免不同用户共享状态。

CODE — SSR 中注册 Pinia

// server.js (Express)
app.get('*', async (req, res) => {
  const pinia = createPinia()
  const app = createSSRApp(App)
  app.use(pinia)

  // 预取数据到 store
  const userStore = useUserStore(pinia)
  await userStore.fetchUser()

  const html = await renderToString(app)
  res.send(html)
})
            
PLAIN ENGLISH

每个 HTTP 请求都要创建新的 Pinia 实例

创建 Vue 应用并注册 Pinia

注意:useUserStore 必须传入 pinia 参数

在渲染之前预取数据

渲染 HTML 并返回给客户端

⚠️
SSR 常见错误

最典型的错误是在模块顶层调用 useStore()——这会在模块加载时执行,导致所有用户共享同一个 store 实例。正确做法是在 setup() 函数或 data() 钩子内部调用。

$patch:批量修改的利器

想象你在整理房间——与其一件一件挪动家具,不如一次性规划好全部位置。$patch 就是这个"一次性规划"。

CODE

store.$patch((state) => {
  state.items.push(' newItem')
  state.hasChanged = true
})
            
PLAIN ENGLISH

用函数式 $patch 批量修改 state

往数组里添加一个新元素

同时标记 hasChanged 为 true

🔔

订阅机制:$subscribe

当 state 发生变化时,你想收到通知?用 store.$subscribe()。Pinia 源码中用 watch 深度监听整个 state,每当有变化就触发所有注册的回调函数。这就像在图书馆里注册了"新书到馆通知"——有变化,你第一时间知道。

storeToRefs:解构的魔法

如果你直接解构 store,响应式会丢失。storeToRefs 就是来解决这个问题的。

CODE — storeToRefs 源码

export function storeToRefs<SS>(store: SS) {
  const rawStore = toRaw(store)
  const refs = {} as StoreToRefs<SS>
  for (const key in rawStore) {
    const value = rawStore[key]
    if (value.effect) {
      refs[key] = computed({
        get: () => store[key],
        set: (val) => { store[key] = val }
      })
    } else if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    }
  }
  return refs
}
            
PLAIN ENGLISH

拿到 store 的原始对象(去掉 Vue 的代理层)

创建一个空对象来存放转换后的 ref

遍历 store 的每个属性

如果属性有 effect(是 computed getter),包装成可写的 computed

getter:从 store 读取最新值

setter:写入时直接修改 store

如果是 ref 或 reactive,用 toRef 保持引用

返回所有转换后的响应式引用

💡
Aha! 注意那个 value.effect 的判断——这是检测一个值是不是 computed 的巧妙方式。Vue 没有提供 isComputed() API,所以 Pinia 通过检查内部是否有 effect 属性来判断。这是阅读优秀源码才能学到的实战技巧。

插件系统:扩展 Pinia 的能力

Pinia 的插件系统让你可以在每个 store 创建时注入通用逻辑——比如持久化、日志、权限检查等。

CODE — 持久化插件

function piniaPluginPersist({ store }) {
  const saved = localStorage.getItem(store.$id)
  if (saved) store.$patch(JSON.parse(saved))

  store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

const pinia = createPinia()
pinia.use(piniaPluginPersist)
            
PLAIN ENGLISH

定义一个 Pinia 插件函数

从 localStorage 读取该 store 之前保存的数据

如果有数据,用它来恢复 store 的状态

订阅 store 的变化

每当 state 变化,自动保存到 localStorage

创建 Pinia 实例并注册插件

💡
插件能做什么?

Pinia 插件可以:(1) 给每个 store 添加新属性或方法;(2) 监听 store 变化做额外处理;(3) 在 store 初始化时执行逻辑。常见用途包括持久化、路由同步、错误上报、DevTools 集成等。社区有 pinia-plugin-persistedstate 等成熟插件可直接使用。

$patch 的两种调用方式

📖
扩展知识点:$patch 对象式与函数式的差异

$patch 有两种调用方式。第一种是对象式,直接传入一个包含要修改字段的对象,适合简单的属性替换。第二种是函数式,传入一个接收 state 的函数,在函数体内自由修改,适合复杂的数组操作或条件修改。关键区别在于:对象式会触发一次响应式更新,函数式也只触发一次,但函数式可以执行任意复杂的逻辑。当你需要同时修改多个字段并且它们之间有依赖关系时,函数式是唯一选择。此外,使用函数式 $patch 修改数组时,可以直接使用 push、splice 等方法,而对象式无法做到这一点。

🎯
扩展知识点:Pinia 的调试技巧

Pinia 提供了 $onAction 方法来监听所有 action 的调用。你可以用它来实现日志记录、性能分析和错误追踪。$onAction 接受一个回调函数,其中包含 action 的名称、参数、执行结果和执行时间。结合 $subscribe 监听状态变化,你可以构建完整的审计日志。在实际项目中,建议在开发环境启用这些监听,生产环境只保留错误追踪,避免性能开销。另外,Vue DevTools 的 Pinia 面板可以让你查看每次 action 调用前后的状态变化,是调试时不可或缺的工具。

📝

Action 日志插件

使用 $onAction 在每次 action 调用时记录日志,包括参数、返回值和执行时间,方便调试和性能分析。

🔒

状态快照模式

通过 $subscribe 监听变化,在关键节点保存状态快照到数组,实现自定义的撤销和重做功能。

🌐

SSR 水合模式

在服务端将 Pinia 状态序列化到 HTML 中,客户端通过 $patch 恢复状态,避免重复请求。

检验你的理解

Option Store 和 Setup Store 在 Pinia 内部是如何处理的?