Moja częstotliwość pisana nowych postów woła po pomstę do nieba. Jestem perfekcjonista i zanim coś zrobię muszę mieć 100% pewność że jest OK… poza tym czasu też nie mam za wiele. Dość jednak tłumaczenia – przejdźmy do konkretów.

Nowa wersja języka JavaScript zawiera mnóstwo świetnych feature’ów, m.in. Promisy, Proxy, Dekoratory. Zanim jednak zaczynam korzystać z jakiegoś nowego udogodnienia muszę się do niego przekonać. Tak właśnie było z Promisami. Przez dłuższy czas, od pojawienia się pierwszych wersji obietnic (biblioteka q, Bluebird czy nawet Promisy w jQuery) ja nadal nagminnie korzystałem z callback’ow. Teraz jednak wiem że wynikało to z mojej ignorancji i braku zrozumienia zagadnienia. Podobnie z async/await, którego nie ma jeszcze w oficjalnym standardzie ECMA Script. Teraz… nie wyobrażam sobie pracy bez async/await

Przejdźmy jednak do sedna tego posta – czyli Dekoratory.

Czym są dekoratory? W skrócie jest sposób na opakowanie małego fragmentu kodu w inny fragment kodu – czyli „udekorowanie go”. Są to po prostu zwykłe funkcje, które zwracają inną funkcję, i które są wywoływane z odpowiednimi informacjami o dekorowanym fragmencie kodu. Dekorowany w ten sposób kod jest zastępowany wartością zwracaną. Dekoratory w JavaScript używają specjalnej składni, gdzie są poprzedzanie symbolem @ i umieszczanie tuż przed kodem, który chcemy „udekorować”.

Sama funkcja dekoratora jest wywoływana z 3 parametrami:

  • target – czyli klasa, w której znajduje się dekorowany kod (metoda, właściwość, getter czy setter)
  • name – czyli nazwa, dekorowanej właściwości
  • descriptor – czyli deskryptor dekorowanej właściwości (o tym czym jest deskryptor można sprawdzić tutaj => Object.defineProperty())

Poniżej przykład prostego dekoratora, którego zadaniem jest „logowanie” informacji o wywoływanej metodzie. Podczas wywołania udekorowanej metody powinniśmy w konsoli zobaczyć informację o nazwie metody i przekazanych do niej argumentach. Jeżeli wywołanie metody się powiedzie – otrzymamy informację o wyniku, w przeciwnym wypadku otrzymamy komunikat o błędzie.

function log(target, name, descriptor) {
  // first get the orginal method
  const orginalMethod = descriptor.value;
  
  // overwite orginal method in descriptor
  descriptor.value = function(...args) {
    console.log(`Call method: ${name} with arguments: ${args}`);
    // try call orginal method
    try {
      const results = orginalMethod.apply(this, args);
      console.log(`Method: ${name} - results: ${results}`);
      return results;
    } catch(e) {
      console.log(`Method: ${name} - error: ${results}`);
    }

    return _descriptor;
  }
}

 

 

Poniżej przykład banalnej klasy, która ma tylko jedną metodę, zadaniem której jest zwrócić odwrócony tekst. Metodę dekorujemy oczywiście wcześniej utworzonym dekoratorem:

class Reverser {
  @log()
  reverseText(text) {
    return text.split('').reverse().join('');
  }
}

 

 

Akcję Vuex i dekoratory

Czas na właściwy temat – wykorzystanie dekoratorów w akcjach dla Vuex. Zanim jednak do tego przejdę – krótki opis problemu, który chcemy rozwiązać.

Zapewne w każdej większej aplikacji SPA korzystamy z jakiegoś API do pobierania, wyświetlania i aktualizacji danych. Jak wiadomo takie akcje są asynchroniczne. Jak również wiadomo, API nie zawsze odpowiada tak szybko jakbyśmy tego chcieli i nie zawsze mamy wpływ na to aby to zmienić. W takiej sytuacji musimy jakoś „ładnie” poinformować użytkownika o tym, że ładujemy/aktualizujemy dane i tym samym zablokować na ten czas „narwanych” użytkowników, którzy będą klikali kilkukrotnie przycisk, w celu przyśpieszenia zapisania/pobrania danych (podobną sytuację można zaobserwować przy wysiadaniu z pociągu metra 😉 ). Możemy to zrobić na mnóstwo różnych sposobów – spinnery na przyciskach, zasłonięcie całego ekranu i jeden spinner na środku itp. To wszystko zależy jednak od zaprojektowanego UX i nie o tym ma być ten wpis.

