WSの小屋

Vuex 到 Pinia:Vue 状态管理的进化之路

从 Vuex 到 Pinia,不仅是换了一个状态管理库,更是 Vue 生态从 Options API 时代到 Composition API 时代的一次设计哲学升级。本文将从 Vuex 的设计缺陷出发,解析 Pinia 为什么更好,以及如何从 Vuex 迁移到 Pinia。

为什么需要状态管理

组件树的通信困境

在没有状态管理时,Vue 组件之间的状态共享靠 props/emit 逐层传递:

   App          ← 全局状态放哪里?
  /   \
Header  Main      ← Header 需要用户信息,Main 也需要
       /   \
   Sidebar  Content ← Sidebar 需要主题,Content 需要文章列表
              /
          Comments   ← Comments 需要用户信息和文章 ID

状态跨层级传递时的痛点:

  • Prop drilling:中间层组件被迫传递它不关心的 prop
  • 兄弟通信:需要事件总线或 provide/inject
  • 状态同步:多个组件需要共享并同步修改同一份状态

Flux 架构与 Vuex

受 Redux / Flux 思想启发,Vue 团队推出了 Vuex,核心理念是「单一状态树 + 单向数据流」:


Actions  →  Mutations  →  State  →  View
  ↑                                |
  └──────── Dispatch ──────────────┘

  • State:全局唯一的响应式数据源
  • Mutations:唯一允许修改 State 的同步函数
  • Actions:处理异步逻辑,然后 commit mutation
  • Getters:派生状态(类似 computed)

Vuex 的设计缺陷

1. Mutation 是多余的

// Vuex
const store = {
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    async incrementAsync({ commit }) {
      await fetch('/api/increment')
      commit('increment')  // 必须通过 mutation 修改
    }
  }
}

// 使用
store.commit('increment')          // 同步
store.dispatch('incrementAsync')    // 异步

为什么必须有 mutation?Vuex 的设计参考了 Redux 的 reducer 必须是纯函数。但 Vue 的响应式系统本身就不要求同步修改——state.count++ 就是完全合法的响应式操作。Mutation 这一层带来的只是心智负担:

  • 简单修改要写 mutation + 调用 commit
  • 字符串类型的 mutation 名容易拼错,没有类型推导
  • DevTools 的时间旅行调试实际很少用到

2. 模块嵌套太复杂

// Vuex modules
const store = new Vuex.Store({
  modules: {
    user: {
      namespaced: true,
      state: () => ({ profile: null }),
      actions: {
        async fetchProfile({ commit }) { ... }
      },
      modules: {
        permissions: {
          namespaced: true,
          state: () => ({ list: [] }),
          getters: {
            canEdit: state => state.list.includes('edit')
          }
        }
      }
    }
  }
})

// 使用时路径超长
store.dispatch('user/permissions/fetchList')
store.getters['user/permissions/canEdit']

3. TypeScript 支持差

Vuex 4 的类型支持需要大量 ModuleTree<RootState> 类型声明,且 action/mutation 的参数类型推导几乎为零:

// 需要 Root State 类型
interface RootState {
  user: UserState
}

// 每个 module 需要定义 state 类型
interface UserState {
  profile: User | null
}

// action 的 context 没有类型推导
const actions = {
  fetchProfile(context) {  // context 是 any
    // context.commit 的 payload 也没有类型
  }
}

// 要获得类型安全,需要安装 vuex-smart-module 或手写大量类型体操

4. 没有 tree-shaking

Vuex 的所有功能都是整体引入的,即使用不到 module、hot-reload,也会打包进去。

Pinia:重新设计

设计哲学

Pinia 由 Vue 核心团队成员 Eduardo San Martin Morote 开发,也是 Vue Router 4 的作者。Pinia 的核心理念:

  1. 不要 mutation:直接修改 state 或用 action 修改
  2. 不要嵌套模块:每个 store 是扁平的,用 ID 区分
  3. TypeScript 优先:完善的类型推导,零配置
  4. Composition API:store 定义就是 composable 函数

Store 定义对比

// ===== Vuex =====
const userModule = {
  namespaced: true,
  state: () => ({
    profile: null as User | null,
    token: ''
  }),
  getters: {
    isLoggedIn: state => !!state.token
  },
  mutations: {
    SET_PROFILE(state, profile) {
      state.profile = profile
    },
    SET_TOKEN(state, token) {
      state.token = token
    }
  },
  actions: {
    async login({ commit }, { username, password }) {
      const { token, profile } = await api.login(username, password)
      commit('SET_TOKEN', token)
      commit('SET_PROFILE', profile)
    }
  }
}

