Wzorzec flux-like dla poczatkujących (implementacja Vuex, TypeScript)

Dzień dobry w nowym roku :) Zmiany w życiu zawodowym oraz prywatnym sprawiły, że mam mnóstwo pomysłów oraz szalonych inspiracji, więc lecimy!

Nie ukrywam, że ostatnie wpisy z NodeJS z Typescript mają nas prowadzić do stworzenia prostej aplikacji z opartej na stacku (NodeJS + ExpressJS + TypeScript + VueJS) i postawimy kolejny krok w tym kierunku.

Dzisiaj wyjaśnię Wam o co chodzi z tym fluxem i pokażę Wam prościutki przykład flux-like z Vuexem, żebyśmy mogli zaimplementować go później. To zaczynamy!

Flux - co to jest?

Flux to wzorzec projektowy stworzony do ujednolicenia przepływu danych w aplikacjach, szczególnie tych frontowych. Posiada trzy części: dispatch, store i view. Nie mam odwagi ich przetłumaczyć ze względu na brak wiedzy lingwistycznej, ale zaraz dokładnie je wyjaśnię :)

View (ang. widok) to część w której użytkownik komunikuje się z aplikacją.

Store to w dużym uproszczeniu obiekt z stanem aplikacji. W prostszych aplikacjach potrzebny jest tylko obecny, ale możliwość zarządzenia "czasem" (a dokładniej zmianami na tym obiekcie) otwiera szereg możliwości.

Dispatcher to centralna część fluxa z której wysłamy prośby o zmianę stanu aplikacji.

Większość standardowych implementacji fluxopodobnych ma też czwartą część: action, która jest helperem dla dispatcha.

W fluxie przepływ danych jest jednokierunkowy: użytkownik lub stan początkowy wywołuje akcję, akcja jest wysyłana do dispatchera, dispatcher zmienia stan, a stan zmienia widok. Potem ponownie użytkownik wywołuje akcję i wracamy do początku. Każdy z etapów jest niezależny od siebie, z wyjątkiem prostych funkcji wejścia/wyjścia.

Co daje nam Flux i dlaczego powinniśmy go używać?

Przede wszystkim aktualizacja danych na frontcie, szczególnie gdy jest zbudowany z komponentów, wymaga ciągłej wymiany informacji między nimi. Łatwo wpaść w dwie bardzo głębokie dziury związane z aktualizacjami: kaskada update'ów (propagacja danych) oraz piekiełko eventowe, gdzie każda zmiana odpala parę trudnych do śledzenia eventów. Flux rozwiązuje ten problem.

Warto jeszcze wspomnieć o tym, że:

  1. Flux jest łatwy do implementacji w istniejących aplikacjach, szczególnie opartych na MVC.
  2. Większość implementacji Fluxa ma jedno źródło danych, co jest bardzo proste i wygodne.
  3. ułatwia debugowanie! Z użyciem prostych narzędzi w przeglądarce można nagrywać stany i odtwarzać je do poprzednich wartości.
  4. ma scentralizowane zarządzanie danymi. Żadna część aplikacji poza fluxem nie musi się o to martwić, a ty zawsze wiesz gdzie szukać błędu :)
  5. może być podzielony na moduły. Jeśli boisz się gigantycznego obiektu z ogromną ilością danych z całej aplikacji, spokojnie, implementacje zawierają proste i intuicyjne rozwiązania. Niestety nie będziemy ich dzisiaj przerabiać.

Oczywiście nic nie jest bez wad, lecz zanim zaczniemy wyliczać wady przytoczę popularny cytat twórcy Reduxa, Dana Abramova:

Biblioteki fluxa są jak okulary, będziesz wiedział, że ich potrzebujesz.

Po pierwsze rozwaga: jeśli aplikacja jest bardzo mała lub/i opiera się głównie na aspekcie wizualnym to wprowadzenie fluxa niewiele zmieni.

Trzeba także pamiętać, że są to dane przetrzymywane w pamięci przeglądarki, które trzeba synchronizować z back-endem a implementacja w starszych aplikacjach może być problematyczna.

Vuex

Vuex (a także Redux itp.) nie jest dokładną implementacją fluxa. Jest uproszczony, posiada tylko jednego store'a (zwanego jedynym źródłem prawdy), nie ma dispatcher'a w klasycznym sensie. Wiele rzeczy opisanych powyżej nie dotyczy wprost Vuexa, z czego najbardziej widać to, że od strony technicznej store zawiera wszystkie elementy fluxa.

Dla uproszczenia założę, że czytelnik zna TypeScript'a, VueJs, podstawy programowania obiektowego,  potrafi korzystać z edytora oraz managera paczek (tutaj użyłam npm'a) i posiada zainstalowaną wersję NodeJS.

