一、前言
对于使用 Vue 技术栈的同学来说,状态管理库的选择相对较少,通常集中在 Vuex 和 Pinia 上。因此,在面试中,面试官经常会问到:"Vuex 和 Pinia 有什么区别?你为什么会选择它?"此外,面试官还可能关注 Store 中数据的流转和响应式问题,这时就会涉及到另一个常用的 API:storeToRefs
。
除了这些核心内容,Pinia 还提供了一些非常实用的 API,比如 $patch
、$reset
、$subscribe
等,这些功能可以帮助开发者更高效地管理状态。不过,不知道大家是否思考过一个问题:如果将响应式对象绑定到全局的 window
对象中,是否可以替代 Pinia 呢?
接下来,我们将深入探讨这些内容,帮助大家更好地理解状态管理的实现原理和最佳实践。
二、Vuex vs Pinia
如果你使用过 react
相关的状态管理后再使用 vue
相关的状态管理,你就会觉得 vuex
或 pinia
真的十分简便。其实个人觉得 vuex
和 pinia
都是比较好上手的,只是 pinia
的设计更贴近 composition API
,因此对于 vue3
的开发者来说非常友好。
Pinia 有几个关键的设计点:
1. 更简洁直观的 API 设计
去除 Mutation :Pinia 合并了 Vuex 的 Mutation 和 Action,直接通过 Actions 处理同步和异步操作,减少代码复杂度。 更直观的 Composition API 风格 :与 Vue 3 的 Composition API 高度契合,可以通过 ref
、computed
等直接定义响应式状态,代码更简洁。
2. 更完美的 Typescript 支持
Pinia 原生支持 TypeScript,类型推断更加完善,无需额外配置即可享受类型安全。而 Vuex 则需要借助复杂的手动类型定义
3. 模块化设计更灵活
更自然的模块化: 每个 Store 独立定义(defineStore),无需手动分模块,天然支持代码分割
无需嵌套模块: Pinia 通过多个独立的 Store 组织状态,每个 Store 都是扁平化的,避免 Vuex 中嵌套模块的复杂性。
自动代码分割: Store 可以按需加载,天然支持代码拆分(配合构建工具),优化应用体积。
4. 更轻量高效
体积小:Pinia 的体积比 Vuex 更小(压缩后约 1KB),对应用性能更友好。
体验优 :Pinia 基于 Vue 3 的响应式系统(reactive
、ref
),性能与开发体验更优。
Pinia的核心概念
- State: 用于存放数据,有点儿类似 data 的概念;
- Getters: 用于获取数据,有点儿类似 computed 的概念;
- Actions: 用于修改数据,有点儿类似 methods 的概念;
- Plugins: Pinia 插件。
Pinia 与 Vuex 代码分割机制
假设项目有 3 个 Store(user
、job
、pay
)和 2 个页面(首页、个人中心),分别使用 Pinia 和 Vuex 对其状态管理。
Vuex 的代码分割
模块合并打包 :Vuex 在构建时会将所有模块(如 user
、job
、pay
)合并到一个包中。例如,若首页仅需 job
store,但打包时会强制包含全部模块,导致首屏加载冗余代码,影响性能。
依赖耦合:即使页面仅使用部分模块,Vuex 也会将所有模块代码引入到同一 chunk 中,无法实现按需加载。
解决方案: 经常在首页优化时,会考虑这个场景,一般处理方案时去做 Vuex 的按需加载,beforeCreate 时候,可以去判断当前页面需要加载哪些 store,利用这个 API 可以实现$store.registerModule
Pinia 的代码分割
按需加载 :Pinia 通过动态依赖检查,仅打包当前页面引用的 Store。例如,首页引用 job
store 时,构建工具(如 Webpack/Vite)会自动分离该 Store 到独立 chunk,其他未使用的 Store(如 user
、pay
)不会混入首页代码。
模块独立性 :每个 Store 通过 defineStore
独立定义,天然支持代码分割,无需手动配置命名空间或模块拆分
Pinia与Vuex的嵌套模块
Vuex 的嵌套模块
通过 modules
显式划分模块层级(如 user/profile
、user/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 });
缺点:
- 复杂度高:嵌套模块需配置
state
、mutations
、actions
、getters
和子模块,代码量显著增加,学习曲线陡峭。 - 对 Typescript 支持繁琐
- 过度设计风险:中小型项目可能因过度分层导致开发效率下降(例如为简单功能创建多个模块)。
Pinia 的嵌套模块
Pinia
的每个 Store
天然独立,无需手动配置模块或命名空间。也正是因为天然独立,Store
可以按需导入(例如配合路由懒加载),减少打包体积。每个 Store
独立维护自己的状态、计算和操作。
如果 pinia
想要实现类似于 vuex
嵌套模块,来处理复杂状态,可以使用以下三种方案:
- 按业务域组织 Store 文件:
tree
src/
stores/
user/
index.ts # 主 Store
profile.ts # 子逻辑(通过组合函数复用)
settings.ts
product/
index.ts
variants.ts
- 将子逻辑拆分为组合函数,供多个 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 };
});
- 通过直接导入调用其他 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 # 操作日志模块(仅审计员可见)
- 定义好 analytics 和 auditLog 模块
- 根据权限动态注册:
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);
}
}
- 在组件中按需使用
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 没有内置的动态注册机制,但是可以通过以下模式模拟:
- 动态创建 store
ts
// 动态加载并创建 Store
let analyticsStore: Store | null = null;
if (needsAnalytics) {
const analyticsModule = await import('./plugins/analytics');
analyticsStore = analyticsModule.useAnalyticsStore();
}
- 卸载时清理 store
ts
// 重置 Store
const resetStore = () => {
analyticsStore?.$reset();
analyticsStore = null;
};
Pinia与Vuex持久化处理
为什么需要持久化处理?这个是什么?
当用户刷新浏览器页面(按 F5 或 Cmd+R)时,当前的 JavaScript 执行环境会被完全重置。内存中的所有变量,包括 Pinia store 里的 state
,都会丢失。刷新后,应用会重新初始化,Pinia store 会回到其定义的初始状态,而不是用户刷新前的状态。
如果你的应用依赖于某些状态(如用户登录信息、购物车数据、用户偏好等)需要在页面刷新后仍然保留,就是需要将状态持久化存储到本地(如stroage中)
Vuex持久化处理
- 安装依赖
bash
npm install vuex-persistedstate --save
- 直接使用
storage
:可以设置为window.localStorage
或window.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持久化处理
- 安装插件:
bash
npm install pinia-plugin-persistedstate
- 配置 Pinia:
ts
// main.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
- 标记需要持久化的 Store:
ts
// stores/user.ts
export const useUserStore = defineStore('user', {
state: () => ({ token: '' }),
persist: true, // 开启持久化
});
- 自定义配置:
ts
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
persist: {
key: 'my-cart', // 存储的键名(默认是 store id)
storage: sessionStorage, // 指定存储方式(默认 localStorage)
paths: ['items'], // 仅持久化部分状态
},
});
总结
其实 vuex 和 pinia 没有谁优谁劣的问题,只是它俩的使用场景有所不同:
- vuex 比较适合复杂的大型应用中,因为有比较严格的状态分层,比如超大项目需要嵌套模块明确层级关系的
- 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)//发现失去响应性
- 简单数据类型:会失去响应性
- 引用数据类型:不会失去响应性
针对上述解构导致其失去响应性,就可以采用 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)实现的。当你使用 reactive
或 ref
创建响应式数据时,Vue 会为这些数据创建一个代理对象。这个代理对象会拦截对数据的访问和修改,从而实现响应性。
然而,当你直接解构响应式数据时,实际上是将代理对象中的值复制到一个普通变量中。这个普通变量与原始的代理对象没有任何联系,因此失去了响应性
避免响应式陷阱
toRefs
:用于 reactive 对象,将响应式对象的属性转为 ref 对象,从而保持响应性。
ts
import { reactive, toRefs } from 'vue';
const state = reactive({
count: 0,
});
const { count } = toRefs(state); // 保持响应性
storeToRefs
:用于 pinia store
ts
import { storeToRefs } from 'pinia';
import { useCounterStore } from './stores/counter';
const counterStore = useCounterStore();
const { count } = storeToRefs(counterStore); // 保持响应性
- 直接访问响应式对象:不需要解构
- 使用 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;
}
}
整体思路:
- 遍历 store 的所有属性
- 如果属性是响应式(isRef 或 isReactive),则将其转为 ref 对象
- 返回一个包含这些 ref 对象的普通对象
细节:
store = toRaw(store)
:使用toRaw
获取 Store 的原始对象,避免处理代理对象。
toRaw
用于获取响应式对象的原始对象。在 Vue 3 中,响应式对象是通过 Proxy 实现的,toRaw
可以绕过 Proxy,直接访问原始对象。这样可以避免在处理响应式对象时产生不必要的副作用。
if (isRef(value) || isReactive(value))
:判断当前属性是否是响应式的(isRef
或isReactive
)。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
可以批量的进行数据的修改。
- 对象形式:这种形式某些情况不能使用,特别是针对引用数据类型
ts
// 对象形式
counterStore.$patch({
count: 10,
name: 'Vue 3',
});
- 函数形式:
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
上,可以在应用的任何地方访问和修改状态,类似于全局状态管理。但是我们并不推荐这样使用:
- 将状态绑定到 window 上会导致代码难以调试和维护,尤其是在大型项目中,状态的变化来源难以追踪。
- pinia 是支持模块化的 store,而 window 上的全局对象难以实现
- 全局 window 对象容易被其他库或代码污染,导致命名冲突或意外修改
- 无法进行生命周期管理,window 上的全局对象无法自动清理或销毁
- 等等
六、必写在最后
无论是Vuex的传统深度,还是Pinia的创新简化,都能引领开发者构建出更健壮、高效的应用程序。在这个过程中,理解状态管理的核心价值,灵活运用这些工具,才通往高质高效Vue开发实践的必经之路
如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!