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()并挂载到应用时,就会出现两个关键问题:
- 全局实例覆盖:如果子包的构建环境没有做好模块隔离,新创建的 Pinia 实例可能会覆盖主包的全局实例。因为ES 模块的单例特性会导致重复引入的模块共享同一个实例
- 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 的子包存在,是单独的一个项目
最开始主包的store中username初始值为 chen,随后页面初始化完成后被修改为jiujiu,也就是页面加载完成后的效果,此时子包还没有被触发加载出来
主包入口文件修改username为jiujiu
当我们点击【加载子包】按钮时,触发loadSubApp方法,在这个方法中我们做了2件事,加载子包和获取useUserStore
可以观察到username的值被重置为chen,我们修改的jiujiu数据被覆盖,并且我们也没有调用setUsername对其进行修改,说明在子包创建pinia实例时导致主包的状态被初始化了,pinia实例的引用发生了变化,子包的新实例覆盖了主包的实例
针对这种场景,我们需要实现主包与子包 Pinia 实例的合理隔离或共享,具体有以下几种方案:
1、共享主包Pinia 实例
这是最推荐的方案,将主包的 Pinia 实例传递给子包,确保整个应用为单实例模式,这种方式适用于子包作为主包功能扩展的场景,能保证状态的一致性
- 主包暴露 Pinia 实例:
// 主包入口
import { createPinia } from 'pinia'
export const pinia = createPinia()
app.use(pinia)
- 子包接收主包实例:
// 子包入口
export function install(app, { pinia }) {
// 使用主包传递的pinia实例
app.use(pinia)
// 子包的Store会注册到主包实例上
}
接下来我们修改代码看看是否可以解决问题:
首先,我们修改主包代码,在main.js中暴露pinia实例
然后,在主包的App.vue中导入这个实例,为了在加载子包时将pinia实例传参给子包
最后在子包的入口文件,不要再去创建一个新的实例,而是使用主包传过来的实例
修改完成后,我们刷新查看,此时加载子包之后,就不会使主包store的state被覆盖掉了
2、完全隔离 Pinia
如果子包需要完全独立的状态管理,可以通过模块隔离确保实例不冲突:
完全隔离方案的核心是让主包和子包拥有完全独立的 Pinia 实例,且两者的 Store 不会相互影响。验证需确认:
- 主包和子包的 Pinia 实例完全独立
- 主包和子包的状态变化不会影响
- 即使 Store 的 ID 相同(如都有userStore)
首先按照 “完全隔离” 方案改造代码,关键是让子包使用独立的 Pinia 实例,并通过构建工具确保模块隔离,修改主包配置(vite.config.js)文件,我们引入引入模块联邦插件,让主应用能够将自身部分组件暴露给其他应用使用,同时可以保持pinia状态隔离
改造子包vite配置文件
调试我们可以发现,在加载完子包之后,我们通过createPinia获取到的pinia实例有2个,分别是主包和子包的实例
到这里可能有朋友发出了疑惑,是否是真的做到了完全隔离,那么我们还可以用同名store验证一下
我们在子包store中创建同名user.js
修改完对应的UI,并且增加一些日志,调试观察效果
观察日子发现,虽然同名,可主包和子包的操作并不会互相影响,pinia确实是2个独立的实例
3、使用getActivePinia
Pinia 提供了setActivePinia和getActivePinia方法管理当前活跃实例,这种方式能显式控制 Store 注册到哪个实例,适合需要灵活切换实例的场景
我们需要确保3件事
- 确保主包和子包的 Pinia 实例引用不同
- 主包与子包的状态修改互不影响
- 通过代码逻辑强制检查实例绑定是否正确
我们修改代码进行验证
首先改造子包代码,确保子包使用独立实例:
修改主包入口文件,保持主包实例独立:
接下来,无论我们如何操作页面,2个包的state始终互相隔离,不会互相影响
同时,也可以在关键逻辑中添加实例校验,通过getActivePinia获取到当前活跃pinia实例,和目标pinia实例做对比,确保 Store 绑定到预期实例
当然,如果代码没问题的话,这个if分支的代码不会执行
4、使用ref管理状态
这个方法适用于状态不多的情况下,比如我们开头提到的问题,只是一个state被影响了的话,就可以使用ref来解决问题,简单高效,这里就不举例了
针对项目而言,我们最终采取的办法是:不使用 pina 进行状态管理,使用 ref 来记录状态。
Result
对于多个包需要做状态管理时,可以做以下考虑:
- 状态较少,直接使用 ref 或 reactive 进行管理
- 状态较多,记得做好状态隔离措施,避免相互影响,同时做好自测
- 测试覆盖:添加跨包状态交互的测试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…