Tak więc mamy prostą stronę html w której istnieje aplikacja Single File Component napisana w VueJS i oparta na TypeScriptcie (pokażę wam to w przyszłym miesiącu od podstaw, teraz skupimy się na fluxie).

npm install --save vuex

Następnie tworzymy folder store w którym umieszczamy plik store.ts. Będzie to plik w którym zainicjujemy store'a, a po tym wyeksportujemy i będziemy go importować w komponentach.

import Vuex from 'vuex';

//parts of our store I will explain latter

import { IState, state } from './state';
import { actions } from './actions';
import { mutations } from './mutations';
import { getters } from './getters';

//registration of Vuex in Vue

Vue.use(Vuex);

//defining our Vuex parts, will explain latter

export default new Vuex.Store<IState>({ 
    state, mutations, actions, getters
});

Tutaj musimy zwrócić uwagę na dwie rzeczy: rejestrację store'a w aplikacji Vue (dzięki czemu store będzie mógł być zmienną globalną) oraz eksport konstruktora z jego składowymi, które zaraz wyjaśnię.

State - migawka stanu aplikacji

State to obiekt zawierający wszystkie informacje, które będą w store.

Powiedzmy, że mamy aplikację, której główną funkcjonalnością jest wkładanie kotków do koszyka, więc możemy zadeklarować sobie tablicę dostępnych kotków oraz koszyk.

import Kitten from 'models/kitten';
import { availableKittens } from 'data';

//deklaracja interfejsu na który potem będziemy rzutować

export interface IState {
    kittens: Array<Kitten>;
    basket: object;
}

//stan początkowy aplikacji
export const state: IState = {
    kittens: availableKittens;
    basket: {
        name: 'red',
        capacity: 3,
        kittens: [],
    }
};

Powyżej mamy import nieistniejącej klasy Kitten, interfejs zawierający szkielet naszego store'a oraz obiekt ze stanem początkowym w którym mamy listę dostępnych kotków i koszyk. Dla uproszczenia pobierzemy sobie listę kotków z sztywnego, nieistniejącego obiektu availableKittens, ale normalnie powinniśmy na starcie aplikacji załadować ją z jakiegoś API.

Mutations

Mutacje to synchroniczne funkcje, które jako argument przyjmują stan oraz - gdy jest taka potrzebna - dodatkowy parametr zwany payload, gdzie zwykle przekazujemy dodatkowe dane. Najważniejsze o czym trzeba pamiętać, że stan powinien być zmieniany tylko i wyłącznie przez mutacje.

Zanim pokażę Wam kod jeszcze jedna rzecz: chociaż mutacje są po prostu przypisane do właściwości obiektu, do store'a przekazujemy obiekt rzutowany na MutationTree.

import { MutationTree } from 'vuex';
import { IState } from './state';
import Kitten from 'models/kitten';

export const mutations: MutationTree<IState> = {

    addKittenToBasket: (state, pickedUpKitten: Kitten) => {
        state.basket.kittens.push(pickedUpKitten);
    },

    changeNameOfBasketToBlue: (state) => {
        state.basket.name = 'blue';
    },

};

Proste, prawda? Teraz jedyne co musimy zrobić to użyć tych mutacji. Wywoływanie mutacji nazywamy commitowaniem i wygląda tak: store.commit('addKittenToBasket') lub store.commit('addKittenToBasket', payload).

Ale czekajcie! Czy naprawdę podajemy tutaj stringa jako argument? To nie wygląda bezpiecznie! Rzeczywiście dobrą praktyką jest zrobienie sobie enumów mutacji.

W tym samym folderze stwórzmy sobie mutation-types.ts oraz wykorzystajmy funkcjonalność z ES6 polegająca na tworzeniu właściwości na podstawie zmiennej:

//mutation-types.ts
export enum MUTATIONS = {
    ADD_KITTEN_TO_BASKET = 'ADD_KITTEN_TO_BASKET',
    CHANGE_COLOR_TO_BLUE = 'CHANGE_COLOR_TO_BLUE',
};
//final version of mutations.ts
import { MutationTree } from 'vuex';
import { IState } from './state';
import Kitten from 'models/kitten';

import { MUTATIONS } from './mutation-types';

export const mutations: MutationTree<IState> = {

    [MUTATIONS.ADD_KITTEN_TO_BASKET]: (state, pickedUpKitten: Kitten) => {
        state.basket.kittens.push(pickedUpKitten);
    },

    [MUTATIONS.CHANGE_COLOR_TO_BLUE]: (state) => {
        state.basket.name = 'blue';
    },

};

