Zarządzanie stanem udostępnionym w Vue 3
Opublikowany: 2022-03-10Stan może być trudny. Kiedy zaczynamy prosty projekt Vue, łatwo jest po prostu zachować stan roboczy na konkretnym komponencie:
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 }; },
Kiedy Twój projekt jest pojedynczą stroną pokazującą dane (na przykład w celu ich sortowania lub filtrowania), może to być atrakcyjne. Ale w tym przypadku ten komponent otrzyma dane na każde żądanie. A jeśli chcesz go zatrzymać? W tym miejscu do gry wchodzi zarządzanie państwem. Ponieważ połączenia sieciowe są często drogie i czasami zawodne, lepiej byłoby zachować ten stan podczas przeglądania aplikacji.
Kolejną kwestią jest komunikacja między komponentami. Chociaż możesz używać zdarzeń i rekwizytów do komunikowania się z bezpośrednimi rodzicami-dziećmi, radzenie sobie z prostymi sytuacjami, takimi jak obsługa błędów i flagi zajętości, może być trudne, gdy każdy z twoich widoków/stron jest niezależny. Na przykład wyobraź sobie, że kontrolka najwyższego poziomu została podłączona, aby pokazać błąd i animację ładowania:
// 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>
Bez skutecznego sposobu radzenia sobie z tym stanem może to sugerować system publikowania/subskrybowania, ale w rzeczywistości udostępnianie danych jest w wielu przypadkach prostsze. Jeśli chcesz mieć wspólny stan, jak się do tego zabrać? Przyjrzyjmy się kilku typowym sposobom na zrobienie tego.
Uwaga : kod tej sekcji znajdziesz w „głównej” gałęzi przykładowego projektu w serwisie GitHub.
Stan udostępniony w Vue 3
Odkąd przeszedłem na Vue 3, całkowicie przeszedłem do korzystania z interfejsu Composition API. W artykule używam również języka TypeScript, chociaż nie jest to wymagane w przykładach, które ci pokazuję. Chociaż możesz udostępniać stany w dowolny sposób, pokażę Ci kilka technik, które według mnie są najczęściej używane. Każdy ma swoje plusy i minusy, więc nie traktuj niczego, o czym mówię, jako dogmatu.
Techniki obejmują:
- Fabryki,
- Współdzielone Singletony,
- Vuex 4,
- Vuex 5.
Uwaga : Vuex 5, w momencie pisania tego artykułu, jest na etapie RFC (Request for Comments), więc chcę cię przygotować na to, dokąd zmierza Vuex, ale w tej chwili nie ma działającej wersji tej opcji.
Zagłębmy się…
Fabryki
Uwaga : kod tej sekcji znajduje się w gałęzi „Fabryki” przykładowego projektu w serwisie GitHub.
Wzorzec fabryki polega po prostu na tworzeniu instancji stanu, na którym Ci zależy. W tym wzorcu zwracasz funkcję, która jest bardzo podobna do funkcji start w interfejsie API kompozycji. Stworzysz zakres i zbudujesz składniki tego, czego szukasz. Na przykład:
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 }; }
Możesz poprosić tylko o te części obiektów utworzonych w fabryce, których potrzebujesz:
// In Home.vue const { books, loadBooks } = BookFactory();
Jeśli dodamy flagę isBusy
, aby pokazać, kiedy wystąpi żądanie sieciowe, powyższy kod się nie zmieni, ale możesz zdecydować, gdzie chcesz pokazać 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 }; }
W innym ujęciu (vue?) możesz po prostu poprosić o flagę isBusy, nie wiedząc o tym, jak działa reszta fabryki:
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
Ale być może zauważyłeś problem; za każdym razem, gdy dzwonimy do fabryki, otrzymujemy nową instancję wszystkich obiektów. Zdarza się, że chcesz, aby fabryka zwracała nowe instancje, ale w naszym przypadku mówimy o współdzieleniu stanu, więc musimy przenieść kreację poza fabrykę:
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 }; }
Teraz fabryka daje nam współdzieloną instancję lub singletona, jeśli wolisz. Chociaż ten wzorzec działa, zwracanie funkcji, która nie tworzy nowej instancji za każdym razem, może być mylące.
Ponieważ obiekty leżące pod spodem są oznaczone jako const
, nie powinieneś być w stanie ich zastąpić (i złamać natury singletona). Więc ten kod powinien narzekać:
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
Dlatego ważne może być upewnienie się, że stan mutowalny może być aktualizowany (np. za pomocą books.splice()
zamiast przypisywania książek).
Innym sposobem poradzenia sobie z tym jest użycie współdzielonych instancji.
Udostępnione instancje
Kod tej sekcji znajduje się w gałęzi „SharedState” przykładowego projektu w serwisie GitHub.
Jeśli zamierzasz dzielić stan, równie dobrze możesz jasno powiedzieć, że stan jest singletonem. W takim przypadku można go po prostu zaimportować jako obiekt statyczny. Na przykład lubię tworzyć obiekt, który można zaimportować jako obiekt reaktywny:
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; } });
W tym przypadku po prostu importujesz obiekt (który w tym przykładzie nazywam sklepem):
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
Wtedy łatwo jest związać się ze stanem:
<!-- 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>
Podobnie jak w przypadku innych wzorców, zyskujesz tę zaletę, że możesz udostępniać tę instancję między widokami:
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
Następnie może się to powiązać z tym samym obiektem (niezależnie od tego, czy jest to obiekt nadrzędny Home.vue
czy inna strona w routerze):
<!-- 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>
Niezależnie od tego, czy używasz wzorca fabryki, czy współużytkowanej instancji, oba mają wspólny problem: stan zmienny. Możesz mieć przypadkowe skutki uboczne powiązań lub zmiany kodu, gdy tego nie chcesz. W trywialnym przykładzie, którego używam tutaj, nie jest to wystarczająco skomplikowane, aby się martwić. Ale kiedy budujesz coraz większe aplikacje, będziesz chciał dokładniej przemyśleć mutację stanu. Właśnie tam Vuex może przyjść na ratunek.
Vuex 4
Kod tej sekcji znajduje się w gałęzi „Vuex4” przykładowego projektu na GitHub.
Vuex jest menedżerem stanu w Vue. Został zbudowany przez główny zespół, choć zarządzany jest jako osobny projekt. Celem Vuex jest oddzielenie stanu od działań, które chcesz zrobić ze stanem. Wszystkie zmiany stanu muszą przejść przez Vuex, co oznacza, że jest to bardziej złożone, ale otrzymujesz ochronę przed przypadkową zmianą stanu.
Ideą Vuex jest zapewnienie przewidywalnego przepływu zarządzania państwem. Widoki przepływają do Akcji, które z kolei używają Mutacji do zmiany Stanu, co z kolei aktualizuje Widok. Ograniczając przepływ zmian stanu, powinieneś mieć mniej skutków ubocznych, które zmieniają stan twoich aplikacji; dlatego łatwiej jest budować większe aplikacje. Vuex ma krzywą uczenia się, ale dzięki tej złożoności uzyskujesz przewidywalność.
Ponadto Vuex obsługuje narzędzia czasu rozwoju (za pośrednictwem narzędzi Vue) do pracy z zarządzaniem stanem, w tym funkcję o nazwie podróże w czasie. Umożliwia to przeglądanie historii stanu i poruszanie się wstecz i do przodu, aby zobaczyć, jak wpływa to na aplikację.
Są też chwile, kiedy Vuex też jest ważny.
Aby dodać go do swojego projektu Vue 3, możesz dodać pakiet do projektu:
> npm i vuex
Lub alternatywnie możesz go dodać za pomocą Vue CLI:
> vue add vuex
Korzystając z CLI, utworzy punkt wyjścia dla Twojego sklepu Vuex, w przeciwnym razie będziesz musiał ręcznie połączyć go z projektem. Zobaczmy, jak to działa.
Najpierw potrzebujesz obiektu stanu, który jest tworzony za pomocą funkcji createStore Vuex:
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
Jak widać, sklep wymaga zdefiniowania kilku właściwości. Stan to tylko lista danych, do których aplikacja ma mieć dostęp:
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
Pamiętaj, że stan nie powinien używać opakowań ref ani reaktywnych . Te dane są tym samym rodzajem danych udostępnionych, których używaliśmy w przypadku udostępnionych wystąpień lub fabryk. Ten sklep będzie singletonem w Twojej aplikacji, dlatego dane w stanie również będą udostępniane.
Następnie spójrzmy na działania. Akcje to operacje, które chcesz włączyć i które dotyczą stanu. Na przykład:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
Akcje są przekazywane do instancji sklepu, dzięki czemu można uzyskać stan i inne operacje. Normalnie zdekonstruowalibyśmy tylko te części, których potrzebujemy:
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
Ostatnim elementem są Mutacje. Mutacje to funkcje, które mogą mutować stan. Tylko mutacje mogą wpływać na stan. Tak więc w tym przykładzie potrzebujemy mutacji, które zmieniają stan:
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
Funkcje mutacji zawsze przechodzą do obiektu stanu, dzięki czemu można zmutować ten stan. W pierwszych dwóch przykładach widać, że jawnie ustawiamy stan. Ale w trzecim przykładzie przekazujemy stan do ustawienia. Mutacje zawsze przyjmują dwa parametry: stan i argument podczas wywoływania mutacji.
Aby wywołać mutację, użyjesz funkcji commit w sklepie. W naszym przypadku dodam to tylko do destrukturyzacji:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
Zobaczysz tutaj, jak zatwierdzenie wymaga nazwy akcji. Istnieją sztuczki, dzięki którym nie tylko używa się magicznych ciągów, ale na razie to pominę. Takie użycie magicznych ciągów jest jednym z ograniczeń korzystania z Vuex.
Chociaż używanie commita może wydawać się niepotrzebnym wrapperem, pamiętaj, że Vuex nie pozwoli ci mutować stanu poza mutacją, dlatego tylko wywołania za pomocą commita .
Możesz również zobaczyć, że wywołanie setBooks przyjmuje drugi argument. To drugi argument, który nazywa się mutacją. Gdybyś potrzebował więcej informacji, musiałbyś umieścić je w jednym argumencie (obecnie kolejne ograniczenie Vuex). Zakładając, że musisz wstawić książkę do listy książek, możesz to nazwać tak:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
Wtedy możesz po prostu rozłożyć na części, których potrzebujesz:
mutations: { insertBook(state, { book, place }) => // ... }
Czy to jest eleganckie? Nie bardzo, ale to działa.
Teraz, gdy nasza akcja pracuje z mutacjami, musimy mieć możliwość korzystania ze sklepu Vuex w naszym kodzie. Do sklepu można dostać się naprawdę na dwa sposoby. Po pierwsze, rejestrując sklep za pomocą aplikacji (np. main.ts/js), będziesz miał dostęp do scentralizowanego sklepu, do którego masz dostęp z każdego miejsca w aplikacji:
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
Pamiętaj, że nie jest to dodawanie Vuex, ale rzeczywisty sklep, który tworzysz. Po dodaniu możesz po prostu wywołać useStore
, aby uzyskać obiekt sklepu:
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
Działa to dobrze, ale wolę po prostu importować sklep bezpośrednio:
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
Teraz, gdy masz już dostęp do obiektu sklepu, jak z niego korzystasz? W przypadku stanu musisz owinąć je obliczonymi funkcjami, aby zmiany były propagowane do twoich powiązań:
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
Aby wywołać akcje, musisz wywołać metodę wysyłki :
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
Akcje mogą mieć parametry, które dodajesz po nazwie metody. Na koniec, aby zmienić stan, musisz wywołać zatwierdzenie, tak jak zrobiliśmy to w Akcjach. Na przykład mam w sklepie właściwość stronicowania, a następnie mogę zmienić stan za pomocą commit :
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
Zauważ, że wywołanie tego w ten sposób spowoduje błąd (ponieważ nie możesz ręcznie zmienić stanu):
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
To jest tutaj prawdziwa siła, chcielibyśmy mieć kontrolę, w której stan się zmienia, i nie mieć skutków ubocznych, które powodują błędy na dalszych etapach rozwoju.
Możesz być przytłoczony liczbą ruchomych elementów w Vuex, ale może to naprawdę pomóc w zarządzaniu stanem w większych, bardziej złożonych projektach. Nie powiedziałbym, że potrzebujesz go w każdym przypadku, ale będą duże projekty, w których ogólnie ci pomoże.
Duży problem z Vuex 4 polega na tym, że praca z nim w projekcie TypeScript pozostawia wiele do życzenia. Z pewnością możesz tworzyć typy TypeScript, aby pomóc w programowaniu i kompilacji, ale wymaga to wielu ruchomych elementów.
W tym miejscu Vuex 5 ma na celu uproszczenie działania Vuex w TypeScript (i ogólnie w projektach JavaScript). Zobaczmy, jak to będzie działać, gdy zostanie wydane w następnej kolejności.
Vuex 5
Uwaga : kod tej sekcji znajduje się w gałęzi „Vuex5” przykładowego projektu na GitHub.
W czasie tego artykułu Vuex 5 nie jest prawdziwy. To RFC (prośba o komentarze). To plan. To punkt wyjścia do dyskusji. Tak więc wiele z tego, co mogę tutaj wyjaśnić, prawdopodobnie nieco się zmieni. Ale aby przygotować cię na zmianę w Vuex, chciałem pokazać, dokąd zmierza. Z tego powodu kod skojarzony z tym przykładem nie kompiluje się.
Podstawowe koncepcje działania Vuex pozostały nieco niezmienione od samego początku. Wraz z wprowadzeniem Vue 3, Vuex 4 został stworzony głównie po to, aby umożliwić Vuex pracę w nowych projektach. Ale zespół próbuje przyjrzeć się prawdziwym problemom związanym z Vuex i rozwiązać je. W tym celu planują kilka ważnych zmian:
- Nigdy więcej mutacji: działania mogą mutować stan (i prawdopodobnie każdego).
- Lepsza obsługa TypeScriptu.
- Lepsza funkcjonalność wielu sklepów.
Więc jak to działa? Zacznijmy od stworzenia sklepu:
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); } } });
Pierwszą zmianą, którą należy zauważyć, jest to, że każdy sklep potrzebuje teraz własnego klucza. Ma to na celu umożliwienie odzyskania wielu sklepów. Następnie zauważysz, że obiekt stanu jest teraz fabryką (np. zwraca z funkcji, a nie tworzy się podczas parsowania). I nie ma już sekcji mutacji. Wreszcie, wewnątrz akcji, możesz zobaczyć, że uzyskujemy dostęp do stanu jako tylko właściwości wskaźnika this
. Nie musisz już przechodzić w stan i angażować się w działania. Pomaga to nie tylko w uproszczeniu programowania, ale także ułatwia wywnioskowanie typów dla TypeScript.
Aby zarejestrować Vuex w swojej aplikacji, zarejestruj Vuex zamiast swojego globalnego sklepu:
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
Na koniec, aby korzystać ze sklepu, zaimportujesz sklep, a następnie utworzysz jego instancję:
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
Zauważ, że to, co jest zwracane ze sklepu, to obiekt fabryki, który zwraca tę instancję sklepu, bez względu na to, ile razy wywołasz fabrykę. Zwrócony obiekt to po prostu obiekt z akcjami, stanem i getterami jako obywatelami pierwszej klasy (z informacją o typie):
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
Zobaczysz tutaj, że state (np. currentPage
) to po prostu proste właściwości. A akcje (np loadBooks
) to tylko funkcje. To, że korzystasz ze sklepu, jest efektem ubocznym. Możesz traktować obiekt Vuex jako zwykły przedmiot i zająć się swoją pracą. To znacząca poprawa w API.
Kolejną zmianą, na którą warto zwrócić uwagę, jest to, że możesz również wygenerować swój sklep za pomocą składni podobnej do 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 } });
Pozwala to na budowanie obiektu Vuex tak samo, jak w przypadku widoków za pomocą interfejsu Composition API i prawdopodobnie jest to prostsze.
Jedną z głównych wad tego nowego projektu jest to, że tracisz niezmienność stanu. Toczą się dyskusje na temat możliwości umożliwienia tego (tylko do rozwoju, tak jak Vuex 4), ale nie ma zgody, jak ważne jest to. Osobiście uważam, że jest to kluczowa korzyść dla Vuex, ale musimy zobaczyć, jak to się potoczy.
Gdzie jesteśmy?
Zarządzanie stanem współdzielonym w aplikacjach jednostronicowych jest kluczowym elementem tworzenia większości aplikacji. Posiadanie planu gry określającego, jak chcesz się do tego zabrać w Vue, jest ważnym krokiem w projektowaniu rozwiązania. W tym artykule pokazałem kilka wzorców zarządzania stanem współdzielonym, w tym to, co nadejdzie w Vuex 5. Mam nadzieję, że teraz będziesz miał wiedzę, aby podjąć właściwą decyzję dla swoich własnych projektów.