[pl] Promisy w Vue – jak poprawnie komunikować się z api

English version

W tym artykule zakładam, że wiesz jak działają promisy w JS. Jeśli nie, zachęcam do przeczytania artykułu na MDN.

Spójrz na poniższy component Vue i spróbuj znaleźć w nim buga:

<template>
    <div>
        <button @click="load(1)" :class="{active:active===1}">Show first article</button>
        <button @click="load(2)" :class="{active:active===2}">Show second article</button>
        <p v-if="loader">Loading...</p>
        <p v-else>{{content}}</p>
    </div>
</template>
<script>
export default {
    data(){
        return {content:null, active:null, loader:false}
    },
    methods:{
        load(id){
            this.active=id;
            this.loader=true;
            fetch(`/article/${id}`)
                .then(response=>response.json())
                .then(json=>{
                    this.content=json;
                    this.loader=false;
                });
        }
    }
};
</script>
<style>
    button.active{
        color:red;
    }
</style>

Dosyć prosty komponent, zawiera 2 przyciski. Klikając na jeden z nich włączamy metodę load, która ładuje treść danego artykułu z api restowego. Na czas ładowania wyświetlany jest <p> z napisem „Loading…”. Zaznacza też kliknięty przycisk na czerwono (klasa active) żeby było widać który artykuł jest wyświetlany. Tak prosty komponent – czy może coś pójść nie tak?

Pomyślmy chwilkę, co się stanie, gdy klikniesz jeden z przycisków, a zaraz potem drugi? Albo w ogóle zaczniesz klikać w te dwa przyciski na zmianę tak szybko jak potrafisz? Nic szczególnego. Nie znaczy to że wszystko jest w porządku – najprawdopodobniej twoje restapi działa na localhoście, więc odpowiedź z serwera przyjdzie szybciej niż zdążysz nacisnąć kolejnego buttona.

Ale nawet jeśli spowolnisz sobie sieć (np. w devtoolsach Chrome jest taka możliwość) to i tak nic się nie wydarzy – restapi odpowie wolniej, ale i tak odpowiedzi zejdą w tej samej kolejności w jakiej zostały kliknięte.

Ale jest możliwe, szczególnie przy słabym internecie lub przy bardzo obciążonym serwerze, że odpowiedzi z serwera przyjdą w innej kolejnośc. W tym momencie zmienna content, która została przypisana wewnątrz then nie będzie się zgadzać z active, która była przypisana poza then. W tej sytuacji wyświetlona treść artykułu nie będzie się zgadzać z tytułem (podświetlonym buttonem)

Możesz pomyśleć: skoro zazwyczaj działa, to wszystko jest w porządku, nie ma co się przejmować. Ale to bardzo złe podejście. W pewnym momencie ten bug przydarzy się twojemu klientowi, więc on odezwie się do ciebie, że trzeba to naprawić. Wtedy ty uruchamiasz aplikację i odpowiadasz najpopularniejszym zdaniem ludzi z branży IT: „dziwne, u mnie działa”.

Najgorszy rodzaj buga to taki, który przydarza się tylko czasem i nie jesteś w stanie go powtórzyć.

Mój sposób jak sprawdzić czy nasza aplikacja nie ma bugów związanych z asynchronicznością, to na czas testów po stronie backendu przy każdym wywołaniu requesta dodać sleep na losową liczbą sekund, dzięki czemu odpowiedzi z serwera będą zawsze losowe.

Jak to naprawić

Jest wiele sposobów jak się zabezpieczyć. Najprostszym jest zablokować przyciski na czas ładowania, tak żeby nie można było wykonywać dwóch requestów jednocześnie. Jednak jest to mało user-friendly i jak dla mnie to bardziej maskowanie problemu niż jego naprawianie.

Drugie podejście to sprawdzić, czy dany request jest wciąż aktualny:

        load(id){
            this.active=id;
            this.loader=true;
            fetch(`/article/${id}`)
                .then(response=>response.json())
                .then(json=>{
                    if(this.active!=id) return;//id changed, so ignore
                    this.content=json;
                    this.loader=false;
                });
        }