Teraz możemy commitować w dużo ładniejszym stylu: store.commit(MUTATIONS.ADD_KITTENS_TO_BASKET) :)

Akcje - pierwsze drzwi wejściowe do store'a.

Akcje to funkcje, które wykonują operacje na danych ze store'a i comitują mutacje. W przeciwieństwie do mutacji mogą być asynchroniczne i posiadać logikę biznesową. Także w tym przypadku odnoszenie się do enumów zamiast stringów jest dobrą praktyką.

import { IState } from './state';
import { ACTIONS } from './action-types';
import { MUTATIONS } from './mutation-types';
import { Store } from 'vuex';
import { ActionTree } from 'vuex';
import Kitten from 'models/kitten';

export const actions: ActionTree<IState, any> = {

    [ACTIONS.ADD_KITTEN_TO_BASKET]: ({ commit, state }, kitten) => {
        commit(MUTATIONS.ADD_KITTEN_TO_BASKET, kitten);
    },

    [ACTIONS.CHANGE_COLOR_TO_BLUE]: ({ commit }) => {
        commit(MUTATIONS.CHANGE_COLOR_TO_BLUE);
    },
};

Wywoływanie akcji wygląda tak: store.dispatch(ACTIONS.ACTION_NAME) i w takiej formie będziemy umieszczać je w metodach komponentów.

W pierwszej chwili to może być mylące, często akcja będzie zawierała tylko jedną linię, ale pamiętajmy, że tylko akcja może zwracać Promise'a i że mutacje powinny być pojedynczymi operacjami na store.

Getters - drzwi wyjściowe dla danych

Do tej pory rozmawialiśmy o zmienianiu danych w store'e, ale co z pobieraniem ich?

Pomimo, że mamy dostęp do store'a w każdej części aplikacji dobrą praktyką jest definiowanie getterów. Idealnym przykładem jest imię i nazwisko, które jest konkatenacją dwóch zmiennych. Zwykle definiowalibyśmy getter w każdym komponencie w którym potrzebujemy taki zlepek danych albo helpera na boku. Gettery pomagają nam scentralizować takie operacje.

import { IState } from './state';
import Kitten from 'models/kitten';

export const getters: GetterTree<IState, any> = {

    allKittens(state): Array<Kitten> {
        return state.kittens;
    },

    kittensFromBasket(state): Array<Kitten> {
        return state.basket.kittens;
    },

    nameOfBasket(state): string {
        return state.basket.name || 'red';
    },

};

Tutaj można trochę pomanipulować wracanymi danymi gdy zajdzie taka potrzebna, jednak często będą to tylko odniesienie do store'a.

Przykładowe użycie

Chociaż implementację na serio zostawiam na kolejny wpis, nie chcę Was zostawić bez jakiegokolwiek przykładu.

<template>
    <button v-on:click="addRandomKittenToBasket()"></button>
</template>

<script lang="ts">

    import Vue from 'vue';
    import Component from 'vue-class-component';
    import Vuex, { StoreOptions } from 'vuex';
    import store from 'src/store/store';
    import { ACTIONS } from 'src/store/action-types';

    @Component({})
    
    export default class Play extends Vue {
        constructor() {
            super();
        }

        private addRandomKittenToBasket() {
            const kitten = this.kittens.filter(kitten => !this.kittensInBasket.includes(kitten)).pop();
            
            if (kitten) {
                store.dispatch(ACTIONS.ADD_KITTEN_TO_BASKET, kitten);
            }
        }

        get kittens() {
            return store.getters.allKittens;
        }
        get kittensInBasket() {
            return store.getters.kittensFromBasket;
        }
    }
</script>

Mamy tutaj zdefiniowane przykładowe użycie, gdzie użyłam getterów w komponencie jako skrótów do getterów ze store'a oraz po naciśnięciu przycisku wywołuję akcję, która dodaje pierwszego z brzegu kotka do koszyka.  Jeśli dodawanie losowego kotka byłoby częstym przypadkiem w aplikacji, to można by stworzyć taką akcję, która byłaby reużywalna w całej aplikacji.

Podsumowanie

Jak widzicie Vuex to szybkie i proste rozwiązanie pomagające uniknąć meczącej zabawy z przekazywaniem. W obecnych czasach to wręcz podstawa dla każdego front-end developera. Dodatkowo myślę, że implementacja w TypeScriptcie jest fajną ciekawostką.

Mam nadzieje, że ten teoretyczny wpis się spodobał, bo już nie mogę doczekać się implementacji :) Jak zwykle z niecierpliwością czekam na wasze komentarze i uwagi.

Kamila

Dziękuję za poświęcony czas, będzie mi bardzo miło jak zostawisz komentarz :)