Проблема

На сегодняшний день все стейт-менеджеры экосистемы Vue (изначально Vuex, впоследствии Pinia, на примере которой и будет рассмотрена проблема) предоставляют глобальное централизованное хранилище, привязанное к корню приложения. И это замечательно: думаю, практически каждый читатель пользовался бенефитами данного подхода, будь то доступ к стору на любом уровне вложенности или переиспользование данных между различными блоками страницы. Однако у текущей системы есть одно важное ограничение - глобальные модули Pinia стора не позволяют создавать независимые состояния для инстансов одного компонента/модуля. Приведу несколько примеров.

Мы в YCLIENTS столкнулись с этой проблемой, когда пытались переиспользовать крупный и сложный модуль оплат между вкладками одного модального окна. В окне одновременно могут сосуществовать несколько инстансов модуля, состояния которых хранятся в сторе и должны быть полностью независимы друг от друга, что в разрез с концепцией единого глобального хранилища из Pinia.

С похожей проблемой столкнулись пользователи Pinia на GitHub:

Cуществуют кейсы, когда на одной странице необходимо отобразить несколько датагридов (к примеру, сравнение различных наборов данных). Таким образом, состояния деревьев компоне��тов каждого датагрида должны быть независимы друг от друга

Также стоит отметить, что во frontend-разработке нет единых правил по работе со сторами. В них хранят совершенно разные по типу и объемам данные. Мне встречались проекты, где даже небольшие переиспользуемые компоненты/блоки имели свои сторы. В целом сторов становится всё больше — одним из трендов последнего времени является переход от единого монолита к работе с множеством маленьких, специализированных хранилищ (Pinia и Effector яркий тому пример). Всё это (тенденции индустрии и разнообразие подходов к работе со сторами) делает проблему значительно более актуальной.

Решения от сообщества

В комментариях под дискуссией сообщество (при содействии одного из мейнтенеров Pinia) предложило несколько решений (раз и два). Однако их все объединяет одна главная особенность — использование кастомного идентификатора для доступа к стору (в решениях выше это tableId или listViewId). Без идентификатора сторонние компоненты не смогут получить доступ к нужному модулю Pinia. Следовательно, необходимо реализовать механизм хранения и передачи кастомных идентификаторов (ведь подобных сторов может быть несколько) всем использующим данный модуль компонентам, в том числе компонентам-потомкам. Решив одну проблему, мы получили другую.

Отдельного внимания заслуживает библиотека pinia-di. С ее помощью можно решить вышеописанную проблему, но представленный подход значительно сложнее подхода Pinia. Скорее всего, команде потребуется время на его изучение и внедрение. Фактически авторы предлагают новый синтаксис работы со стором, который во многом идет вразрез с главными преимуществами и принципами Pinia: простотой и доступностью. Кажется необходимо решение, которое будет больше похоже на оригинальный синтаксис.

Мой вариант решения

Pinia стор, привязанный к скоупу (или Pinia scoped стор). В данном случае скоупом (или областью видимости) является инстанс компонента, который первым в иерархии использовал данный стор. Все дочерние компоненты данного инстанса получают доступ к нужному стору автоматически, передача идентификатора скоупа происходит под капотом, разработчику не нужно продумывать этот механизм или менять стандартный подход к работе со сторами. При использовании модуля в параллельной иерархии создается новый, независимый стор, доступ к которому потомки также получат автоматически.

В итоге в каждой иерархии будет использоваться свой отдельный стор (storeModuleName/3 и storeModuleName/6 на картинке выше), скоупом которого является инстанс инициализирующего компонента.

Этого удалось добиться за счет двух важных концепций:

  1. Создание стора (вызов оригинального defineStore()) происходит в момент непосредственного использования, что позволяет привязаться к скоупу(инстансу компонента)

  2. Для передачи идентификатора компонентам скоупа используется provide/inject. При этом получение и отправка идентификатора происходят под капотом, внутри функции useStore

Теперь перейдем к реализации. За основу взят source код функции defineStore. Типизация практически полностью скопирована из оригинала (Vue core team активно используют as и any, поэтому и я не стал их избегать). В комментарии добавлены пояснения по каждому важному шагу:

import {defineStore, Pinia, StoreDefinition, StoreGeneric, getActivePinia} from 'pinia'
import {inject, getCurrentInstance, onUnmounted, ComponentInternalInstance, InjectionKey} from 'vue'

// id и piniaId.
// id - это первый аргумент функции defineScopeYcStore. К примеру, RecordAcquiringPaymentRedesign.
// piniaId - id стора в pinia, содержит в себе идентификтор скоупа. К примеру, RecordAcquiringPaymentRedesign/123124123123123, где 123124123123123 - идентификатор скоупа(в качестве идентификатора скоупа используется uid первого компонента иерархии, в котором использовался стор)
//
// scopedStoresIdsByScope содержит информацию о том, в каких скоупах(scopeId) и какие именно сторы(id и piniaId) создавались.
// Позволяет для данного скоупа(scopeId) получить id и piniaId всех созданных в данном скоупе сторов. Используется для предотвращения повторного создания сторов с одниковым скоупом
type ScopedStoresIds = {[id in string]: string} // {RecordAcquiringPaymentRedesign: 'RecordAcquiringPaymentRedesign/123124123123123', ...}
const scopedStoresIdsByScope: {[scopeId in string]: ScopedStoresIds} = {} // {123123: {RecordAcquiringPaymentRedesign: 'RecordAcquiringPaymentRedesign/123124123123123', ...}}