Przypuśćmy jednak, że chcemy aby podczas pobierania/aktualizacji danych, kilka elementów informowało o tym użytkownika, np. przycisk i tabela, w której wyświetlamy dane. W jaki sposób przechować informację o tym, ze aktualnie jakieś dane są aktualizowane? Możemy oczywiście w naszym Store (State, Vuex – whatever) umieścić właściwości typu bool, które będą przechowywały aktualną informację o tym, czy dane są pobierane i po pobraniu danych commitować ich zmianę. Komponenty mogą nasłuchiwać tych wartości i wyświetlać odpowiednią informację użytkownikowi. Pozostaje jednak pytanie – a co jeżeli jednocześnie pobieramy kilka informacji i nie wiemy, która z nich pobierze się pierwsza? Możemy oczywiście, dla każdej z nich utworzyć właściwość informującą o tym, że pobieramy dane, np.: isLoadingUsers, isLoadingComments, etc. Możemy… ale po co? 🙂 A może umieścić w Store właściwość, która będzie przechowywała informację o aktualnie trwającej asynchronicznej akcji? Komponenty mogłyby wtedy nasłuchiwać tylko tej właściwości, np. sprawdzając, czy akcja, która ich dotyczy nadal trwa, czy już się zakończyła.

Oczywiście wykorzystamy do tego dekoratory i async/await.

Moduł Vuex – AppModule.js

Najpierw utworzymy moduł w Vuex (np. AppModule), który będzie przechowywał informację dotyczące stanu całej aplikacji, np. czy aktualnie trwają jakieś akcje asynchroniczne:

Plik: AppModule.js

import { xor } from 'lodash';
import { TOGGLE_PENDING_STORE_ACTIONS } from './mutations-types';

const INITIAL_STATE = {
  pendingStoreActions: []
}

const mutations = {
  [TOGGLE_PENDING_STORE_ACTIONS](state, payload) {
    const oldStoreActions = state.pendingStoreActions;
    const newStoreActions = xor(oldStoreActions, [payload]);

    state.pendingStoreActions = newStoreActions
  }
}

const actions = {

}

const getters = {
  pendingStoreActions: state => state.pendingStoreActions;
}

export default {
  state: INITIAL_STATE,
  mutations,
  actions,
  getters
}

 

Dekorator

Teraz czas na „gwiazdę”, czyli dekorator, który będzie aktualizował stan aplikacji o informację o trwających akcjach asynchronicznych.

Plik utils/decorators.js

import store from '@/store';

export const togglePendingStoreActions = () =>
  (target, key, descriptor) => {
    const _descriptor = descriptor;
    const orginalMethod = _descriptor.value;
    _descriptor.value = async function (...args) {
      const storeActionsName = key;
      store.commit('TOGGLE_PENDING_STORE_ACTIONS', storeActionsName);
      await orginalMethod.apply(this, args);
      store.commit('TOGGLE_PENDING_STORE_ACTIONS', storeActionsName);
    };

    return descriptor;
  };

 

 

Moduł Vuex – UsersModule.js

Czas wykorzystać dekorator w akcjach asynchronicznych. Załóżmy, że nasza aplikacja ma za zadanie pobrać listę użytkowników. W tym celu potrzebujemy modułu Vuex, który będzie obsługiwał wszystko co dotyczy listy użytkowników.

Plik UsersModule.js

import axios from "axios";
import { togglePendingStoreActions } from "./../../../utils/decorators";
import { FILL_USERS_LIST, SET_PAGE } from "./mutations-types";

const INITIAL_STATE = {
  users: [],
  page: 1
};

const mutations = {
  [FILL_USERS_LIST](state, payload) {
    state.users = payload.users;
  },

  [SET_PAGE](state, payload) {
    state.page = payload.page;
  }
};

const actions = {
  @togglePendingStoreActions()
  async getUsers({ state, commit }) {
    const page = state.page;
    const users = (await axios.get(`https://reqres.in/api/users?page=${page}`))
      .data.data;
    commit(FILL_USERS_LIST, { users });
  },

  setPage({ commit, dispatch }, page) {
    commit(SET_PAGE, { page });
    dispatch("getUsers");
  }
};

const getters = {
  users: state => state.users,
  page: state => state.page
};

export default {
  state: INITIAL_STATE,
  mutations,
  actions,
  getters
};

 

 

Moduł ma dwie akcje – akcja, której zadaniem jest wywołać zewnętrzne API w celu pobrania listy użytkowników oraz akcja, której zadaniem jest ustawienie aktualnej strony. Ze względu na to, że musimy poczekać na pobranie listy użytkowników zanim zaktualizujemy stan aplikacji – to właśnie tą akcję udekorujemy.

Stan aplikaji – Vuex

Należy jeszcze oczywiście utworzyć globalny stan aplikacji i „wpiąć” do niego oba utworzone wczesniej moduły.

Plik store/UsersList.vue

import Vue from "vue";
import Vuex from "vuex";

import AppModule from "./modules/AppModule";
import UsersModule from "./modules/UsersModule";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    AppModule,
    UsersModule
  }
});

export default store;

 

 

Lista użytkowników – komponent UsersList.vue

Czas na komponent, który będzie wyświetlał listę użytkowników (o ile lista nie będzie pusta) raz informację, o tym, że lista użytkowników jest właśnie aktualizowana.  Komponent nie będzie miał swojej logiki, dlatego utworzymy go jako „functional component”.