// ===== Pinia =====
export const useUserStore = defineStore('user', () => {
  // state
  const profile = ref<User | null>(null)
  const token = ref('')

  // getters
  const isLoggedIn = computed(() => !!token.value)

  // actions
  async function login(username: string, password: string) {
    const res = await api.login(username, password)
    token.value = res.token
    profile.value = res.profile
  }

  return { profile, token, isLoggedIn, login }
})

使用对比

// Vuex
import { useStore } from 'vuex'

const store = useStore()
store.state.user.profile            // 无类型提示
store.getters['user/isLoggedIn']    // 字符串路径
store.dispatch('user/login', {       // 字符串路径
  username: 'admin',
  password: '123'
})

// Pinia
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
userStore.profile            // ✅ 完善类型提示
userStore.isLoggedIn         // ✅ 直接访问
userStore.login('admin', '123')  // ✅ 直接调用,参数类型安全
userStore.token = 'xxx'      // ✅ 可以直接修改 state

Pinia 的两种写法

Options Store(传统写法)

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  getters: {
    double: (state) => state.count * 2,
    // 访问其他 getter
    doublePlusOne(): number {
      return this.double + 1
    }
  },

  actions: {
    increment() {
      this.count++
  },
    async fetchCount() {
      this.count = await api.getCount()
    }
  }
})

Setup Store(Composition API 推荐)

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Eduardo')

  // getters
  const double = computed(() => count.value * 2)
  const doublePlusOne = computed(() => double.value + 1)

  // actions
  function increment() {
    count.value++
  }

  async function fetchCount() {
    count.value = await api.getCount()
  }

  return { count, name, double, doublePlusOne, increment, fetchCount }
})

两种写法效果等价,但 Setup Store 的优势:

  • 可以使用任何 Composition API(watchwatchEffect
  • 可以调用其他 composable(如 VueUse)
  • 更灵活的私有状态(不 return 就是私有的)

Pinia 进阶用法

跨 Store 调用

// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const profile = ref<User | null>(null)

  function setProfile(p: User) {
    profile.value = p
  }

  return { profile, setProfile }
})

// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  async function checkout() {
    // 直接调用其他 store
    const userStore = useUserStore()
    if (!userStore.profile) {
      throw new Error('请先登录')
    }

    await api.checkout({
      userId: userStore.profile.id,
      items: items.value
    })
  }

  return { items, checkout }
})

重置状态

const store = useCounterStore()

// 一键重置到初始状态(Options store 自动支持,Setup store 需手动实现)
store.$reset()

// Setup store 手动实现 reset
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const initialCount = 0

  function $reset() {
    count.value = initialCount
  }

  return { count, $reset }
})

批量修改状态

// $patch:批量修改,一次触发更新
store.$patch({
  count: store.count + 1,
  name: 'New Name'
})

// $patch 函数写法(适合复杂逻辑)
store.$patch((state) => {
  state.count++
  state.items.push({ id: 1, name: 'new' })
  state.user.profile = newProfile
})

订阅状态变化

// 订阅 state 变化
const unsubscribe = store.$subscribe((mutation, state) => {
  console.log('State changed:', mutation.type, state)

  // 持久化到 localStorage
  localStorage.setItem('cart', JSON.stringify(state.items))
})

// 订阅 action 执行
const unsubscribeAction = store.$onAction(({
  name,           // action 名称
  store,          // store 实例
  args,           // action 参数
  after,          // action 成功后的回调
  onError         // action 失败后的回调
}) => {
  console.log(`Action ${name} started`)

  after((result) => {
    console.log(`Action ${name} succeeded`, result)
  })

  onError((error) => {
    console.error(`Action ${name} failed`, error)
  })
})

// 清理订阅
unsubscribe()
unsubscribeAction()

持久化插件

// plugins/pinia-persist.ts
import { PiniaPluginContext } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复
  const saved = localStorage.getItem(store.$id)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // 订阅变化,自动保存
  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

// main.ts
import { createPinia } from 'pinia'
const pinia = createPinia()
pinia.use(persistPlugin)

