Pinia vs Vuex深度探索:状态管理的进阶之道

一、前言

对于使用 Vue 技术栈的同学来说,状态管理库的选择相对较少,通常集中在 Vuex 和 Pinia 上。因此,在面试中,面试官经常会问到:"Vuex 和 Pinia 有什么区别?你为什么会选择它?"此外,面试官还可能关注 Store 中数据的流转和响应式问题,这时就会涉及到另一个常用的 API:storeToRefs

除了这些核心内容,Pinia 还提供了一些非常实用的 API,比如 $patch$reset$subscribe 等,这些功能可以帮助开发者更高效地管理状态。不过,不知道大家是否思考过一个问题:如果将响应式对象绑定到全局的 window 对象中,是否可以替代 Pinia 呢?

接下来,我们将深入探讨这些内容,帮助大家更好地理解状态管理的实现原理和最佳实践。

二、Vuex vs Pinia

如果你使用过 react 相关的状态管理后再使用 vue 相关的状态管理,你就会觉得 vuexpinia 真的十分简便。其实个人觉得 vuexpinia 都是比较好上手的,只是 pinia 的设计更贴近 composition API,因此对于 vue3 的开发者来说非常友好。

Pinia 有几个关键的设计点:

1. 更简洁直观的 API 设计

去除 Mutation :Pinia 合并了 Vuex 的 Mutation 和 Action,直接通过 Actions 处理同步和异步操作,减少代码复杂度。 更直观的 Composition API 风格 :与 Vue 3 的 Composition API 高度契合,可以通过 refcomputed 等直接定义响应式状态,代码更简洁。

2. 更完美的 Typescript 支持

Pinia 原生支持 TypeScript,类型推断更加完善,无需额外配置即可享受类型安全。而 Vuex 则需要借助复杂的手动类型定义

3. 模块化设计更灵活

更自然的模块化: 每个 Store 独立定义(defineStore),无需手动分模块,天然支持代码分割

无需嵌套模块: Pinia 通过多个独立的 Store 组织状态,每个 Store 都是扁平化的,避免 Vuex 中嵌套模块的复杂性。

自动代码分割: Store 可以按需加载,天然支持代码拆分(配合构建工具),优化应用体积。

4. 更轻量高效

体积小:Pinia 的体积比 Vuex 更小(压缩后约 1KB),对应用性能更友好。

体验优 :Pinia 基于 Vue 3 的响应式系统(reactiveref),性能与开发体验更优。

Pinia的核心概念

  1. State: 用于存放数据,有点儿类似 data 的概念;
  2. Getters: 用于获取数据,有点儿类似 computed 的概念;
  3. Actions: 用于修改数据,有点儿类似 methods 的概念;
  4. Plugins: Pinia 插件。

Pinia 与 Vuex 代码分割机制

假设项目有 3 个 Store(userjobpay)和 2 个页面(首页、个人中心),分别使用 Pinia 和 Vuex 对其状态管理。

Vuex 的代码分割

模块合并打包 :Vuex 在构建时会将所有模块(如 userjobpay)合并到一个包中。例如,若首页仅需 job store,但打包时会强制包含全部模块,导致首屏加载冗余代码,影响性能。

依赖耦合:即使页面仅使用部分模块,Vuex 也会将所有模块代码引入到同一 chunk 中,无法实现按需加载。

解决方案: 经常在首页优化时,会考虑这个场景,一般处理方案时去做 Vuex 的按需加载,beforeCreate 时候,可以去判断当前页面需要加载哪些 store,利用这个 API 可以实现$store.registerModule

详情可以参考:segmentfault.com/a/119000003...

Pinia 的代码分割

按需加载 :Pinia 通过动态依赖检查,仅打包当前页面引用的 Store。例如,首页引用 job store 时,构建工具(如 Webpack/Vite)会自动分离该 Store 到独立 chunk,其他未使用的 Store(如 userpay)不会混入首页代码。

模块独立性 :每个 Store 通过 defineStore 独立定义,天然支持代码分割,无需手动配置命名空间或模块拆分

Pinia与Vuex的嵌套模块

