菠萝滞销,帮帮我们(多个APP实例间pinia混乱)

Pinia 是 Vue.js 的官方状态管理库,其工作原理可以简单总结如下:

  1. 核心概念 - Store :Pinia 围绕"store"构建。一个 store 是一个包含应用状态(state)、计算属性(getters)和修改状态的函数(actions)的容器。每个 store 有唯一的 ID。
  2. 响应式状态 :Pinia 使用 Vue 3 的 Composition API(主要是 reactiveref)来创建一个响应式的 state 对象。当组件读取 store 的 state 时,Vue 的响应式系统会自动追踪依赖;当 state 改变时,依赖它的组件会自动更新。
  3. 模块化设计:与 Vuex 的单一 store 不同,Pinia 采用天然的模块化。每个 store 都是独立的,可以直接定义和使用,无需像 Vuex 那样在主 store 中注册模块,避免了命名空间的复杂性。
  4. Actions 处理逻辑actions 是 store 中定义的方法,用于封装业务逻辑,如修改 state、处理异步操作(如 API 调用)。组件通过调用 store 的 actions 来触发状态变更。
  5. 自动解构与响应式 :Pinia 提供 storeToRefs 工具函数,可以将 store 中的 state 和 getters 解构为响应式引用,避免在组件解构时丢失响应性。
  6. Vue Devtools 集成:Pinia 与 Vue Devtools 深度集成,可以方便地调试状态变化、时间旅行等。

简单来说:Pinia 创建一个响应式的 store 对象来集中管理状态,利用 Vue 的响应式系统实现数据自动更新,通过 actions 定义修改逻辑,以简洁、模块化的方式为 Vue 应用提供状态管理。

我负责的项目本身是依赖的vue的app的概念,实现的多维表格,表格内部依赖了app和pinia的便利,能够通过全局的状态管理随时获取到想要的数据。当组件借用通过 app 和 Pinia 建立的全局状态管理时,会带来以下几大核心好处:

1. 解决深层组件通信难题 (避免"Prop Drilling")

  • 问题 :在复杂应用中,一个深层嵌套的子组件可能需要父组件的父组件的数据。传统方式需要一层层通过 props 传递,代码冗长且难以维护。
  • Pinia 方案:任何组件(无论层级深浅)都可以直接访问 Pinia store 中的状态,无需通过中间组件层层传递,极大简化了组件间的通信。

2. 提升代码可维护性与可复用性

  • 逻辑集中 :业务逻辑(如用户登录、商品增减)被封装在 store 的 actions 中,而不是散落在各个组件内。修改一处即可影响所有使用该逻辑的地方。
  • 组件解耦:组件不再需要关心数据从哪里来、如何更新,只需关注自身的 UI 渲染,职责更清晰,更容易复用。

3. 状态变更可预测与可追踪

  • 单一数据源 :所有状态变更都必须通过明确的 actions 方法进行(或通过 $patch),避免了组件随意修改全局状态导致的混乱。
  • 模块化设计 :Pinia 允许创建多个独立的 store(如 userStore, cartStore, productStore),每个 store 负责特定领域的状态,非常适合大型应用的架构。
  • 易于扩展:新增功能时,只需创建新的 store 模块,不会影响现有代码结构。

以上的这个表格就是我负责的多维表格的项目,能够使用canvas来渲染应该显示的数据,从而实现虚拟滚动,使页面能够同时加载10w+的数据丝滑使用。并且项目内置了筛选,排序,分组等功能。

由于项目足够复杂,所以Pinia大王也是大显神通,如虎添翼。全局的状态管理,使我能够在项目的任何一个地方获取全局使用的数据而不需要用vue的props层层加码层层传递,有了那么多好处自然是难舍难分如胶似漆了,Pinia真好。

但是就在最近,小弟遇到了头痛的问题,大哥说,既然咱的多维表格那么好,那我们要大大的用,要好好的用。 原本一个页面上就存在一个多维表格,一个页面只是为了展示一个表,所以相当于是项目本身的app里嵌套了一个多维表格的vue app。项目里的数据通过prop传递到多维表格当中,多维表格通过暴露事件,告诉外部,表格内触发的变化。两者泾渭分明,井水不犯河水。大哥一拍脑袋之后,页面上出现了一个页面下渲染多个多维表格的情况,甚至还有多维表格里嵌套多维表格的行为。也就是说,页面上的APP实例的情况从: APP->APP

变成了:

APP->APP

L>APP->APP

L>APP

