Управление общим состоянием в Vue 3

Опубликовано: 2022-03-10
Краткое резюме ↬ Написание крупномасштабных приложений Vue может оказаться непростой задачей. Использование общего состояния в ваших приложениях Vue 3 может помочь уменьшить эту сложность. Существует ряд общих решений для решения состояния. В этой статье я расскажу о плюсах и минусах таких подходов, как фабрики, общие объекты и использование Vuex. Я также покажу вам, что будет в Vuex 5, что может изменить то, как мы все используем общее состояние в Vue 3.

Состояние может быть тяжелым. Когда мы начинаем простой проект Vue, может быть просто сохранить наше рабочее состояние для определенного компонента:

 setup() { let books: Work[] = reactive([]); onMounted(async () => { // Call the API const response = await bookService.getScienceBooks(); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } }); return { books }; },

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

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

 // App.vue <template> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert" v-if="error">{{ error }}</div> <div class="alert bg-gray-200 text-gray-900" v-if="isBusy"> Loading... </div> <router-view :key="$route.fullPath"></router-view> </div> </template>

Без эффективного способа обработки этого состояния можно было бы предложить систему публикации/подписки, но на самом деле обмен данными во многих случаях является более простым. Если вы хотите иметь общее состояние, как вы это делаете? Давайте рассмотрим некоторые распространенные способы сделать это.

Примечание . Вы найдете код для этого раздела в «основной» ветке примера проекта на GitHub.

Общее состояние в Vue 3

После перехода на Vue 3 я полностью перешел на использование Composition API. Для статьи я также использую TypeScript, хотя это не требуется для примеров, которые я вам показываю. Хотя вы можете делиться состоянием любым удобным для вас способом, я собираюсь показать вам несколько методов, которые я нахожу наиболее часто используемыми шаблонами. У каждого есть свои плюсы и минусы, так что не принимайте то, о чем я здесь говорю, как догму.

Методы включают в себя:

  • Фабрики,
  • Общие синглтоны,
  • Векс 4,
  • Векс 5.

Примечание . Vuex 5 на момент написания этой статьи находился на стадии RFC (запрос на комментарии), поэтому я хочу, чтобы вы были готовы к тому, куда движется Vuex, но сейчас нет рабочей версии этой опции.

Давайте копать…

Фабрики

Примечание . Код для этого раздела находится в ветке «Factory» примера проекта на GitHub.

Фабричный шаблон — это просто создание экземпляра интересующего вас состояния. В этом шаблоне вы возвращаете функцию, очень похожую на функцию start в Composition API. Вы бы создали область и построили компоненты того, что вы ищете. Например:

 export default function () { const books: Work[] = reactive([]); async function loadBooks(val: string) { const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books }; }

Вы можете запросить только те части созданных на фабрике объектов, которые вам нужны, например:

 // In Home.vue const { books, loadBooks } = BookFactory();

Если мы добавим флаг isBusy , чтобы показать, когда происходит сетевой запрос, приведенный выше код не изменится, но вы можете решить, где вы собираетесь показывать isBusy :

 export default function () { const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books, isBusy }; }

В другом представлении (vue?) вы можете просто запросить флаг isBusy, не зная, как работает остальная часть фабрики:

 // App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })

Но вы, возможно, заметили проблему; каждый раз, когда мы вызываем фабрику, мы получаем новый экземпляр всех объектов. Бывают случаи, когда вы хотите, чтобы фабрика возвращала новые экземпляры, но в нашем случае мы говорим о совместном использовании состояния, поэтому нам нужно переместить создание за пределы фабрики:

 const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } export default function () { return { loadBooks, books, isBusy }; }

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

Поскольку базовые объекты помечены как const , вы не сможете их заменить (и нарушить природу синглтона). Итак, этот код должен жаловаться:

 // In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const

Поэтому может быть важно убедиться, что изменяемое состояние может быть обновлено (например, с помощью books.splice() вместо назначения книг).

Другой способ справиться с этим — использовать общие экземпляры.

Еще после прыжка! Продолжить чтение ниже ↓

Общие экземпляры

Код для этого раздела находится в ветке SharedState примера проекта на GitHub.

Если вы собираетесь совместно использовать состояние, вам также следует четко понимать, что состояние является одноэлементным. В этом случае его можно просто импортировать как статический объект. Например, мне нравится создавать объект, который можно импортировать как реактивный объект:

 export default reactive({ books: new Array<Work>(), isBusy: false, async loadBooks() { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books.splice(0, this.books.length, ...response.data.works); } this.isBusy = false; } });

В этом случае вы просто импортируете объект (который я называю хранилищем в этом примере):

 // Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });

Тогда становится легко привязываться к состоянию:

 <!-- Home.vue --> <div class="grid grid-cols-4"> <div v-for="book in state.books" :key="book.key" class="border bg-white border-grey-500 m-1 p-1" > <router-link :to="{ name: 'book', params: { id: book.key } }"> <BookInfo :book="book" /> </router-link> </div>