Vuex 的嵌套模块

通过 modules 显式划分模块层级(如 user/profileuser/settings),适合高度复杂的状态树。

tree 复制代码
src/
  store/
    index.js          # 根 Store
    modules/
      user/           # 用户模块(父模块)
        index.js      # 用户模块入口
        profile.js    # 子模块:用户资料
        settings.js   # 子模块:用户设置
      cart.js         # 购物车模块(独立模块)
js 复制代码
import Vue from 'vue';
import Vuex from 'vuex';
import user from './modules/user'; // 导入用户模块(包含嵌套子模块)
import cart from './modules/cart'; // 导入购物车模块

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    user, // 嵌套模块
    cart  // 平级模块
  }
});
js 复制代码
import profile from './profile';
import settings from './settings';// store/modules/user.js
export default {
  namespaced: true,//开启命名空间,避免与其他模块冲突
  state: { /* ... */ },
  modules: {
    profile: { /* ... */ },
    settings: { /* ... */ }
  }
};

模块支持 namespaced: true,避免 Action/Mutation 的命名冲突:

js 复制代码
// 调用其他模块的 Action
dispatch('moduleA/someAction', payload, { root: true });

缺点

  • 复杂度高:嵌套模块需配置 statemutationsactionsgetters 和子模块,代码量显著增加,学习曲线陡峭。
  • 对 Typescript 支持繁琐
  • 过度设计风险:中小型项目可能因过度分层导致开发效率下降(例如为简单功能创建多个模块)。

Pinia 的嵌套模块

Pinia 的每个 Store 天然独立,无需手动配置模块或命名空间。也正是因为天然独立,Store 可以按需导入(例如配合路由懒加载),减少打包体积。每个 Store 独立维护自己的状态、计算和操作。

如果 pinia 想要实现类似于 vuex 嵌套模块,来处理复杂状态,可以使用以下三种方案:

  1. 按业务域组织 Store 文件:
tree 复制代码
src/
  stores/
    user/
      index.ts       # 主 Store
      profile.ts     # 子逻辑(通过组合函数复用)
      settings.ts
    product/
      index.ts
      variants.ts
  1. 将子逻辑拆分为组合函数,供多个 Store 复用:
ts 复制代码
// stores/user/profile.ts
export const useUserProfile = () => {
  const profile = ref<UserProfile>({});
  const fetchProfile = async () => { /* ... */ };
  return { profile, fetchProfile };
};

// stores/user/index.ts
export const useUserStore = defineStore('user', () => {
  const { profile, fetchProfile } = useUserProfile();
  return { profile, fetchProfile };
});
  1. 通过直接导入调用其他 Store
ts 复制代码
// stores/cart.ts
export const useCartStore = defineStore('cart', {
  actions: {
    checkout() {
      const userStore = useUserStore();
      if (!userStore.isLoggedIn) {
        // 调用其他 Store 的 Action
        userStore.showLoginModal();
      }
    }
  }
});

当然 pinia 并不是完美的,而是采用了牺牲层级换取了简洁和灵活

缺点:

  • 当 Store 数量激增时(如超过 50 个),需自行制定目录规范(例如按业务域划分目录),否则可能陷入"Store 地狱"。
  • Store 之间的依赖需手动处理(例如通过 useOtherStore() 调用),缺乏显式的层级关系。

Pinia与Vuex的按需导入

Vuex 的 registerModule 动态注册模块

Vuex 的 registerModule 允许在运行时动态注册模块。这个在下述这种场景中非常有用:假设系统需要根据用户权限动态加载不同功能模块

tree 复制代码
src/
  store/
    modules/
      core/              # 基础模块(始终加载)
        user.ts
      plugins/           # 动态模块目录
        analytics.ts     # 数据分析模块(仅管理员可见)
        auditLog.ts      # 操作日志模块(仅审计员可见)
  1. 定义好 analytics 和 auditLog 模块
  2. 根据权限动态注册:
ts 复制代码
// 用户登录后动态加载模块
import { Store } from 'vuex';
import analyticsModule from './modules/plugins/analytics';
import auditLogModule from './modules/plugins/auditLog';

