如何解决主包与子包 Pinia 状态管理冲突的问题?

117 阅读7分钟

Situation

项目由多个模块构成,子模块作为主模块的子包引入。上线后却发现了一个问题,一个功能被另一个看起来完全不相关的功能给影响了,导致交互异常

我们在这里对问题分析一下,同时提供可解决问题的方案

Task

根据问题表现进行调试,我们发现是pinia的状态管理冲突导致的问题,原因是:

主包中使用 pinia 进行状态管理,子包中也使用了 pinia 做状态管理,用户触发某个行为,这个行为对应的子包中重新创建了一个 pinia 的实例,导致主包中的 store 被重新初始化,进而导致状态管理出现错误。

问题发生流程为:主包的store中定义了A变量,并且赋值为“a1”,按照正常交互逻辑走的话,A会被修改为“a2”,此时用户如果操作了子包提供的功能,那么子包的pinia被初始化,导致主包的pinia对象被替换,然后用户再操作主包提供的功能,此时主包会在后来子包创建的pinia对象上重新初始化store,这样变量A的值变成了“a1”,最终导致功能无法正常使用

Action

为了解决这个问题,我们需要先明确 Pinia 的工作原理

pinia工作原理

Pinia 通过创建一个全局实例(通常称为pinia)来管理所有 Store,这个实例相当于一个状态容器的 "根节点":

// 主包中通常的初始化方式
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia)

当我们在子包中再次执行createPinia()并挂载到应用时,就会出现两个关键问题:

  1. 全局实例覆盖:如果子包的构建环境没有做好模块隔离,新创建的 Pinia 实例可能会覆盖主包的全局实例。因为ES 模块的单例特性会导致重复引入的模块共享同一个实例
  2. Store 重新注册:Pinia 的 Store 注册机制与实例强绑定。当新实例被创建后,原有的 Store 定义会被重新执行并注册到新实例上,导致原有状态丢失。例如:
// store/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ name: 'chen' })
})

这段代码在主包初始化时会注册到主 Pinia 实例,当子包创建新实例后,useUserStore会再次注册到新实例,导致主包中获取的 Store 实际上指向了新实例的状态。

解决方案

我们用一个简单的vue+vite项目demo来验证这个问题,sub-app作为main-app 的子包存在,是单独的一个项目

image.png

最开始主包的store中username初始值为 chen,随后页面初始化完成后被修改为jiujiu,也就是页面加载完成后的效果,此时子包还没有被触发加载出来

image.png

主包入口文件修改username为jiujiu

image.png

当我们点击【加载子包】按钮时,触发loadSubApp方法,在这个方法中我们做了2件事,加载子包和获取useUserStore

image.png

可以观察到username的值被重置为chen,我们修改的jiujiu数据被覆盖,并且我们也没有调用setUsername对其进行修改,说明在子包创建pinia实例时导致主包的状态被初始化了,pinia实例的引用发生了变化,子包的新实例覆盖了主包的实例

2025-08-15 11.59.57.gif

针对这种场景,我们需要实现主包与子包 Pinia 实例的合理隔离或共享,具体有以下几种方案:

1、共享主包Pinia 实例

这是最推荐的方案,将主包的 Pinia 实例传递给子包,确保整个应用为单实例模式,这种方式适用于子包作为主包功能扩展的场景,能保证状态的一致性

  1. 主包暴露 Pinia 实例:
// 主包入口
import { createPinia } from 'pinia'
export const pinia = createPinia()
app.use(pinia)
  1. 子包接收主包实例:
// 子包入口
export function install(app, { pinia }) {
  // 使用主包传递的pinia实例
  app.use(pinia)
  // 子包的Store会注册到主包实例上
}

接下来我们修改代码看看是否可以解决问题:

首先,我们修改主包代码,在main.js中暴露pinia实例

image.png

然后,在主包的App.vue中导入这个实例,为了在加载子包时将pinia实例传参给子包

image.png

最后在子包的入口文件,不要再去创建一个新的实例,而是使用主包传过来的实例

image.png

修改完成后,我们刷新查看,此时加载子包之后,就不会使主包store的state被覆盖掉了

2025-08-15 14.18.43.gif

2、完全隔离 Pinia

如果子包需要完全独立的状态管理,可以通过模块隔离确保实例不冲突:

完全隔离方案的核心是让主包和子包拥有完全独立的 Pinia 实例,且两者的 Store 不会相互影响。验证需确认:

  1. 主包和子包的 Pinia 实例完全独立
  2. 主包和子包的状态变化不会影响
  3. 即使 Store 的 ID 相同(如都有userStore)

首先按照 “完全隔离” 方案改造代码,关键是让子包使用独立的 Pinia 实例,并通过构建工具确保模块隔离,修改主包配置(vite.config.js)文件,我们引入引入模块联邦插件,让主应用能够将自身部分组件暴露给其他应用使用,同时可以保持pinia状态隔离

image.png

改造子包vite配置文件

image.png

调试我们可以发现,在加载完子包之后,我们通过createPinia获取到的pinia实例有2个,分别是主包和子包的实例

image.png

到这里可能有朋友发出了疑惑,是否是真的做到了完全隔离,那么我们还可以用同名store验证一下

我们在子包store中创建同名user.js

image.png

修改完对应的UI,并且增加一些日志,调试观察效果

2025-08-15 15.38.01.gif

观察日子发现,虽然同名,可主包和子包的操作并不会互相影响,pinia确实是2个独立的实例

image.png

3、使用getActivePinia

Pinia 提供了setActivePinia和getActivePinia方法管理当前活跃实例,这种方式能显式控制 Store 注册到哪个实例,适合需要灵活切换实例的场景

我们需要确保3件事

  1. 确保主包和子包的 Pinia 实例引用不同
  2. 主包与子包的状态修改互不影响
  3. 通过代码逻辑强制检查实例绑定是否正确

我们修改代码进行验证

首先改造子包代码,确保子包使用独立实例:

image.png

修改主包入口文件,保持主包实例独立:

image.png

接下来,无论我们如何操作页面,2个包的state始终互相隔离,不会互相影响

image.png

同时,也可以在关键逻辑中添加实例校验,通过getActivePinia获取到当前活跃pinia实例,和目标pinia实例做对比,确保 Store 绑定到预期实例

image.png

当然,如果代码没问题的话,这个if分支的代码不会执行

2025-08-15 14.54.50.gif

4、使用ref管理状态

这个方法适用于状态不多的情况下,比如我们开头提到的问题,只是一个state被影响了的话,就可以使用ref来解决问题,简单高效,这里就不举例了

针对项目而言,我们最终采取的办法是:不使用 pina 进行状态管理,使用 ref 来记录状态。

Result

对于多个包需要做状态管理时,可以做以下考虑:

  1. 状态较少,直接使用 ref 或 reactive 进行管理
  2. 状态较多,记得做好状态隔离措施,避免相互影响,同时做好自测
  3. 测试覆盖:添加跨包状态交互的测试case,检测实例冲突导致的状态异常

写在最后

Pinia 的useStore()始终绑定到当前活跃的实例,当子包创建新实例后,所有新的useStore()调用都会指向这个新实例,导致状态看似 "被重置",这是主包与子包的 Pinia 实例管理混乱导致的。在理解 Pinia 的实例与 Store 注册机制的基础上,我们可以通过合理的实例共享或隔离策略来规避这类问题。

参考

originjs/vite-plugin-federation:www.npmjs.com/package/@or…

pinia getActivePinia:pinia.vuejs.org/api/pinia/f…

vue ref:vuejs.org/api/reactiv…