或者使用 pinia-plugin-persistedstate

import { createPersistedState } from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(createPersistedState())

// 在 store 中声明持久化
export const useUserStore = defineStore('user', {
  state: () => ({ token: '', profile: null }),
  persist: {
    key: 'app-user',
    storage: sessionStorage,
    pick: ['token']  // 只持久化 token
  }
})

SSR 兼容(Nuxt)

// Nuxt 中 Pinia 自动注册
// stores/counter.ts
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  return { count }
})

// 任意组件中直接使用
const counter = useCounterStore()

// server-side 初始化
// plugins/init.server.ts
export default defineNuxtPlugin(async () => {
  const userStore = useUserStore()
  userStore.profile = await $fetch('/api/me')
})

从 Vuex 迁移到 Pinia

迁移策略

  1. 保留 Vuex,逐步添加 Pinia store
  2. 每次迁移一个 module
  3. 迁移完后移除 Vuex

迁移示例

// ===== 原始 Vuex module =====
const userModule = {
  namespaced: true,
  state: () => ({ profile: null, token: '' }),
  getters: {
    isLoggedIn: state => !!state.token,
    isAdmin: state => state.profile?.role === 'admin'
  },
  mutations: {
    SET_USER(state, { profile, token }) {
      state.profile = profile
      state.token = token
    },
    LOGOUT(state) {
      state.profile = null
      state.token = ''
    }
  },
  actions: {
    async login({ commit }, credentials) {
      const res = await api.login(credentials)
      commit('SET_USER', res)
    },
    async logout({ commit }) {
      await api.logout()
      commit('LOGOUT')
    }
  }
}

// ===== 迁移后的 Pinia store =====
export const useUserStore = defineStore('user', () => {
  // state
  const profile = ref<User | null>(null)
  const token = ref('')

  // getters
  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => profile.value?.role === 'admin')

  // actions
  async function login(credentials: LoginCredentials) {
    const res = await api.login(credentials)
    profile.value = res.profile
    token.value = res.token
  }

  async function logout() {
    await api.logout()
    profile.value = null
    token.value = ''
  }

  return { profile, token, isLoggedIn, isAdmin, login, logout }
})

// ===== 组件迁移 =====

// Before (Vuex)
import { useStore } from 'vuex'
import { computed } from 'vue'

const store = useStore()
const isLoggedIn = computed(() => store.getters['user/isLoggedIn'])
const login = () => store.dispatch('user/login', { username, password })

// After (Pinia)
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const { isLoggedIn } = storeToRefs(userStore)  // 保持响应性
const login = () => userStore.login(username, password)

迁移对照表

Vuex Pinia
state: () => ({}) const x = ref()
getters: {} const x = computed(() => ...)
mutations: {} 直接在 action 中修改 x.value = ...
actions: {} function x() { ... }
store.commit('mutation') 直接修改 store.x = ...
store.dispatch('action') 直接调用 store.x()
store.getters['module/name'] store.name
store.state.module.x store.x
mapState/mapGetters storeToRefs(store)
mapActions 解构 action 函数

Pinia 为什么没有 Mutation

Vuex 要求通过 mutation 修改状态,主要目的有两个:

  1. DevTools 时间旅行:记录每次 mutation,可以回到任意状态
  2. Mutation 必须同步,确保状态变化可预测

Pinia 的取舍:

  • DevTools 仍然可以追踪 state 变化(通过 reactive 的深响应实现)
  • 时间旅行功能很少被使用,不值得为此增加心智负担
  • Vue 的响应式系统本身就是同步追踪的,不需要额外约束

Pinia 放弃 mutation 是一种「实用主义」设计:用 10% 的功能损失换取 90% 的开发体验提升。

总结

维度 Vuex 4 Pinia
API 风格 Options API + Mutation Composition API
模块化 嵌套 modules + namespace 扁平 store + ID
TypeScript 差,需大量类型声明 优秀,零配置类型推导
体积 ~7KB ~3KB
Mutation 必需 移除
SSR 需要手动处理 开箱即用
DevTools
Composition API 通过 setup 写法支持 原生设计

如果你的项目还在用 Vuex,建议新功能用 Pinia,逐步迁移旧代码。如果是新项目,毫不犹豫选 Pinia。如果用 Nuxt 3+,Pinia 已经是内置的状态管理方案了。

Comments | 0条评论