export function loadPrivilegedModules(store: Store<any>, userRole: string) {
  // 管理员加载数据分析模块
  if (userRole === 'admin') {
    store.registerModule(['plugins', 'analytics'], analyticsModule);
  }

  // 审计员加载操作日志模块
  if (userRole === 'auditor') {
    store.registerModule(['plugins', 'auditLog'], auditLogModule);
  }
}
  1. 在组件中按需使用
html 复制代码
<template>
  <!-- 动态模块的 UI 控制 -->
  <AnalyticsDashboard v-if="$store.hasModule(['plugins', 'analytics'])" />
</template>

<script>
export default {
  mounted() {
    // 检查模块是否已加载
    if (this.$store.hasModule(['plugins', 'analytics'])) {
      this.$store.dispatch('plugins/analytics/trackPageView');
    }
  }
};
</script>

pinia 的按需引入

Pinia 没有内置的动态注册机制,但是可以通过以下模式模拟:

  1. 动态创建 store
ts 复制代码
// 动态加载并创建 Store
let analyticsStore: Store | null = null;

if (needsAnalytics) {
  const analyticsModule = await import('./plugins/analytics');
  analyticsStore = analyticsModule.useAnalyticsStore();
}
  1. 卸载时清理 store
ts 复制代码
// 重置 Store
const resetStore = () => {
  analyticsStore?.$reset();
  analyticsStore = null;
};

Pinia与Vuex持久化处理

为什么需要持久化处理?这个是什么?

当用户刷新浏览器页面(按 F5 或 Cmd+R)时,当前的 JavaScript 执行环境会被完全重置。内存中的所有变量,包括 Pinia store 里的 state,都会丢失。刷新后,应用会重新初始化,Pinia store 会回到其定义的初始状态,而不是用户刷新前的状态。

如果你的应用依赖于某些状态(如用户登录信息、购物车数据、用户偏好等)需要在页面刷新后仍然保留,就是需要将状态持久化存储到本地(如stroage中)

Vuex持久化处理

  1. 安装依赖
bash 复制代码
npm install vuex-persistedstate --save
  1. 直接使用
  • storage:可以设置为 window.localStoragewindow.sessionStorage,决定数据存储在哪里
  • paths:可以指定只持久化部分状态,例如只保存 user 数据
  • 插件会自动处理状态的序列化和反序列化
js 复制代码
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import createPersistedState from 'vuex-persistedstate'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    user: null,
    count: 0
  },
  mutations: {
    setUser(state, user) {
      state.user = user
    },
    increment(state) {
      state.count++
    }
  },
  plugins: [
    createPersistedState({
      storage: window.localStorage, // 指定存储方式,默认为 localStorage
      paths: ['user'] // 可选,指定需要持久化的状态路径,不指定则全部持久化
    })
  ]
})

pinia持久化处理

  1. 安装插件
bash 复制代码
npm install pinia-plugin-persistedstate
  1. 配置 Pinia
ts 复制代码
// main.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);

app.use(pinia);
  1. 标记需要持久化的 Store
ts 复制代码
// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({ token: '' }),
  persist: true, // 开启持久化
});
  1. 自定义配置
ts 复制代码
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] }),
  persist: {
    key: 'my-cart', // 存储的键名(默认是 store id)
    storage: sessionStorage, // 指定存储方式(默认 localStorage)
    paths: ['items'], // 仅持久化部分状态
  },
});

总结

其实 vuex 和 pinia 没有谁优谁劣的问题,只是它俩的使用场景有所不同:

  1. vuex 比较适合复杂的大型应用中,因为有比较严格的状态分层,比如超大项目需要嵌套模块明确层级关系的
  2. pinia 比较适合中小型应用中,因为Pinia 的简洁 API 和灵活性,能够快速上手并减少开发负担。

三、storeToRefs

Pinia 的 Store 是一个普通的 JavaScript 对象,虽然它的状态(state)和计算属性(getters)本身是响应式的,但在组件中直接解构它们时,会失去响应性。

ts 复制代码
  state: () => {
    return {
      hello: 'hello world',
      myList: []
    }
  }
//使用:
import { mainStore } from '../store/index'
import { storeToRefs } from 'pinia'

const store = mainStore()

const { hello: myHello } = storeToRefs(store)//发现失去响应性
  1. 简单数据类型:会失去响应性
  2. 引用数据类型:不会失去响应性

针对上述解构导致其失去响应性,就可以采用 storeToRefs 这个

storeToRefs 的作用 是将 Store 中的状态(state)和计算属性(getters)转换为响应式的 ref 对象。这样,在组件中解构它们时,仍然可以保持响应性。

ts 复制代码
const counterStore = useCounterStore();
const { count, doubleCount } = storeToRefs(counterStore); // 保持响应性

响应式解构陷阱

在 vue 中,响应式数据的解构陷阱是一个常见的坑,尤其在使用 vue3 的 composition API 或 Pinia 等状态管理库时。

响应式解构陷阱:响应式数据(如 ref、reactive、Pinia Store 等状态)是通过 vue 响应式系统实现的。当你直接解构这些响应式数据时,可能会丢失它们的响应性。

出现这个解构陷阱的原因 :vue 的响应式系统是通过代理(Proxy)实现的。当你使用 reactiveref 创建响应式数据时,Vue 会为这些数据创建一个代理对象。这个代理对象会拦截对数据的访问和修改,从而实现响应性。

然而,当你直接解构响应式数据时,实际上是将代理对象中的值复制到一个普通变量中。这个普通变量与原始的代理对象没有任何联系,因此失去了响应性

避免响应式陷阱

  1. toRefs:用于 reactive 对象,将响应式对象的属性转为 ref 对象,从而保持响应性。
ts 复制代码
import { reactive, toRefs } from 'vue';

const state = reactive({
  count: 0,
});

const { count } = toRefs(state); // 保持响应性
  1. storeToRefs:用于 pinia store
ts 复制代码
import { storeToRefs } from 'pinia';
import { useCounterStore } from './stores/counter';

const counterStore = useCounterStore();
const { count } = storeToRefs(counterStore); // 保持响应性
  1. 直接访问响应式对象:不需要解构
  2. 使用 computed
ts 复制代码
import { reactive, computed } from 'vue';

const state = reactive({
  count: 0,
});

const doubleCount = computed(() => state.count * 2); // 响应式

原理

在 vue 中 storeToRefs 代码的实现:

ts 复制代码
function storeToRefs(store) {
    // See https://github.com/vuejs/pinia/issues/852
    // It's easier to just use toRefs() even if it includes more stuff
    if (isVue2) {
        // @ts-expect-error: toRefs include methods and others
        return toRefs(store);
    }
    else {
        store = toRaw(store);
        const refs = {};
        for (const key in store) {
            const value = store[key];
            if (isRef(value) || isReactive(value)) {
                // @ts-expect-error: the key is state or getter
                refs[key] =
                    // ---
                    toRef(store, key);
            }
        }
        return refs;
    }
}

整体思路:

  1. 遍历 store 的所有属性
  2. 如果属性是响应式(isRef 或 isReactive),则将其转为 ref 对象
  3. 返回一个包含这些 ref 对象的普通对象

细节:

  • store = toRaw(store):使用 toRaw 获取 Store 的原始对象,避免处理代理对象。

toRaw 用于获取响应式对象的原始对象。在 Vue 3 中,响应式对象是通过 Proxy 实现的,toRaw 可以绕过 Proxy,直接访问原始对象。这样可以避免在处理响应式对象时产生不必要的副作用。

  • if (isRef(value) || isReactive(value)):判断当前属性是否是响应式的(isRefisReactive)。
  • refs[key] = toRef(store, key):如果属性是响应式的,使用 toRef 将其转换为 ref 对象,并存储到 refs 对象中。

toRef 用于将响应式对象的某个属性转换为 ref 对象。与 ref 不同,toRef 创建的 ref 对象会与原始属性保持同步,即修改 ref 对象的值会同步修改原始属性,反之亦然。

为什么这里需要使用 isRef 和 isReactive 对属性进行过滤?

