Проблема

На сегодняшний день все стейт-менеджеры экосистемы 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-сторов — узконаправленных, локальных сторов, привязанных к конкретному инстансу модуля.

Комментарии (19)


  1. popov-a-e
    02.04.2023 20:16
    +1

    Спасибо за статью!
    А мы вот отказались от централизованного хранилища состояний насовсем - в пользу сервисов с Composition API (или use-функций, кому как удобнее). Пожалуй, единственный минус - отсутствие удобной навигации в devtools. Также нет возможности вернуться по состоянию в прошлое, но это и так мало кто всерьез использует.
    Зато из плюсов - никакой магии, полная поддержка типов (это ведь просто классы / функции), нет ограничений на композицию классов, сервисы можно без проблем тестировать, внедрить DI и так далее.


    1. knerok Автор
      02.04.2023 20:16

      Спасибо Вам за комментарий!

      Отказ от централизованного хранилища состояний - тоже решение, и даже более чистое, но как мне кажется, достаточно радикальное и также может повлечь за собой последствия в виде props drilling и усложнения логики в компоненте

      В данной статье главной целью было предложить решение для уже устоявшейся концепции


  1. gmtd
    02.04.2023 20:16

    Да, чем вам обычная композбл функция не угодила?
    Pinia - глобализация локального стейта. А вы берете глобальный стейт и обратно локализуете.


    1. knerok Автор
      02.04.2023 20:16

      Если мы решаем для этих же целей использовать композабл, то вновь возникает проблема хранения состояния для всех компонентов модуля целиком. Нам необходимо, чтобы доступ к состоянию был на всех уровнях иерархии компонентов.

      По сути у нас три варианта:

      1. Инициализируем composable на самом верху и прокидываем состояние пропсами вниз. По сути это отказ от централизованного хранилища. Это решение, и даже более чистое, но как мне кажется, достаточно радикальное и также может повлечь за собой последствия в виде props drilling(особенно больно выглядит при большом количестве уровней в иерархии компонентов) и усложнения логики в компоненте. Думаю не каждый на готов пойти на этот шаг и отказаться от Flux архитектуры

      2. Каждый раз заново инициализировать состояние при использовании composable. Тоже кажется не совсем решает нашу проблему и добавляет кучу усложнении логике компонентов

      3. Вынести состояние за composable. Условно создать реактивную переменную-состояние и обращаться к нему внутри composable. Здесь мы сталкиваемся с аналогичными проблемами Pinia, которые я пытался исправить в моем посте

      И как мы видим, у каждого из этих вариантов есть свои недостатки


      1. gmtd
        02.04.2023 20:16

        Пункт 1 и provide/inject?

        Кроме того, вы уверены, что надо прокидывать всё состояние, а не конкретные реактивные переменные?

        Сколько у вас уровней, что встает проблема props drilling-a?


        1. knerok Автор
          02.04.2023 20:16

          Да, но у provide/inject есть свои недостатки, хорошо это удалось описать Илье Климову(с 10:30 минуты, https://www.youtube.com/watch?v=p3vfmNIjmW4). В доке Vue одно время даже был варнинг по его использованию, с чем я согласен и на что мы уже натыкались сами

          Реактивные переменные это же и есть состояния. Да, не все из них можно отнести к состоянию всего модуля, но в нашем случае модуль крупный, используется в нескольких местах, состояние соотвественно тоже довольно большое

          Также у нас в модуле оплаты из статьи около 10 уровней вложенности

          Честно говоря мы пробовали идти по этому пути и именно из-за недостатков выше были вынуждены от него отказаться


  1. Dolios
    02.04.2023 20:16

    Pinia scoped стор

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


    1. knerok Автор
      02.04.2023 20:16
      +1

      Хотел как лучше, а получилось как всегда) Исправил


  1. AntonCheloshkin
    02.04.2023 20:16
    +1

    А вы уверены что модуль module/1 удалится при удалении компонента?
    А его точно ни кто кроме Вас не получит?
    Вместо mapState можно использовать UseNamespacedGetters

    Поделюсь своими мыслями, более лаконичная реализация.

    Использовать функциональный компонент scoped-store, который создает scoped стору, и провайдит прямо инстанс сторы.

    useStore этот инстанс пытается инжектить и возвращает глобальную стору в случае если ни чего нету.
    Либо, бросает исключение - зависит от контекста.
    Есть жесткий варн на provide/inject.

    Прикол в том, как организованы модули.

    Глобальные модули инициализируются статическим объектом state.
    Тогда мы уверены что состояние модуля будет единым для всех инстансов сторы.

    scoped модули - state инициализируется из функции, это гарантирует создание пустого индивидуального стейта на каждый инстанс.

    Хороший пример - у нас есть два кейса.
    Компонент используется как корневой в роутере, тогда стейт прилетает в глобальную стору.
    И на этой же странице нам нужно показать модалку, в которой нам нужно отобразить этот компонент в списке
    Каждый заворачиваем в скопед стору и привет.
    Конечно, настолько элементарный пример реализуется без таких приседаний.
    Но уж если дело дошло до скопед стор, то такое решение проще и юзабельнее подключеня новых модулей в глобальную стору.

    Встряхнули и полетели. Потомки ни чего не знают о верхних модулях.
    Не забываем что подход provide/inject не только плох, но и, в чем-то, необходим.


    1. knerok Автор
      02.04.2023 20:16

      Да, модуль стора удалится, для этого добавлен хук onUnmounted, в котором и происходит очистка

      Ваш вариант через компоненты тоже интересный, спасибо за коммент! Единственный момент - думаю не всем может подойти создание функциональных компонентов scoped сторов. Есть риски усложнить код + наружу выносится то, что у меня располагается под капотом(и кажется неплохо под этим капотом себя чувствует)). Также сама идея делать из компонента стор будет выглядит немного странновато, если будет сосуществовать одновременно с классическим стором на pinia или vuex(которые используются в большинстве проектов на Vue)

      Также стоит отметить, что я не предлагаю заменить все pinia сторы на pinia scoped сторы. Скорее предлагаю инструмент, который можно использовать там, где классический подход pinia не справляется)


      1. AntonCheloshkin
        02.04.2023 20:16

        Модуль стора удалится, но все еще доступен глобально.

        При возникновении исключения в onUnmounted он останется в сторе

        Как раз, в этом случае, scoped store будет виден только внутри компонента и не доступен больше ниоткуда

        И гарантированно удалится без хуков


        1. knerok Автор
          02.04.2023 20:16

          Модуль стора не будет доступен глобально из за строчки

          delete pinia.state.value[piniaId] 

          Взял из api документации pinia

          По исключениям - да, от них в целом трудно застраховаться


          1. AntonCheloshkin
            02.04.2023 20:16

            Пока компонент не удален - будет
            И Вы удаляете не модуль, а стейт?

            А как же сайд эффекты, обсерверы?


            1. knerok Автор
              02.04.2023 20:16

              Да, пока компонент жив в pinia будет существовать scoped стор и будет лежать рядом с обычными сторами. В имени scoped стора будет содержаться id скоупа, его сложно будет перепутать с другими модулями

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


              1. AntonCheloshkin
                02.04.2023 20:16

                А сам стор?
                Реактивщина внутри стора.
                Есть регистер/унрегистер, почему не он?


                1. knerok Автор
                  02.04.2023 20:16

                  У pinia есть унрегистер? По-моему это было только у vuex. Или вы про $dispose?

                  https://github.com/vuejs/pinia/issues/557


              1. AntonCheloshkin
                02.04.2023 20:16

                И, если я не путаю, правильно использовать событие onBeforeUnmount
                Может получиться что не вся реактивщина помрет

                И в унмоунтед что-то может посчитать что оно погибло и/или что-то уже не доступно


                1. knerok Автор
                  02.04.2023 20:16

                  По onBeforeUnmount подумаем, спасибо!


      1. AntonCheloshkin
        02.04.2023 20:16

        Также сама идея делать из компонента стор будет выглядит немного странновато

        Странновато, так же, лежит и глобальная версия сторы в корневом компоненте Vue