Как и в случае с другими шаблонами, вы получаете преимущество, заключающееся в том, что вы можете разделить этот экземпляр между представлениями:

 // App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })

Затем это может быть привязано к тому же объекту (будь то родитель Home.vue или другая страница в маршрутизаторе):

 <!-- App.vue --> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert bg-gray-200 text-gray-900" v-if="state.isBusy">Loading...</div> <router-view :key="$route.fullPath"></router-view> </div>

Независимо от того, используете ли вы фабричный шаблон или общий экземпляр, у них обоих есть общая проблема: изменяемое состояние. У вас могут быть случайные побочные эффекты привязок или изменения состояния кода, когда вы этого не хотите. В тривиальном примере, который я использую здесь, это не настолько сложно, чтобы о нем беспокоиться. Но по мере того, как вы создаете все более и более крупные приложения, вам нужно будет более тщательно продумать изменение состояния. Вот где Vuex может прийти на помощь.

Векс 4

Код для этого раздела находится в ветке «Vuex4» примера проекта на GitHub.

Vuex — это менеджер состояний для Vue. Он был построен основной командой, хотя управляется как отдельный проект. Цель Vuex — отделить состояние от действий, которые вы хотите выполнить с состоянием. Все изменения состояния должны проходить через Vuex, что означает, что это сложнее, но вы получаете защиту от случайного изменения состояния.

Идея Vuex состоит в том, чтобы обеспечить предсказуемый поток управления состоянием. Представления переходят к действиям, которые, в свою очередь, используют мутации для изменения состояния, которое, в свою очередь, обновляет представление. Ограничивая поток изменения состояния, вы должны иметь меньше побочных эффектов, которые изменяют состояние ваших приложений; поэтому проще создавать большие приложения. У Vuex есть кривая обучения, но с такой сложностью вы получаете предсказуемость.

Кроме того, Vuex поддерживает инструменты времени разработки (через Vue Tools) для работы с управлением состоянием, включая функцию, называемую перемещением во времени. Это позволяет просматривать историю состояния и перемещаться вперед и назад, чтобы увидеть, как оно влияет на приложение.

Бывают случаи, когда Vuex тоже важен.

Чтобы добавить его в свой проект Vue 3, вы можете либо добавить пакет в проект:

 > npm i vuex

Или, в качестве альтернативы, вы можете добавить его с помощью Vue CLI:

 > vue add vuex

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

Во-первых, вам понадобится объект состояния, созданный с помощью функции Vuex createStore:

 import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });

Как видите, хранилище требует определения нескольких свойств. Состояние — это просто список данных, к которым вы хотите предоставить доступ своему приложению:

 import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });

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

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

 actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },

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

 actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },

Последняя часть этого — мутации. Мутации — это функции, которые могут изменять состояние. Только мутации могут влиять на состояние. Итак, для этого примера нам нужны мутации, которые изменяют состояние:

 mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },

Функции мутации всегда передают объект состояния, чтобы вы могли изменить это состояние. В первых двух примерах вы можете видеть, что мы явно устанавливаем состояние. Но в третьем примере мы передаем состояние для установки. Мутации всегда принимают два параметра: состояние и аргумент при вызове мутации.

Чтобы вызвать мутацию, вы должны использовать функцию фиксации в хранилище. В нашем случае я просто добавлю его в деструктуризацию:

 actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },

Здесь вы увидите, как для фиксации требуется имя действия. Есть приемы, позволяющие не просто использовать магические строки, но я пока пропущу это. Такое использование магических строк является одним из ограничений использования Vuex.

Хотя использование коммита может показаться ненужной оболочкой, помните, что Vuex не позволит вам изменять состояние, кроме как внутри мутации, поэтому будут работать только вызовы через коммит .

Вы также можете видеть, что вызов setBooks принимает второй аргумент. Это второй аргумент, вызывающий мутацию. Если вам нужно больше информации, вам нужно упаковать ее в один аргумент (еще одно ограничение Vuex в настоящее время). Предполагая, что вам нужно вставить книгу в список книг, вы можете вызвать ее следующим образом:

 commit("insertBook", { book, place: 4 }); // object, tuple, etc.

Затем вы можете просто разобрать нужные вам фрагменты:

 mutations: { insertBook(state, { book, place }) => // ... }

Это элегантно? Не совсем, но это работает.

Теперь, когда у нас есть действие, работающее с мутациями, нам нужно иметь возможность использовать хранилище Vuex в нашем коде. На самом деле есть два способа попасть в магазин. Во-первых, зарегистрировав хранилище в приложении (например, main.ts/js), вы получите доступ к централизованному хранилищу, к которому у вас есть доступ везде в вашем приложении:

 // main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')

Обратите внимание, что это не добавление Vuex, а ваш реальный магазин, который вы создаете. Как только это будет добавлено, вы можете просто вызвать useStore , чтобы получить объект хранилища:

 import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...

Это отлично работает, но я предпочитаю просто импортировать магазин напрямую:

 import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...

Теперь, когда у вас есть доступ к объекту хранилища, как вы его используете? Для состояния вам нужно будет обернуть их вычисляемыми функциями, чтобы изменения распространялись на ваши привязки:

 export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });

Чтобы вызвать действия, вам нужно будет вызвать метод отправки :

 export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });

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

 const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);

Обратите внимание, что такой вызов вызовет ошибку (поскольку вы не можете изменить состояние вручную):

 const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;

Это реальная сила здесь, мы хотели бы контролировать, где изменяется состояние, и не иметь побочных эффектов, которые приводят к ошибкам в дальнейшей разработке.

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

Большая проблема с Vuex 4 заключается в том, что работа с ним в проекте TypeScript оставляет желать лучшего. Вы, конечно, можете создавать типы TypeScript, чтобы помочь в разработке и сборке, но для этого требуется много движущихся частей.

Вот где Vuex 5 предназначен для упрощения работы Vuex в TypeScript (и в проектах JavaScript в целом). Давайте посмотрим, как это будет работать, когда оно будет выпущено в следующий раз.

Векс 5

Примечание . Код для этого раздела находится в ветке «Vuex5» примера проекта на GitHub.

На момент написания этой статьи Vuex 5 еще не существовал. Это RFC (запрос комментариев). Это план. Это отправная точка для обсуждения. Так что многое из того, что я могу объяснить здесь, скорее всего, несколько изменится. Но чтобы подготовить вас к изменениям в Vuex, я хотел дать вам представление о том, куда они идут. Из-за этого код, связанный с этим примером, не собирается.

Основные концепции работы Vuex несколько изменились с момента его создания. С введением Vue 3 Vuex 4 был создан, чтобы в основном позволить Vuex работать в новых проектах. Но команда пытается найти настоящие болевые точки Vuex и решить их. С этой целью они планируют несколько важных изменений:

  • Больше никаких мутаций: действия могут мутировать состояние (и, возможно, кого угодно).
  • Улучшенная поддержка TypeScript.
  • Улучшенная функциональность мультимагазина.

Итак, как это будет работать? Начнем с создания магазина:

 export default createStore({ key: 'bookStore', state: () => ({ isBusy: false, books: new Array<Work>() }), actions: { async loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } }, getters: { findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } } });

Первое изменение, которое нужно увидеть, это то, что каждому магазину теперь нужен собственный ключ. Это позволит вам получить несколько магазинов. Далее вы заметите, что объект состояния теперь является фабрикой (например, возвращается из функции, а не создается при синтаксическом анализе). И раздела мутаций больше нет. Наконец, внутри действий вы можете видеть, что мы обращаемся к состоянию как к свойствам указателя this . Больше не нужно переходить в состояние и совершать действия. Это помогает не только упростить разработку, но и упрощает вывод типов для TypeScript.

Чтобы зарегистрировать Vuex в своем приложении, вы зарегистрируете Vuex вместо своего глобального хранилища:

 import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')

Наконец, чтобы использовать магазин, вы импортируете магазин, а затем создадите его экземпляр:

 import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...

Обратите внимание, что из хранилища возвращается объект фабрики, который возвращает этот экземпляр хранилища, независимо от того, сколько раз вы вызываете фабрику. Возвращенный объект — это просто объект с действиями, состоянием и геттерами как гражданами первого класса (с информацией о типе):

 onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;

Здесь вы увидите, что состояние (например, currentPage ) — это просто свойства. А действия (например, loadBooks ) — это просто функции. Тот факт, что вы используете здесь магазин, является побочным эффектом. Вы можете обращаться с объектом Vuex просто как с объектом и заниматься своей работой. Это значительное улучшение API.

Еще одно изменение, которое важно отметить, заключается в том, что вы также можете создать свой магазин, используя синтаксис, подобный Composition API:

 export default defineStore("another", () => { // State const isBusy = ref(false); const books = reactive(new Array≷Work>()); // Actions async function loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } // Getters const bookCount = computed(() => this.books.length); return { isBusy, books, loadBooks, findBook, bookCount } });

Это позволяет вам создавать свой объект Vuex так же, как ваши представления с помощью Composition API, и, возможно, это проще.

Одним из основных недостатков этого нового дизайна является то, что вы теряете неизменность состояния. Ведутся дискуссии о возможности включить это (только для разработки, как и в Vuex 4), но нет единого мнения, насколько это важно. Я лично считаю, что это ключевое преимущество Vuex, но нам еще предстоит увидеть, как это работает.

Где мы?

Управление общим состоянием в одностраничных приложениях является важной частью разработки большинства приложений. Наличие плана того, как вы хотите действовать в Vue, является важным шагом в разработке вашего решения. В этой статье я показал вам несколько шаблонов для управления общим состоянием, в том числе то, что будет в Vuex 5. Надеюсь, теперь у вас есть знания, чтобы принять правильное решение для ваших собственных проектов.