pinia 中的 store 可能包含以下属性:

  • state: 响应式对象(reactive 包裹)
  • getters: 计算属性(computed 生成的 ref
  • actions: 普通方法(非响应式)

手动遍历并筛选 isRef(value) || isReactive(value),可以 仅转换 state 和 getters,排除 actions。

四、其他 API

$patch

可以批量的进行数据的修改。

  1. 对象形式:这种形式某些情况不能使用,特别是针对引用数据类型
ts 复制代码
// 对象形式
counterStore.$patch({
  count: 10,
  name: 'Vue 3',
});
  1. 函数形式:
ts 复制代码
// 函数形式
counterStore.$patch((state) => {
  state.count += 5;
  state.name = 'Pinia Rocks';
  stare.arr.push({})//这个对象形式中无法实现
});

$reset

主要是将我们的数据进行重置操作,也就是将我们 state 里面定义的数据,全部初始化。

ts 复制代码
import { useCounterStore } from './stores/counter';

const counterStore = useCounterStore();

// 修改状态
counterStore.count = 10;
counterStore.name = 'Vue 3';

console.log(counterStore.count); // 输出: 10
console.log(counterStore.name);  // 输出: Vue 3

// 重置状态
counterStore.$reset();

console.log(counterStore.count); // 输出: 0
console.log(counterStore.name);  // 输出: Pinia

注意:

  • $reset 只会重置状态(state),不会影响计算属性(getters)或方法(actions)。
  • 操作不可逆

$subscribe

根据名字它是订阅 的意思,可知它的主要是用于监听 state里面的值是否发生了变化。

它返回一个函数,这个函数里面有两个参数

  • 第一个参数 args ,这个 args 里面的东西,就是 vue3 中的 watchEffect 里面的东西
  • 第二个参数 state ,这个就是我们当前的 state
ts 复制代码
// 监听数据的变化
store.$subscribe((args, state) => {
  console.log('args', args)
  console.log('state', state)
})

五、全局响应式对象

响应式对象绑定到 window 上,可以在应用的任何地方访问和修改状态,类似于全局状态管理。但是我们并不推荐这样使用:

  1. 将状态绑定到 window 上会导致代码难以调试和维护,尤其是在大型项目中,状态的变化来源难以追踪。
  2. pinia 是支持模块化的 store,而 window 上的全局对象难以实现
  3. 全局 window 对象容易被其他库或代码污染,导致命名冲突或意外修改
  4. 无法进行生命周期管理,window 上的全局对象无法自动清理或销毁
  5. 等等

六、必写在最后

无论是Vuex的传统深度,还是Pinia的创新简化,都能引领开发者构建出更健壮、高效的应用程序。在这个过程中,理解状态管理的核心价值,灵活运用这些工具,才通往高质高效Vue开发实践的必经之路

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

七、参考文档

  1. Pinia-学习之路 04,改变数据状态的方式
  2. vuex按需加载,避免首页初始化所有数据
  3. Vue状态管理深度剖析:Vuex vs Pinia ------ 从原理到实践的全面对比_pinia和vuex-CSDN博客
  4. Pinia状态管理 | vue-template
相关推荐
天官赐福_2 分钟前
vue2的scale方式适配大屏
前端·vue.js
江城开朗的豌豆2 分钟前
CSS篇:前端经典布局方案:左侧固定右侧自适应的6种实现方式
前端·css·面试
我儿长柏必定高中4 分钟前
Promise及使用场景
前端
无名友4 分钟前
HTML — 浮动
前端·css·html
0xJohnsoy5 分钟前
React中的this详解
前端
the_one6 分钟前
🚀「v-slide-in」+ 瀑布流实战指南:Vue 高级滑入动画一键实现,页面质感瞬间拉满!
前端·javascript·css
ZL不懂前端6 分钟前
微前端介绍
前端
Lear7 分钟前
uniapp&微信小程序markdown&latex
前端
江城开朗的豌豆7 分钟前
CSS篇:CSS选择器详解与权重计算全指南
前端·css·面试
asing8 分钟前
之家中后台前端解决方案 - 支点2.0
前端·javascript