Menu Zamknij

VUEX – kilka przemyśleń po roku kodowania

Co to jest Vue i Vuex?

Jeśli nie wiesz co to jest Vue lub Vuex – to zapraszam najpierw do obejrzenia nagrania z mojej prezentacji w ramach Tech3Camp’a: https://nowoczesny-frontend.pl/tech-3camp-jest-juz-nagranie-video

Kilka przemyśleń…

Mniej więcej od roku w różnych projektach firmowych używamy tandemu Vue.js + Vuex i muszę powiedzieć, że im dalej w las tym ciekawiej 🙂 A dokładniej, im więcej używam zarządzania stanem aplikacji poprzez Vuex, tym lepiej cały ten flow układa się w głowie i odkrywam coraz wydajniejsze sposoby na stworzenie re-używalnych i bardzo prostych w swojej konstrukcji komponentów.

W trakcie tego roku obejrzałem całkiem sporo filmów z różnych prezentacji, przeczytałem też wiele artykułów o użyciu Vuexa, jednak nic nie zastąpi praktyki i własnych doświadczeń podczas rozwiązywania bieżących problemów w kodzie. Na tej podstawie postanowiłem stworzyć dość luźną listę własnych wniosków i przemyśleń odnośnie użycia Vuexa w projekcie. No to lecimy…

Czy na pewno potrzebuję Vuex’a?

Flux libraries are like glasses: you’ll know when you need them.

–Dan Abramov

O ile użycie Vuexa w przypadku ekstremalnie małego projektu oczywiście nie ma większego sensu, to jednak jeśli mamy choć kilka osobnych komponentów, zastosowanie biblioteki do zarządzania stanem aplikacji jest jak najbardziej wskazane. To jest decyzja bardziej na poziomie architektury – jeśli użyjemy Vuexa, możemy w dużej mierze pozbyć się bezpośredniej komunikacji między komponentami za pomocą emitowanych eventów. Vuex – dzięki wsparciu reaktywności – zapewni automatyczne przerenderowanie korzystających z niego komponentów.

Moduły!

Dokumentacja i przykłady bardzo często prezentują użycie pojedynczego store’a, ale w prawdziwej aplikacji takie podejście jest mało efektywne. Dobrą praktyką jest dzielenie projektu na moduły, dlaczego więc nie zastosować tego do samego Vuexa?

Podział na moduły całkiem dobrze opisuje dokumentacja: https://vuex.vuejs.org/guide/modules.html#modules

W praktyce podział store’a na moduły znacząco zwiększa czytelność i ogranicza objętość kodu. Takie podejście ułatwia też pracę w teamie – każdy programista może się skupić na swoim fragmencie aplikacji, bez tworzenia punktów ewentualnych kolizji.

W tym miejscu trzeba jednak pamiętać o jednym – taki podział na moduły jest dość umowny – w praktyce wszystkie akcje, mutacje i gettery są rejestrowane we wspólnej przestrzeni (tylko state jest prywatny dla każdego modułu) – co wymusza stosowanie dla nich unikalnych nazw. Można tego uniknąć wprowadzając dodatkowo przestrzenie nazw (namespace) – zagadnienie to jest opisane tu: https://vuex.vuejs.org/guide/modules.html#namespacing

Warto też pamiętać, że czasem może zajść potrzeba komunikacji pomiędzy modułami Vuexa – gdy potrzebujemy z poziomu jednego modułu pobrać zawartość state z innego modułu lub z głównego state – w tym wypadku można to zrobić za pomocą rootState.

Spójrzmy na przykłady z dokumentacji.

Akcja:

const moduleA = {
  // ...
  actions: {
    incrementIfOddOnRootSum ({ state, commit, rootState }) {
      if ((state.count + rootState.count) % 2 === 1) {
        commit('increment')
      }
    }
  }
}

Getter:

const moduleA = {
  // ...
  getters: {
    sumWithRootCount (state, getters, rootState) {
      return state.count + rootState.count
    }
  }
}

Destrukturyzacja obiektu kontekstu

W powyższym przykładzie akcji widzimy taki fragment kodu:

incrementIfOddOnRootSum ({ state, commit, rootState }) ...