的情况,初想很美好,功能很强大,于是也就动手做了起来。完成了实现的方案之后,才发现,由于用到该场景的情况会存在这个表点点,那个表用用的复杂情况,这就导致用户会在不同的app之间切换,但是由于pinia的特性导致了一个app只有一个pinia,本来其实应该不会有什么问题,但是这几个并行的app都是用同一套的代码渲染出来的,在当前Vue3应用架构中,存在一个严重的问题:在单个Vue3应用实例中错误地创建了多个相同的app实例,导致应用内多个组件实例同时渲染相同代码,并且这些实例共享了相同key的Pinia状态管理实例。这造成了以下现象:

  1. 多个独立的UI组件(或整个应用模块)同时渲染,造成视觉上的重复内容
  2. 由于Pinia状态共享,不同UI组件之间的状态相互干扰
  3. 用户交互行为(如点击、输入等)导致状态混乱,多个组件同时响应同一操作

根本原因分析

1. Pinia的全局注册机制

Pinia的store是基于全局唯一键 注册的。当使用defineStore('storeKey', {...})定义store时,storeKey是全局唯一的标识符。在单个应用中,Pinia会将store注册到全局状态管理器中。由于所有App都是使用相同代码生成的app实例并且在代码最开始设计的时候并没有考虑到这种复杂使用的情况,所以所有的key都是绑定写死的情况如以下代码一般。

js 复制代码
import { defineStore, type Pinia } from 'pinia'
// 问题代码示例 
export const useMyStore = defineStore('myStore', () => {
  const a = shallowRef<string>('')

  return {
    a,
  }
})

当在多个app实例中使用相同的'myStore'作为key时,Pinia会将它们视为同一个store实例,导致所有app实例共享同一份状态。

2. 多app实例的创建方式

问题根源在于错误地在单个应用中创建了多个Vue3应用实例。页面上每一个生成的app都在创建的时候创建了一个pinia

js 复制代码
export function setupApp(rootProps?: RootProps) {
  const appProps: AppProps = {
    //一些配置参数
  }
  const app = createApp(rootProps?.App ?? App, appProps)
  const pinia = createPinia()
  app.use(pinia)
  setupI18n(app, rootProps?.locale)
  setupVueKonva(app)
  setupElementPlus(app, rootProps?.elementPlusConfig)
  return { app, pinia }
}

这就导致页面上存在很多很多的pinia,更为糟糕的是,很多工具函数中也会使用pinia中的函数,并且更为糟糕的是这些ts文件中根本拿不到pinia实例。

下面我们来看看pinia官网对于这种情况的描述

Pinia store 依靠 pinia 实例在所有调用中共享同一个 store 实例。大多数时候,只需调用你定义的 useStore() 函数,完全开箱即用。例如,在 setup() 中,你不需要再做任何事情。但在组件之外,情况就有点不同了。 实际上,useStore() 给你的 app 自动注入了 pinia 实例。这意味着,如果 pinia 实例不能自动注入,你必须手动提供给 useStore() 函数。 你可以根据不同的应用,以不同的方式解决这个问题。 当处理服务端渲染时,你将必须把 pinia 实例传递给 useStore()。这可以防止 pinia 在不同的应用实例之间共享全局状态。

pinia.vuejs.org/zh/core-con...

也就是说我要在不同的app之间,游龙,我必须告诉app,我使用的是哪一个pinia,但是项目本身已经有了太多太多的直接使用pinia的例子,完全不传递pinia的实例本身,而是依赖app自己的pinia管理,例如下方这个使用较少的弹窗pinia已经有了33处不传递pinia直接使用的情况,要是让我一处一处的去兼容,那一定是无法完成的大工程。变成西西弗斯,日复一日的推巨石。

对于以上的排查问题之后发现的问题,我冥思苦想,终于想到了破局之道。

所以,将大局逆转吧!开!!!

首先的当务之急就是需要将不同的app使用上不同的Key生成的store,但是又不能够影响到了原先的使用,需要在不对原先的调用方法产生影响的情况下,改造pinia。 所以我就想到了使用map来存储不同的pinia的方式 我们将原来的静态定义 Store 改造为 工厂函数 + 命名空间注册机制,通过动态创建带唯一 ID 的 Pinia Store,并结合 Vue 的实例上下文进行自动绑定。

关键技术点

  1. createStore(namespace):生成带命名空间的 Store 工厂函数
  2. setupStore(namespace):注册特定命名空间的 Store
  3. useStore():运行时根据当前组件实例自动匹配对应的 Store
  4. ✅ 内部维护 _popperStoreMap 映射表,避免重复创建
  5. ✅ 完全保留原 API 调用方式(无侵入式升级)
js 复制代码
// 原始版本:只有一个全局 store
import { defineStore } from 'pinia'

export const useMyStore = defineStore('myStore', () => {
  const data = ref(false)

  const open = () => {
    data.value = true
  }
  const close = () => {
    data.value = false
  }

  return { data, open, close }
})
js 复制代码
// 改造后:工厂函数 + 动态注册 + 自动绑定
import { defineStore } from 'pinia'
import { getCurrentInstanceId } from './xxx'

// Step 1: 创建带 namespace 的 store 工厂
function createNamespacedStore(namespace: string) {
  const id = `${namespace}/myStore`

  return defineStore(id, () => {
    const data = ref(false)

    const open = () => {
      data.value = true
    }
    const close = () => {
      data.value = false
    }

    return { data, open, close }
  })
}

// Step 2: 存储已创建的 stores
const storeMap = new Map<string, () => any>()

// Step 3: 注册某个 namespace 的 store
export function setupMyStore(namespace: string) {
  if (!storeMap.has(namespace)) {
    storeMap.set(namespace, createNamespacedStore(namespace))
  }
}

// Step 4: 使用时自动匹配当前实例
export function useMyStore() {
  const instanceId = getCurrentInstanceId()
  if (storeMap.has(instanceId)) {
    return storeMap.get(instanceId)!
  }
  throw new Error(`No store found for instance: ${instanceId}`)
}

如此之后,就将所有的pinia都和一个使用getCurrentInstanceId从App身上获取的key,来从storeMap身上获取自己的store,注册的事情就交给一个统一的函数,在setupApp的时候去统一的完成(见下图)

这样子我就只需要维护一个InstanceId就能够统帅三军了。 正当我沾沾自喜的自测的时候

我又发现了另外的一个问题,那就是每个注册的时候不同的app其实是注册了自己的pinia,也就是说key为instance1/myStore的store被固定在pinia1上,而instance2/myStore被注册到了pinia2上。如果我在使用的时候,不对使用的情况指定pinia就会导致仍然无法实现我希望的功能

所以,再次将大局逆转吧!

js 复制代码
//原先的调用方式
export function useMyStore() {
  const instanceId = getCurrentInstanceId()
  if (storeMap.has(instanceId)) {
    return storeMap.get(instanceId)()
  }
  throw new Error(`No store found for instance: ${instanceId}`)
}

//增加了pinia的传递的方式
export function useMyStore(pinia?: Pinia) {
  const instanceId = getCurrentInstanceId()
  if (storeMap.has(instanceId)) {
  const currentPinia = getCurrentActivePinia()
    return storeMap.get(instanceId)(pinia || currentPinia)
  }
  throw new Error(`No store found for instance: ${instanceId}`)
}

代码中增加了getCurrentActivePinia方法,我在全局中维护了当前正在使用到的pinia实例,自此才真正的实现了多个app实例间Pinia实例的管理,在不改变原有代码的情况下做到了pinia的管理。 只需全局维护好pinia和instanceID就能够完美兼容。

事后来看,反思之后我可能会觉得其实并不需要设置instanceId,只需要后面的这次改动指定到了pinia也能够正常运行,像是放屁脱裤子。但是在排查问题的时候,不同的store使用了不同的id让我能debugger的时候轻松完成,想到了这里我觉得这无关紧要的instanceid说不定在未来某天也能够发挥他的作用,而不需后续来接替这份代码的人再像我一样为之前没有考虑到的情况感到头疼,所以也就留了下来,毕竟这样子更优雅也更合理。

无缝迁移 原有代码无需修改 setDrawer, closeDialog 等调用方式
自动识别上下文 利用 getCurrentInstanceId() 自动绑定当前组件所属实例
按需注册 调用 setupPopperStore('app1') 才会创建该命名空间的 store
内存优化 相同 namespace 不会重复创建 store
调试友好 Devtools 中显示为 app1/PopperStore,清晰可辨

后话

解决问题虽然痛苦,但是完成重构的那一天,我知道这个从我接任上一任负责人的那天就一直种下的阴霾也是烟消云散了,下班的路上只觉得如沐春风。有时候能用自己的能力解决问题也是快乐的事情,且折腾吧。

相关推荐
llq_3502 小时前
pnpm / Yarn / npm 覆盖依赖用法对比
前端
麦当_2 小时前
ReAct 模式在 Neovate 中的应用
前端·javascript·架构
折七2 小时前
告别传统开发痛点:AI 驱动的现代化企业级模板 Clhoria
前端·后端·node.js
程序0072 小时前
纯html实现商品首页
前端
coderlin_2 小时前
BI磁吸布局 (2) 基于react-grid-layout扩展的布局方式
前端·react.js·前端框架
Ankkaya2 小时前
vue3 实现自定义模板表单打印
前端
itslife2 小时前
vite源码 - 开始
前端·javascript
Achieve - 前端实验室3 小时前
【每日一面】React Hooks闭包陷阱
前端·javascript·react.js
张愚歌3 小时前
Leaflet行政区划边界开发全攻略
前端