Zakładamy, że komponent będzie wymagał podania listy użytkowników oraz informacji o tym, czy lista jest aktualnie aktualizowana.

Plik UsersList.vue

<template functional>
  <div class="UsersList">
    <table class="table is-striped is-bordered is-fullwidth">
      <thead>
        <tr>
          <th>Avatar</th>
          <th>First Name</th>
          <th>Last Name</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in props.users" :key="user.id" v-if="!props.isLoading">
          <td><img class="image is-32x32" :src="user.avatar"></td>
          <td>{{ user.first_name }}</td>
          <td>{{ user.last_name }}</td>
        </tr>
        <tr>
          <td colspan="3" v-if="props.isLoading" class="UsersList__loading-message">
            Loading data...
          </td>
        </tr>
      </tbody>
    </table>  
  </div>
</template>

<style>
.UsersList {
  margin: 10px;
}
.UsersList__loading-message {
  text-align: center;
  font-size: 24px;
}
</style>

 

 

Stronicowanie – komponent PagePagination.vue

Brakuje jeszcze komponentu, który będzie stronicował listę użytkowników. Zakładamy, że w czasie trwania aktualizacji listy użytkowników, przyciski odpowiedzialne za stronicowanie będą zablokowane.

Plik PagePagination.vue

<template>
  <nav class="PagePagination pagination" role="navigation" aria-label="pagination">
    <a class="pagination-previous button is-link"
      :disabled="page === 1"
      :class="{ 'is-loading' : isLoading }" 
      @click="previousPage">Previous</a>
    <a class="pagination-next button is-link"
      :disabled="users.length === 0"
      :class="{ 'is-loading' : isLoading }" 
      @click="nextPage">Next page</a>
  </nav>
</template>

<script>
import { mapActions, mapGetters } from "vuex";

export default {
  name: "PagePagination",

  methods: {
    ...mapActions(["setPage"]),
    nextPage() {
      if (this.users.length !== 0) {
        const nextPage = this.page + 1;
        this.setPage(nextPage);
      }
    },
    previousPage() {
      if (this.page !== 1) {
        const previousPage = this.page - 1;
        this.setPage(previousPage);
      }
    }
  },

  computed: {
    ...mapGetters(["page", "users", "pendingStoreActions"]),
    isLoading() {
      return this.pendingStoreActions.includes("getUsers");
    }
  }
};
</script>

<style>
.PagePagination {
  margin: 10px;
}
</style>

 

 

Całość – App.vue

Na koniec najważniejsza część – czyli plik App.vue. Wyświetlimy w nim oba utworzone komponenty oraz zaraz po jego utworzeniu pobierzemy listę użytkowników.

<template>
  <div id="app">
    <PagePagination />
    <UsersList :users="users" :is-loading="isLoading"/>
    <label>Pending vuex actions:</label>
    <pre>{{ pendingStoreActions }}</pre>
  </div>
</template>

<script>
import { mapActions, mapGetters } from "vuex";
import store from "./store";
import PagePagination from "./components/PagePagination";
import UsersList from "./components/UsersList";

export default {
  name: "App",
  store,
  components: {
    PagePagination,
    UsersList
  },

  created() {
    this.getUsers();
  },

  methods: {
    ...mapActions(["getUsers"])
  },

  computed: {
    ...mapGetters(["users", "pendingStoreActions"]),
    isLoading() {
      return this.pendingStoreActions.includes("getUsers");
    }
  }
};
</script>

<style>
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  width: 100%;
  color: #2c3e50;
}
</style>

 

Należy zwrócić uwagę w jaki sposób jest utworzona, w tym i powyższym komponencie, właściwość wyliczana (computed property) „isLoading”. Do obu komponentów przekazujemy właściwość „pendingStoreActions” ze stanu (moduł AppModule), która w postaci tablicy przechowuje nazwy aktualnie trwających akcji Vuex. Z racji tego, że  „isLoading” jest właściwością wyliczaną, reaguje na zmiany wartości właściwości w niej wykorzystanych (jej ostateczna wartość jest od nich uzależniona).  Co oznacza, że możemy to wykorzystać sprawdzając, czy w tablicy „pendingStoreActions” znajduje się aktualnie element o nazwie akcji – „getUsers”. Wykorzystujemy do tego metodę wyższego rzędu – „.includes()” dla Array, która zwraca true lub false w zależności od tego, czy tablica zawiera przekazany element, czy nie.

Podsumowanie

Tym sposobem dotarłem do końca tego wpisu 🙂 Warto zwrócić uwagę na to, że aby wykorzystać dekoratory należy doinstalować do Babel odpowiedni plugin: babel-plugin-transform-decorators-legacy oraz dodać go do pliku .babelrc:

{
  "plugins": [
    "transform-decorators-legacy",
  ],
}

 

 

 

Poniżej przedstawiam całe rozwiązanie w niesamowitym narzędziu, jakim jest CodeSandbox: