Управление состоянием в приложениях на Vue
Описание основных способов управления состоянием Vue приложения
Задача
Необходимо хранить общее состояние приложения с возможностью доступа к нему и возможностью вносить изменения в это состояние из любого компонента.
Решение
Есть несколько способов реализации общего состояния.
1. Хранение состояния в родительском компоненте
В небольших приложениях, компоненты которых имеют не более двух уровней вложенности, можно хранить состояние в общем родительском компоненте и передавать данные в дочерние компоненты в виде свойств (props). Изменения в общее состояние вносятся посредством генерации событий дочерними компонентами.
App.vue
<template>
<AppChild :doubled-count="doubledCount" @increment="increment" />
<AppChild :doubled-count="doubledCount" @increment="increment" />
</template>
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => {
count.value += 1
}
</script>
AppChild.vue
<template>
<div>
doubledCount: {{ doubledCount }}
</div>
<div>
<button @click="$emit('increment')">increment</button>
</div>
</template>
<script setup lang="ts">
defineProps<{ doubledCount: number }>()
defineEmits<{ increment: [number] }>()
</script>
Если приложение достигает более двух уровней вложенности, такой подход становится слишком громоздким, поскольку приходится передавать данные на множество уровней вниз по дереву компонентов и генерировать события на множество уровней вверх. Существуют следующие альтернативные подходы.
2. Использование provide/inject
Здесь родительский компонент продолжает хранить общее состояние,
но вместо передачи этого состояния через свойства компонента оно предоставляется посредством функции provide
.
Любой компонент ниже по дереву может получить доступ к этому состоянию с помощью функции inject
.
Изменения вносятся в общее состояние с помощью методов, предоставляемых через inject
.
App.vue
<template>
<AppChild />
<AppChild />
</template>
<script setup>
import { ref, provide, computed } from 'vue'
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => {
count.value += 1
}
provide('appStore', { doubledCount, increment })
</script>
AppChild.vue
<template>
<div>
<AppDeepDescendant />
</div>
</template>
AppDeepDescendant.vue
<template>
<div>
doubledCount: {{ appStore.doubledCount }}
</div>
<div>
<button @click="appStore.increment">increment</button>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
const appStore = inject('appStore')
</script>
Данный подход особенно полезен, когда требуется наличие нескольких независимых состояний одинакового типа, каждое из которых используется внутри отдельного поддерева компонентов.
App.vue
<template>
<StoreProvider>
<AppChild />
</StoreProvider>
<StoreProvider>
<AppChild />
</StoreProvider>
</template>
StoreProvider.vue
<script setup>
import { ref, provide, computed } from 'vue'
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => {
count.value += 1
}
provide('appStore', { doubledCount, increment })
</script>
В случае необходимости иметь одно единственное общее состояние хорошо подойдет третий способ.
3. Реализация глобального состояния в виде синглтон-композабл
Такую возможность предоставляет функция createGlobalState
из библиотеки VueUse.
Эта функция создает композабл, который используется всеми заинтересованными компонентами как обычный композабл.
Этот композабл создает состояние только один раз, и все последующие его вызовы возвращают один и тот же
экземпляр состояния.
App.vue
<template>
<AppChild />
<AppChild />
</template>
useAppStore.ts
import { createGlobalState } from '@vueuse/core'
export const useAppStore = createGlobalStore(() => {
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => {
count.value += 1
}
return {
doubledCount,
increment
}
})
AppDeepDescendant.vue
<template>
<div>
doubledCount: {{ appStore.doubledCount }}
</div>
<div>
<button @click="appStore.increment">increment</button>
</div>
</template>
<script setup>
import { useAppStore } from './useAppStore'
const appStore = useAppStore()
</script>
Иногда похожее поведение реализуется путём вынесения реактивных объектов
в область видимости модуля (т.е. с внешней стороны композабл-функции).
Такой способ имеет ряд недостатков (например, побочные эффекты при импорте модулей).
Поэтому такому способу лучше предпочесть createGlobalState
.
4. Гибридный подход с createInjectionState
Гибридом глобального композабл и provide/inject является подход, реализуемый
с помощью функции createInjectionState
из уже упомянутой выше библиотеки VueUse.
Эта функция позволяет передавать состояние вниз по дереву компонентов,
но не требует явного вызова inject
в потребителях, а позволяет использовать композабл функции.
App.vue
<template>
<StoreProvider>
<AppChild />
</StoreProvider>
<StoreProvider>
<AppChild />
</StoreProvider>
</template>
StoreProvider.vue
<script setup>
import { provideAppStore } from './useAppStore'
provideAppStore()
</script>
useAppStore.ts
import { createInjectionState } from '@vueuse/core'
export const [provideAppStore, useAppStore] = createInjectionState(() => {
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const increment = () => {
count.value += 1
}
return {
doubledCount,
increment
}
})
AppDeepDescendant.vue
<template>
<div>
doubledCount: {{ appStore.doubledCount }}
</div>
<div>
<button @click="appStore.increment">increment</button>
</div>
</template>
<script setup>
import { useAppStore } from './useAppStore'
const appStore = useAppStore()
</script>
Такой подход позволяет совместить преимущества чистого provide/inject
(возможность иметь несколько корневых состояний одного типа)
с удобством композабл функций (нет необходимости явного вызова
inject
с соответствующим ключом, что делает использование проще).
Заключение
Описанные методы не исчерпывают всех способов управления состоянием во вью приложениях, но составляют базовый инструментарий, подходящий как для простых, так и для достаточно сложных приложений.