W tym przykładzie sprawdzamy, czy id się zmieniło. Możemy tak zrobić, jeśli zakładamy, że gdy odpytamy API kilka razy o ten sam id, to zawsze otrzymamy tą samą odpowiedź. Jeśli nie możemy tak założyć, użyjmy typu Symbol:

        load(id){
            const loadSymbol = Symbol();
            this.loadSymbol = loadSymbol;
            this.active=id;
            this.loader=true;
            fetch(`/article/${id}`)
                .then(response=>response.json())
                .then(json=>{
                    if(this.loadSymbol != loadSymbol) return;//loadSymbol changed, so ignore
                    this.content=json;
                    this.loader=false;
                });
        }

Jednak ciągle nie byłem zadowolony z tego rozwiązania, więc wymyśliłem coś lepszego.

PromiseStatus

Popatrz na tą klasę:

import {Enum} from 'enumify-fork';

export default class PromiseStatus {
    constructor(promise = null) {
        this.promise = promise;
    }

    set promise(promise) {
        this.data = null;
        this.error = null;
        this.status = PromiseStatus.Status.noPromise;
        this._promise = null;
        if (promise) {
            this._promise = promise;
            this.status = PromiseStatus.Status.pending;
            promise.then(data => {
                if (this._promise === promise) {
                    this.data = data;
                    this.status = PromiseStatus.Status.resolved;
                }
            }, error => {
                if (this._promise === promise) {
                    this.error = error;
                    this.status = PromiseStatus.Status.rejected;
                }
            })
        }
    }

    get promise() {
        return this._promise;
    }
}

PromiseStatus.Status = class extends Enum {
};
PromiseStatus.Status.initEnum(['noPromise', 'pending', 'resolved', 'rejected']);

Tworzysz obiekt tej klasy przekazując jej promise. W zamian za to klasa dostarcza ci property o nazwie status. Jest to enum, który przyjmuje wartość pending (gdy dane są wciąż ładowane), resolved (gdy dane zostały załadowane poprawnie) lub rejected gdy wystąpił błąd. Dodatkowo property data i error zawierają wartości zwrócone odpowiednio przez resolve i reject.

Ale zapytasz, to czym to się różni od normalnego .then().catch() lub await (użycie async/await zmienia mocno strukturę kodu, ale działanie jest takie samo jak then)? Odpowiedź brzmi: tych property możesz użyć bezpośrednio w <template>, bez użycia dodatkowych zmiennych (co upraszcza kod, ale też zabezpiecza przed tym, że się rozjadą).

Jak tego użyć?

<template>
    <div>
        <button @click="load(1)" :class="{active:active===1}">Show first article</button>
        <button @click="load(2)" :class="{active:active===2}">Show second article</button>
        <p v-if="content.status === Status.pending">Loading...</p>
        <p v-else-if="content.status === Status.resolved">{{content.data}}</p>
        <p v-else-if="content.status === Status.rejected">Error while loading article</p>
    </div>
</template>
<script>
import PromiseStatus from 'reactive-promise-status';

export default {
    data(){
        return {content:new PromiseStatus(), active:null, Status:PromiseStatus.Status}
    },
    methods:{
        load(id){
            this.active=id;
            this.content.promise = fetch(`/article/${id}`).then(response=>response.json());
        }
    }
};
</script>
<style>
    button.active{
        color:red;
    }
</style>

Z poziomu szablonu możemy sprawdzić czy dane się już załadowały w v-if. Samą treść wyciągniemy z content.data. W momencie gdy dane zostaną załadowane widok odświeży się sam.

Warto zauważyć, że zmienna content jest przypisywana w momencie kliknięcia, a nie w momencie załadowania danych, przez to uodparniamy się na problemy z kolejnością odpowiedzi z serwera.

Linki

Jeśli chciałbyś użyć w swoim projekcie ta klasa jest dostępna w npm/yarn. Link do githuba: https://github.com/matrix0123456789/reactive-promise-status

yarn add reactive-promise-status
LUB
npm install reactive-promise-status

Leave a comment