//  Содержит ссылки на созданные ранее scoped сторы. Ключом является piniaId, значением - стор
const scopedStoresByPiniaId: {[piniaId in string]: ReturnType<typeof defineStore>} = {}

export const defineScopedStore: typeof defineStore = function( // Сигнатуру функции скопировал из сорсов defineStore (https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/store.ts#L852)
  idOrOptions: any,
  setup?: any,
  setupOptions?: any,
): StoreDefinition {
  let id
  let options
  // На основе входящи параметров выделяем id и options. Скопировал из сорсов defineStore
  const isSetupStore = typeof setup === 'function'
  if (typeof idOrOptions === 'string') {
    id = idOrOptions
    options = isSetupStore ? setupOptions : setup
  } else {
    options = idOrOptions
    id = idOrOptions.id
  }

  function useStore(pinia?: Pinia | null | undefined, hot?: StoreGeneric): StoreGeneric {
    const currentInstance = getCurrentInstance()
    if (currentInstance === null) {
      throw new Error('Scoped stores can not be used outside of Vue component')
    }

    const scopeId = currentInstance.uid // Если опасаетесь использовать uid компонента в качестве идентификатора скоупа - можно самостоятельно проставлять всем компонентам уникальный id с помощью простенького плагина(https://github.com/vuejs/vue/issues/5886#issuecomment-308647738) и опираться на него
    let piniaId: string | undefined // Id нужного нам scoped стора в pinia

    // Проверяем, создавался ли ранее нужный нам стор в теку��ем компоненте или компонентах-предках. Пытаемся получить piniaId scoped стора
    if (scopedStoresIdsByScope?.[scopeId]?.[id]) {
      piniaId = scopedStoresIdsByScope[scopeId][id]
    } else {
      piniaId = inject<string>(id)
    }

    // Если scoped стор уже создан(удалось получить piniaId) - возвращаем его
    if (piniaId && scopedStoresByPiniaId[piniaId]) {
      return scopedStoresByPiniaId[piniaId](pinia, hot)
    }

    // Если выяснилось, что scoped стор еще не создавался(не удалось получить piniaId) - создаем его
    // piniaId = id стора + идентификатор скоупа
    piniaId = `${id}/${scopeId}`

    // Создаем стор и сохраняем на него ссылку в scopedStoresByPiniaId
    if (isSetupStore) {
      scopedStoresByPiniaId[piniaId] = defineStore(piniaId, setup, options)
    } else {
      scopedStoresByPiniaId[piniaId] = defineStore(piniaId, options)
    }

    // Сохраняем piniaId и id стора в scopedStoresIdsByScopeId
    scopedStoresIdsByScope[scopeId] = scopedStoresIdsByScope[scopeId] ?? {}
    scopedStoresIdsByScope[scopeId][id] = piniaId

    // После создания стора провайдим его piniaId всем потомкам. Так они смогут получить к нему доступ
    // Для совместимости с Options API и map-фукнциями пришлось добавить в provide возможность задавать извне инстанс компонента-провайдера. Подробнее ниже
    // Важно! Если работаете только в Composition API - лучше заменить на обычный provide
    provideInInstance(id, piniaId, currentInstance)

    // Удаляем стор при удалении скоупа. Нет скоупа - нет scoped стора
    onUnmounted(() => {
      const pinia = getActivePinia()

      if (!pinia || !piniaId) return

      delete pinia.state.value[piniaId] // Взял из api документации pinia (https://pinia.vuejs.org/api/interfaces/pinia._StoreWithState.html#Methods-$dispose)
      delete scopedStoresByPiniaId[piniaId]
      delete scopedStoresIdsByScope[scopeId]
    }, currentInstance)

    // Возвращаем созданный стор
    return scopedStoresByPiniaId[piniaId](pinia, hot)
  }

  useStore.$id = String(Date.now()) // В scoped сторах id присваивается позже, в момент использования стора. Нужно лишь для типизации

  return useStore
}

// Vue core team убрали provides из общедоступного типа ComponentInternalInstance, пришлось его вернуть. Типизацию скопировал из сорсов ComponentInternalInstance (https://github.com/vuejs/core/blob/98f1934811d8c8774cd01d18fa36ea3ec68a0a54/packages/runtime-core/src/component.ts#L245)
type ComponentInternalInstanceWithProvides = ComponentInternalInstance & {provides?: Record<string, unknown>}

// Пришлось добавить в provide возможность задавать извне инстанс компонента-провайдера. Код практически полностью скопировал из сорсов provide, единственное отличие - currentInstance передается аргументом извне (https://github.com/vuejs/core/blob/98f1934811d8c8774cd01d18fa36ea3ec68a0a54/packages/runtime-core/src/apiInject.ts#L8)
const provideInInstance = <T>(key: InjectionKey<T> | string | number, value: T, instance: ComponentInternalInstanceWithProvides) => {
  let provides = instance.provides!

  const parentProvides =
    instance.parent && (instance.parent as ComponentInternalInstanceWithProvides).provides
  if (parentProvides === provides) {
    provides = instance.provides = Object.create(parentProvides)
  }

  provides[key as string] = value
}

Версия без комментариев

Текущее решение работает как в Compotition API, так и в Options API (совместимо с  mapState, mapWritableState, mapGetters и mapActions). Сигнатура функции defineScopedStore полностью соответствует сигнатуре оригинальной defineStore.

Обратите внимание на функцию provideInInstance. Если работаете только в Composition API или не пользуетесь map-функциями, лучше заменить её на стандартный provide.

Подробнее о замене provide

Проблема заключается в том, что currentInstance для provide сетится во время вызова setup-функции, а вызов некоторых map-функций(например, mapState) происходит перед вызовом setup. В итоге в некоторых map-функциях provide не работает, так как не может найти currentInstance. Пришлось передавать currentInstance напрямую

Пример из нашей практики

Рассмотрим использование scoped-стора в продуктовом коде YCLIENTS на примере модуля оплаты. Первым шагом создадим модуль scoped стора recordPayment (синтаксис и набор опций полностью идентичны стандартному стору Pinia):

export const useRecordPaymentStore = defineScopeYcStore('RecordPayment', { // можно использовать любой поддерживаемый Pinia синтаксис 
 state: () => ({
   isPaid: false,
 }),
 actions: {
   setIsPaid(val: boolean) {
     this.isPaid = val
   },
 },
})

Переходим к компонентам. Точкой входа в модуль оплаты является компонент VPayment.vue. Именно в нем впервые используется и инициируется scoped стор recordPayment:

export default defineComponent({
 name: 'VPayment',
 setup() {
  …

  return {
   recordPaymentStore: useRecordPaymentStore(),
  }
 },
})

Дочерние компоненты (в данном примере компонент VPaymentLoyaltyMethod.vue) модуля VPayment.vue обращаются к стору recordPayment точно также, как если бы это был стандартный Pinia стор:

export default defineComponent({
 name: 'VPaymentLoyaltyMethod',
 setup() {
  ...

  return {
   recordPaymentStore: useRecordPaymentStore(),
  }
 },
})

Сам модуль оплаты используется в нескольких компонентах-вкладках одного модального окна. В итоге в каждой вкладке модального окна у модуля VPayment будет собственное, независимое состояние, к которому все компоненты модуля могут получить доступ автоматически.

Как видно по коду, используется стандартный синтаксис Pinia, ничего нового. Для использования scoped-сторов команде нет необходимости менять устоявшиеся подходы.

При этом сторы становятся более инкапсулированными и независимыми, что значительно расширяет их область применения и позволяет справиться с описанными выше проблемами. Текущее решение также не мешает нам использовать вложенные сторы, всё будет работать из коробки. Однако привязка к скоупу влечет за собой ряд ограничений.

Ограничения

  • Scoped-сторы можно использовать только внутри компонентов или в функциях, вызываемых из компонентов. Нет инстанса компонента — нет скоупа — нет стора

  • Умирает скоуп (unmount инстанса компонента в котором впервые был использован стор) — умирает и стор

  • Для совместимости с map-функциями mapState, mapWritableState, mapGetters и mapActions пришлось использовать скрытый API инстанса компонента (currentInstance.provides). Но добиться совместимости с функцией mapStores так и не удалось

Где может быть применимо?

Главный кейс применения — сосуществование на одной странице нескольких инстансов компонента/модуля со стором, состояния которых должны быть независимы. Приведу несколько примеров: 

  • Крупный модуль, переиспользуемый между вкладки одного окна (пример из статьи) или же между несколькими табами в рамках одной страницы. Подходит любой модуль со стором, инстансы которого должны быть независимы (в нашем случае это модуль оплат)

  • Несколько таблиц со сторами на странице (пример из дискуссии на GitHub)

  • Фильтры. К примеру, если есть несколько наборов фильтров и у каждого из них своё уникальное состояние

  • Сложный контролл, состояние которого хранится в сторе, переиспользуемый в разных частых страницы 

Подходы Pinia подталкивают нас к созданию маленьких и узконаправленных сторов, в противовес массивным и многофукнциональным модулям из Vuex. Кроме того, смещается фокус с их привязки к глобальному контексту: если раньше инициализировать стор приходилось самостоятельно во время инита всего приложения, то теперь этот процесс происходит автоматически. Всё это отлично согласуется с концепцией scoped-сторов — узконаправленных, локальных сторов, привязанных к конкретному инстансу модуля.