Generalnie akcja Vuex’owa przyjmuje 2 parametry: context i data:

incrementIfOddOnRootSum (context, data) ...

W tym przypadku context jest obiektem, który można zdestrukturyzować (https://github.com/lukehoban/es6features#destructuring) i użyć z niego tylko te elementy, których potrzebujemy:

  • commit – uruchomienie mutacji
  • dispatch – uruchomienie akcji
  • getters – wywołanie gettera
  • state – dostęp do zawartości state’a
  • rootState – dostęp do głównego state’a lub dalszych modułów

Bez użycia destrukturyzacji poprzedni kod wyglądałby tak:

const moduleA = {
  // ...
  actions: {
    incrementIfOddOnRootSum (context) {
      if ((context.state.count + context.rootState.count) % 2 === 1) {
        context.commit('increment')
      }
    }
  }
}

Mapowanie VUEX w komponentach

Podłączając bibliotekę VUEX do Vue.js, otrzymujemy w efekcie dostępny globalnie w projekcie wskaźnik this.$store. Dzięki niemu możemy w komponentach odwołać się do dowolnego state, wywołać akcję, mutację czy gettera.

const Counter = {
  template: `<div>{{ count }}</div>`,
  computed: {
    count () {
      return this.$store.state.count
    }
  }
}

Jednak na dłuższą metę takie użycie dostępu do stanu vuexowego może być niewygodne, dlatego warto skorzystać z kilku funkcji udostępnionych przez tą bibliotekę:

  • mapState
  • mapMutations
  • mapActions
  • mapGetters

Kilka przykładów użycia:

// in full builds helpers are exposed as Vuex.mapState
import { mapState } from 'vuex'

export default {
  // ...
  computed: mapState({
    count: state => state.count
  })
}

W powyższym przykładzie możemy zauważyć kilka rzeczy. Przede wszystkim, mapowanie state (oraz getterów) wykonujemy w sekcji computed komponentu. Jest to o tyle istotne, że każda zmiana zawartości state w store VUEXa – dzięki reaktywności – automatycznie powoduje przerenderowanie wszystkich komponentów korzystających z tego state. Tym samym zadeklarowane w computed wartości (będące wynikiem wykonania odpowiednich funkcji) zostaną przeliczone i uaktualnione, a efekty zobaczymy od razu na ekranie.

Funkcja mapState() może przyjąć jako parametr zarówno obiekt jak i tablicę. W powyższym przykładzie w lokalnym state komponentu zostanie utworzone pole o nazwie count, będące wynikiem wykonania funkcji zwracającej zawartość state.count ze store VUEX. Przy zastosowaniu takiej składni, lokalne pole komponentu może się nazywać dowolnie. Jeśli jednak nie chcemy zmieniać jego nazwy, możemy użyć krótszego zapisu za pomocą tablicy:

computed: mapState([
  // map this.count to store.state.count
  'count'
])

Warto jest świadomie korzystać z obu formatów zapisu – bo o ile w przypadku getterów przypisanie wyniku wykonania gettera do lokalnego pola ze zmianą nazwy ma duży sens, o tyle zmiana nazw akcji czy mutacji już niekoniecznie. Może mały przykład:

import { mapGetters } from 'vuex'

export default {
  // ...
  computed: {
    ...mapGetters([
      'getDoneTodosCount'
    ])
  }
}

W tym przypadku w lokalnym state komponentu zostanie utworzone pole o nazwie getDoneTodosCount – co będzie mylące, ponieważ pole to zawiera wartość zwróconą przez wykonanie funkcji gettera, a nie definicję tej funkcji. Widząc taką nazwę w kodzie kusi nas by jej użyć jako wywołanie funkcji, np. this.getDoneTodosCount() – co skończy się błędem, bo to nie jest funkcja. Warto więc skorzystać z przypisania wyniku wykonania gettera do samodzielnie nazwanego pola. Moim zdaniem poniższy kod jest dużo bardziej czytelny i intuicyjny:

import { mapGetters } from 'vuex'

export default {
  // ...
  computed: {
    ...mapGetters({
      doneTodosCount: 'getDoneTodosCount'
    })
  }
}

Co innego użycie akcji i mutacji – mapujemy je do sekcji methods komponentu – jako funkcje – dlatego jak najbardziej możemy użyć następującej formy:

import { mapMutations, mapActions } from 'vuex'

export default {
  // ...
  methods: {
    ...mapMutations([
      'increment' // map `this.increment()` to `this.$store.commit('increment')`
    ]),
    ...mapActions([
      'addOrder' // map `this.addOrder()` to `this.$store.dispatch('addOrder')`
    ])
  }
}

Kiedy użyć getterów?

Zawartość naszego store’a, możemy pobrać na kilka sposobów – nawiązując do przykładów wyżej – w sekcji computed komponentu możemy użyć mapState() lub mapGetters(). Kiedy jednak użyć którego sposobu?

Użycie mapState() pozwala na pobranie i przypisanie do lokalnego scope wartości bezpośrednio z sekcji state w store. W ten sposób otrzymujemy czyste dane – bez żadnych modyfikacji. Ma to duży sens, jeśli są to typy proste – jakaś flaga będąca wartością boolean (true/false), np. flaga „loading” itp.

import { mapState } from 'vuex'
export default {
  // ...
  computed: {
    ...mapState({
      loading: state => state.loading
    })
  }
}

W przypadku typów złożonych, np. tablicy lub obiektu z danymi, warto pobrać dane za pomocą gettera – czyli funkcji mającej dostęp do danych ze state i zawierającej logikę, która przetwarza wstępnie dane. Możemy takim getterem np. zwracać przefiltrowaną tablicę z danymi lub wynik wykonania jakiejś innej operacji na danych ze state.

getters: {
  // ...
  doneTodosCount: (state, getters) => {
    return getters.doneTodos.length
  }
}


import { mapGetters } from 'vuex'
export default {
  // ...
  computed: {
    ...mapGetters([
      'doneTodosCount'
    ])
  }
}

Jak użyć gettera z parametrami?

Ponieważ getter jest funkcją, która w założeniu ma jakoś przetwarzać zwracane dane, może wystąpić potrzeba przekazania do tej funkcji jakiegoś parametru wejściowego.

Można to zrobić za pomocą następującej konstrukcji:

getters: {
  // ...
  getTodoById: (state) => (id) => {
    return state.todos.find(todo => todo.id === id)
  }
}

store.getters.getTodoById(2)

W powyższym przykładzie getter getTodoById(id) zwraca wynik przefiltrowania tablicy za pomocą metody .find(), do której przekazujemy parametr id.

Akcja czy mutacja?

W dokumentacji VUEXa możemy znaleźć taki schemat działania:

Wynika z niego (jak i z całego wzorca Flux, na którym Vuex jest oparty), że komponent zawsze wywołuje akcję (która jest rozpatrywana przez dispatcher), w wyniku działania akcji wywoływane są mutacje na stanie store’a, które to zmiany dzięki magii reaktywności Vue.js są automatycznie odzwierciedlane w komponencie i jego widoku.

Warto jednak wiedzieć, że nie zawsze musimy wywoływać akcję – jeśli akcja miałaby tylko wywołać mutację, to bardziej celowe będzie bezpośrednie wywołanie mutacji.

W akcjach i mutacjach widzę 2 podstawowe różnice:

  1. akcje są wywoływane asynchronicznie (a mutacje synchronicznie), co oznacza, że wywołanie akcji nie blokuje wątku JavaScriptu. Taka asynchroniczna akcja doskonale nadaje się do przetworzenia logiki – wywołania backendu requestem ajaxowym, przetworzenia wyników, wywołania różnych mutacji. Mutacja powinna obsługiwać tylko modyfikację stanu store’a – bez większej logiki (ewentualnie jakaś walidacja przesłanej wartości).
  2. akcje (w przeciwieństwie do mutacji) mają dostęp do całej instancji store i pozwalają na dostęp zarówno do state (najlepiej w celu odczytania wartości) jak i do innych akcji i mutacji, które to można z poziomu danej akcji wywołać i uruchomić. Mutacja ma dostęp tylko do samego state w store.

Komunikacja pomiędzy komponentami

Użycie w projekcie biblioteki Vuex pozwala na zmianę sposobu komunikacji pomiędzy komponentami. O ile często będziemy używać props do zasilenia jakiegoś komponentu danymi, o tyle możemy w dużej mierze zrezygnować z użycia $emit do sygnalizowania zmian do komponentów nadrzędnych.

Wystarczy, że wszystkie komponenty korzystające ze wspólnych danych będą miały je zamapowane za pomocą mapState() lub mapGetters() (lub poprzez bezpośrednie odwołanie do store VUEXa), by każda mutacja w module VUEXa została reaktywnie odwzorowana na instancji komponentu.

Takie podejście pozwala na łatwą komunikację pomiędzy równoległymi komponentami, lub też takimi, które znajdują się daleko od siebie w drzewiastej strukturze komponentów naszej aplikacji. Po prostu korzystamy ze wspólnego, wyodrębnionego i reaktywnego stanu aplikacji.

Użycie store’a Vuex jako modelu dla formularza

Formularze są nieodłączną częścią każdej aplikacji – czy będzie to filtr w panelu administracyjnym, czy formularz kontaktowy lub zamówieniowy – prędzej czy później użycie tego mechanizmu będzie konieczne. W Vue.js formularze zwykle korzystają z dwukierunkowej komunikacji ze swoim modelem – zwykle zlokalizowanym bezpośrednio w komponencie.

W przypadku użycia VUEXa można model formularza przenieść bezpośrednio do store. Nie zawsze jest to konieczne, ale są sytuacje, gdy takie podejście jest korzystne. W moim wypadku dobrze sprawdza się to na ekranach CRUDowych – gdzie wyświetlam dane z bazy, zapewniam możliwość ich filtrowania (formularz), a także dodania/edycji/usunięcia.

Przykładowy state dla takiego ekranu może wyglądać np. tak:

const state = {
  filters: {
    status: null,
    operationType: null,
    id: null,
    page: 1,
    pageSize: 20
  },
  loading: false,
  items: [],
  pagination: {
    page: null,
    totalPages: null
  }
}

Jak widać na przykładzie, stete Vuexa zawiera między innymi zawartość filtra – w tym przypadku pola status, operationType i id.

Aby zapewnić sobie prawidłową dwukierunkową komunikację między formularzem a modelem w Vuexie, stosujemy w komponencie filtra następującą konstrukcję:

import { mapMutations } from 'vuex'
export default {
  ...
  computed: {
    status: {
      get () {
        return this.$store.state.documents.filters.status
      },
      set (value) {
        this.setDocumentsFilterStatus(value)
      }
    },
    operationType: {
      get () {
        return this.$store.state.documents.filters.operationType
      },
      set (value) {
        this.setDocumentsFilterOperationType(value)
      }
    },
    id: {
      get () {
        return this.$store.state.documents.filters.id
      },
      set (value) {
        this.setDocumentsFilterId(value)
      }
    }
  },
  methods: {
    ...mapMutations([
      'setDocumentsFilterStatus',
      'setDocumentsFilterOperationType',
      'setDocumentsFilterId'
    ])
  ...
  }
}

To oczywiście wyrwany a kontekstu fragment kodu, ale pokazuje on, jak w sekcji computed komponentu Vue stworzyć pola będące modelem dla formularza. Wystarczy każde takie pole zdefiniować przy pomocy zadeklarowania funkcji get() i set() – gdzie get() pobiera i zwraca wartość bezpośrednio ze state Vuexa, a set() wykonuje mutację Vuexową.

To be STRICT, or not to be… – czyli mutowanie danych poza Vuex

Strict Mode – to taki tryb działania biblioteki Vuex, w którym szczególnie jest pilnowany sposób modyfikacji state – dozwolona jest jego modyfikacja tylko za pośrednictwem mutacji.

Ze względów wydajnościowych dokumentacja zaleca włączenie tego trybu tylko w wersji developerskiej:

const store = new Vuex.Store({
  // ...
  strict: process.env.NODE_ENV !== 'production'
})

Tryb strict jest tu kluczowy, ponieważ podczas początków pracy z Vuexem bardzo często spotkamy się z takim komunikatem błędu:

Error: [vuex] Do not mutate vuex store state outside mutation handlers.

W ten sposób Vuex upomina nas, żeby nie modyfikować state bezpośrednio. Taka sytuacja często się zdarza w przypadku typów złożonych takich jak obiekty i tablice (tak wiem, tablica to też obiekt…), które to w JavaScript są przekazywane poprzez referencję, a nie przez skopiowanie wartości.

Jeśli pobraliśmy z bazy tablicę obiektów, którą to przechowujemy w store Vuexa i wyświetlamy ją jako jakąś listę lub tabelę z danymi – to bardzo często przekazujemy pojedynczy element/obiekt z takiej listy do komponentu z jakimś formularzem edycji. Ponieważ obiekt ten jest jedynie referencją do oryginalnego obiektu w store, próba bezpośredniej modyfikacji wartości jego pól skończy się nieuchronnie powyższym błędem, a na produkcji dość nieprzewidzianym działaniem całej aplikacji.

Sposobem na ominięcie tego problemu jest wykonanie tzw. „głębokiej kopii” (deep copy/deep clone). Można to zrobić na kilka sposobów, omówiono je np. tu: https://stackoverflow.com/questions/122102/what-is-the-most-efficient-way-to-deep-clone-an-object-in-javascript

Ja w niektórych projektach używam podłączonej pod Vue biblioteki Lodash, która to udostępnia stosowną metodę.

Taka „głęboka kopia” jest już odłączona od swojego oryginału w state Vuexa, dzięki czemu możemy ją dowolnie zmodyfikować, wysłać do backendu jako np. update rekordu, a następnie uaktualnić sobie zawartość oryginalnej listy – czy to zaciągając ją ponownie w tle czy modyfikując ją tym naszym nowym obiektem – tu już metoda dowolna, byle było to wykonane odpowiednią akcją i mutacją.

Podsumowanie

Z mojej perspektywy użycie Vuexa w aplikacjach opartych o Vue.js było strzałem w 10 – sprawiło że komponenty „schudły”, część logiki została wydzielona w dedykowane moduły Vuexa, a komunikacja w całej aplikacji została bardzo uproszczona. Powiązanie scentralizowanego store z reaktywnością Vue sprawiło, że zmiany w stanie aplikacji oddziałują automatycznie na wszystkie powiązane z tym stanem komponenty i widoki, dzięki czemu sam kod staje się prostszy, bardziej czysty i przyjazny.

Zdaję sobie sprawę, że te kilka przemyśleń i własnych doświadczeń na pewno nie wyczerpują tematu – jest to też spojrzenie bardzo subiektywne i wynikające z mojej praktyki – jeśli ktoś ma odmienne zdanie lub chciałby coś uzupełnić – zapraszam do komentowania – bardzo chętnie skonfrontuję swoje spojrzenie z innym 🙂

7 komentarzy

  1. PC

    >Warto jednak wiedzieć, że nie zawsze musimy wywoływać akcję – jeśli akcja miałaby tylko wywołać mutację, to bardziej celowe będzie bezpośrednie wywołanie mutacji.

    A to nie jest tak, że Vuex za dobry pattern uznaje tylko wywoływanie akcji? Bez bezpośredniego bawienia się mutacjami mimo, iż technicznie jest to możliwe.

  2. Łukasz

    Nie wiem dlaczego ale przestawienie się z reacta na vue było ciężkie 🙂 dziwny haos łączenia wszystko w jedno.. Brak takiego składu z cała strukturą i JS. Może to tylko na początku tak mam…

  3. Przemek

    A jak Vuex ma się do wydajności, bo był kiedyś jeden projekt gdzie logika i stan był w sumie przechowywany w mixinach załadowanych do głonego pliku co suktowało tym, że nawet component który był button dostawał dużą dawne danych.

    Vuex porządkuje kod i go w sumie układa, ale jeśli chodzi o wydajność nie obciąża raczej prawda.

    Ps fajny artykuł

    • Maciek

      Jeśli prawidłowo stosujemy Vuexa – czyli podpinamy go w komponentach tylko tam gdzie to jest potrzebne, jeśli dobrze hermetyzujemy komponenty, to nie powinno to się odbić na wydajności. Na pewno nie bardziej niż całe sekwencje eventów i propsów 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *