Хранение состояния в композитах Vue 3
Composition API – одно из главных преимуществ Vue 3, позволяющее создавать логику, которую легко переиспользовать в разных компонентах. Но с этой гибкостью приходят и новые вопросы: где хранить состояние композитов и как разные подходы влияют на приложение?
В этой статье рассмотрим основные подходы к управлению состоянием в композитах, их влияние на производительность и совместимость с SSR.
Основы реактивности
Прежде чем погрузиться в паттерны хранения состояния, освежим в памяти базовые инструменты реактивности:
-
ref– обертка над значением любого типа. Требует обращения через.value. Если внутри объект, он автоматически становится реактивным на всю глубину. -
reactive– работает только с объектами, проксируя их. Не требует.value, но теряет реактивность при деструктуризации (если не использоватьtoRefs). -
shallowRefиshallowReactive– поверхностные версии, которые не делают вложенные свойства реактивными.shallowRefследит только за заменой самого.value, аshallowReactive– только за свойствами верхнего уровня.
Теперь рассмотрим, где и как лучше хранить эти реактивные данные в контексте переиспользуемых композитов.
Паттерн 1: Локальное состояние
Самый чистый и предсказуемый подход – создавать новый экземпляр состояния при каждом вызове композита:
// useCounter.ts
import { ref } from 'vue';
export function useCounter(initialValue: number = 0) {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
// Состояние создается ВНУТРИ функции
return { count, increment, decrement };
}
// Использование в компоненте
// const { count: countA, increment: incrementA } = useCounter(10);
// const { count: countB, increment: incrementB } = useCounter(100);
// countA и countB – два разных, независимых счетчика
Преимущества:
- Изоляция данных: Каждый экземпляр композита имеет свое независимое состояние.
- Предсказуемость: Поведение понятное и легко тестируемое.
- SSR-совместимость: Идеально для серверного рендеринга – каждый запрос создает свои экземпляры состояния без протечек между клиентами.
Когда использовать: Практически всегда. Формы, локальные UI-состояния, таймеры, логика взаимодействия с API – все это отлично работает с локальным состоянием.
Совет: Если состояние не должно напрямую изменяться извне, оборачивайте его в
readonly():return { count: readonly(count), increment, decrement };
Паттерн 2: Глобальное состояние в модуле
В этом подходе состояние живет на уровне JavaScript-модуля:
// store/useTheme.ts
import { ref, readonly } from 'vue';
// Состояние объявлено ВНЕ функции!
const currentTheme = ref('light');
export function useTheme() {
const toggleTheme = () => {
currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light';
};
// Все вызовы useTheme будут работать с одним и тем же currentTheme
return { theme: readonly(currentTheme), toggleTheme };
}
Преимущества:
- Простота: Минимум кода для создания общего состояния.
- Единый источник истины: Все компоненты работают с одними данными.
Недостатки:
- Несовместимость с SSR: Критический недостаток! При серверном рендеринге модуль инициализируется один раз на сервере, и все пользователи будут видеть одно и то же состояние.
- Сложность отладки: При росте приложения может стать неочевидно, откуда приходят изменения.
Когда можно (осторожно) использовать:
- Только клиентский рендеринг: Для простых случаев и некритичных настроек.
- Константы и конфигурация: Для неизменяемых данных, одинаковых для всех пользователей.
Паттерн 3: Композиты-фабрики с параметрами
Развитие первого паттерна, где композит принимает параметры, влияющие на его поведение:
import { ref, watch, isRef, onMounted, Ref, unref } from 'vue';
export function useAsyncData(
fetchFn: (params: P) => Promise,
params?: Ref | P,
immediate: boolean = true
) {
const data = ref(null);
const isLoading = ref(false);
const error = ref(null);
const execute = async (runtimeParams?: P) => {
const currentParams = runtimeParams ?? unref(params);
isLoading.value = true;
error.value = null;
try {
data.value = await fetchFn(currentParams as P);
} catch (e: any) {
error.value = e;
data.value = null;
} finally {
isLoading.value = false;
}
};
if (isRef(params)) {
watch(params, execute, { immediate });
} else if (immediate) {
onMounted(() => execute(params as P));
}
return { data, isLoading, error, execute };
}
Этот подход сохраняет изоляцию состояния, но добавляет гибкость через параметры и реакцию на их изменения.
Когда использовать: Для повторяющихся операций с разными входными данными – работа с API, фильтрация, пагинация и т.д.
Совет: Используйте
watchдля отслеживания изменений внешних реактивных параметров и обновления состояния композита соответственно.
Паттерн 4: provide/inject – состояние для поддерева
Когда состояние должно быть доступно нескольким компонентам в определенной ветке компонентного дерева:
// types/form.ts
import type { InjectionKey, Ref } from 'vue';
export interface FormContext {
isSubmitting: Ref;
errors: Ref>;
registerField: (name: string) => void;
}
export const formContextKey: InjectionKey = Symbol('formContext');
// FormProvider.vue
//
import { provide, ref } from 'vue';
import { formContextKey } from '../types/form';
const isSubmitting = ref(false);
const errors = ref({});
const fields = ref([]);
const registerField = (name: string) => {
fields.value.push(name);
};
provide(formContextKey, {
isSubmitting,
errors,
registerField,
});
// useFormField.ts
import { inject, computed } from 'vue';
import { formContextKey } from '../types/form';
export function useFormField(fieldName: string) {
const formContext = inject(formContextKey);
if (!formContext) {
throw new Error('useFormField должен использоваться внутри FormProvider');
}
formContext.registerField(fieldName);
const error = computed(() => formContext.errors.value[fieldName]);
return {
error,
isSubmitting: formContext.isSubmitting,
};
}
Преимущества:
- Избегаем prop drilling: Не нужно передавать пропсы через множество промежуточных компонентов.
- Явная область видимости: Данные доступны только в поддереве компонентов.
- SSR-совместимость: Каждое дерево компонентов имеет свой контекст.
Когда использовать: Для компонентов, которые логически связаны и работают вместе – формы, мастеры настройки, выпадающие меню с подменю и т.д.
Совет: Используйте
InjectionKeyдля типизации иSymbolдля уникальных ключей, чтобы избежать конфликтов имен.
Оптимизация производительности с shallowRef и shallowReactive
Для больших структур данных, особенно когда вы не нуждаетесь в реактивности на всю глубину:
export function useItems(initialItems = []) {
// Только .value будет реактивным, внутренняя структура - нет
const items = shallowRef(initialItems);
const setItems = (newItems) => {
items.value = newItems; // Это вызовет обновление
};
const updateItem = (index, newValue) => {
const newItems = [...items.value];
newItems[index] = newValue;
items.value = newItems; // Нужно заменить всю ссылку
};
return { items, setItems, updateItem };
}
Когда использовать:
- Для больших массивов или объектов, где слежение за каждым вложенным свойством избыточно
- Когда вы обновляете данные целиком, а не отдельные свойства
- При интеграции с внешними системами управления состоянием
А что насчет Pinia/Vuex?
Эти библиотеки по-прежнему отлично подходят для управления сложным глобальным состоянием. Pinia прекрасно интегрируется с Composition API и поддерживает SSR. Используйте ее, когда:
- Требуется сложная логика управления состоянием
- Нужны девтулзы для отладки
- Нужна поддержка плагинов (например, для персистентности)
Подведем итоги:
-
Локальное состояние - золотой стандарт. Предсказуемое поведение, простота тестирования, полная SSR-совместимость. Не усложняйте без явной необходимости.
-
Глобальное состояние (хранение в модуле) - допустимо только при клиентском рендеринге и для простых случаев. С SSR - категорически несовместимо.
-
shallowRefиshallowReactive- незаменимы при работе с большими структурами данных, особенно когда не требуется глубокая реактивность. Используйте для оптимизации рендеринга крупных списков. -
readonly()- делает API ваших композитов безопаснее, предотвращая неконтролируемые изменения состояния извне. -
provide/inject- идеальное решение для передачи состояния вглубь дерева компонентов без пропсов. Не забывайте про типизацию черезInjectionKey. -
Pinia - когда действительно нужно управлять сложным глобальным состоянием. Отлично работает с Composition API и SSR, хотя для простых случаев может показаться избыточной.
Выбор подхода всегда зависит от конкретной задачи, но помните: чем проще и изолированнее ваше состояние, тем меньше головной боли при отладке и масштабировании приложения.