Tomasz Sochacki, JavaScript. Techniki zaawansowane

Dzisiaj na tapet trafia książka Tomasza Sochackiego JavaScript. Techniki zaawansowane.

Na samym wstępie muszę uprzedzić, że po, moim zdaniem, świetnej książce o wyrażeniach regularnych, moje oczekiwania były wysokie. To mogło zdecydowanie wpłynąć na mój odbiór nowej książki.

Bo nową książką jestem, prawdę mówiąc, zawiedziony. Największym zarzutem, jaki mam wobec niej, jest fakt, że czyta się ją jak dokumentację. Poznajemy jakiś konkretny ficzer, następuje jego opis teoretyczny, pojawia się prosty przykład, po czym przechodzimy do kolejnego ficzera. Najdobitniej to widać w ostatnim rozdziale, poświęconym canvasowi, który tak naprawdę zawiera jedynie krótkie informacje o najbardziej podstawowych funkcjach do rysowania. Brakuje tutaj prawdziwego „mięska”, jakichś bardziej praktycznych przykładów. Moim zdaniem, o wiele lepiej by to wszystko grało, gdyby cała poznawana w książce wiedza służyła do tworzenia kolejnych kawałków jakiejś większej, skomplikowanej aplikacji sieciowej. Można by tam wykorzystać wszystkie poznane rzeczy – generatory do kontroli przepływu, iteratory do obsługi danych, reaktywność do obsługi zdarzeń, canvas do generowania części interfejsu, wątki do przerzucenia tam części logiki, itp., itd.

W niektórych miejscach opisy były też mocno skrótowe i trudno było je zrozumieć. W przypadku iteratorów i ich metod return i throw byłem wręcz zmuszony spojrzeć do MDN, żeby upewnić się, czy dobrze rozumiem. Bardzo po macoszemu został potraktowany temat PWA i Service Workera. W zasadzie opisany został przy użyciu kilku przykładów, ale zabrakło choćby dokładniejszego wytłumaczenia mechanizmu cache’u. Autor także wyszedł z założenia, że czytelnik jest zapoznany z protokołem HTTP – a to niekoniecznie musi być prawdą. Zwłaszcza, że na samym wstępie książki potencjalny jej odbiorca jest przedstawiany jako osoba znająca podstawy JavaScriptu. A znajomość JavaScriptu nie pociąga za sobą konieczności posiadania wiedzy o działaniu HTTP. JS to język uniwersalny, co zresztą we wstępie również znajduje swoje odzwierciedlenie:

Otwiera to przed nami ogromne możliwości, kiedy to zespół programistów jednego języka jest w stanie wytwarzać oprogramowanie dostępne praktycznie na każdą platformę, od części serwerowej poprzez przeglądarki internetowe i aplikacje natywne dla smartfonów po urządzenia takie jak SmartWatch, SmartTV i wiele innych.

s. 7

Nie ma też dokładnego wytłumaczenia, dlaczego zdarzenia wewnątrz Service Workera mają dodatkowe metody #waitUntil() czy #respondWith(). A przecież te metody tak naprawdę całkowicie zmieniają to, jak działa system zdarzeń DOM! Pozwalają bowiem wymusić, by były one asynchroniczne (czyli listener trwał tak długo, jak długo wykonujemy jakąś asynchroniczną operację).

Dziwi mnie też pewna niekonsekwencja w doborze opisywanych ficzerów. Z jednej strony często wspomina się o tym, że należy zabezpieczyć się przed sytuacjami, w których przeglądarki nie wspierają danego ficzera, a równocześnie bardzo często opisywane są rzeczy dostępne wyłącznie w Chrome, takie jak synchronizacja w tle (zarówno podstawową, jak i okresową, Mozilla oficjalnie uznaje za szkodliwe dla Sieci) czy navigator.connection (uznawane za szkodliwe zarówno przez Mozillę, jak i Apple). Podobna niekonsekwencja pojawia się w przypadku składni JS-a. Wspomina się o potrzebie transpilacji dla starszych przeglądarek, ale równocześnie – używa m.in. dostępnego w globalnym zakresie słowa kluczowego await, które oficjalnie stanie się standardem dopiero w wersji ES 2022 (zgodnie z listą klepniętych propozycji). Dodatkowo wydaje mi się, że obecnie problem transpilacji nie jest aż tak konieczny do poruszania w tego typu książkach ze względu na to, jak wygląda obecny rynek przeglądarek. Jedyną przeglądarką, która faktycznie wymagałaby transpilacji, jest Internet Explorer, a ten ma 0.46% udziałów na globalnym rynku (polski ranking Gemiusa od dawna go już nawet nie notuje).

W książce jest też proponowana praktyka rozszerzania natywnych prototypów. Od lat jest to uważane za złą praktyką i ogólny konsensus jest taki, że warto tę technikę stosować tylko przy tworzeniu polyfillów. Ba, bezpieczne rozszerzanie prototypów, które jest stosowane w książce, może wręcz powodować zdecydowanie większe problemy, niż wymuszanie nadpisania natywnych metod. Dosłownie kilka dni temu rozpętało to dyskusję nad tym, czy nazwa proponowanego Array#groupBy() nie będzie musiała zostać zmieniona, by nie psuć stron używających starej wersji sugar.js.

Pojawiają się też błędy rzeczowe. Jednym z nich jest stwierdzenie, że przy pomocy fetch możemy pracować z obiektem XMLHttpRequest (s. 94). To nie jest prawda, to dwa zupełnie oddzielne mechanizmy do wysyłania asynchronicznych żądań do serwera. XMLHttpRequest to sposób zdecydowanie starszy, który używa zdarzeń. Natomiast fetch to rozwiązanie nowsze, które używa obietnic. Innym błędem rzeczowym jest stwierdzenie, że destrukturyzacja działa jedynie na obiekty iterowalne. To nie jest prawda – a przynajmniej nie w pełni. Tak, jest to prawda w przypadku wyrażenia typu [ ...iterable ] (a więc rozpakowywania do tablicy), ale już nie w przypadku { ...object } (czyli rozpakowywania obiektu). Takie rozróżnienie widać w samej specyfikacji. Pojawia się też informacja, że klasy są cukrem składniowym i przykrywają funkcje. W ES6 można było to uznać za prawdę, ale obecnie trudno to zrobić. Przykładem mogą być tutaj prywatne własności, które działają wyłącznie przy składni klas, nie działają zaś przy starej składni konstruktorów. Dodatkowo, traktowanie klas wyłącznie jako funkcji prowadzi do nieprawdziwych wniosków:

W języku JavaScript tak naprawdę wszystko jest obiektem, nawet funkcje, a jak wiemy, wprowadzone kilka lat temu do języka słowo class to nic innego jak tzw. lukier składniowy dla sposobu zapisu struktur, które pod spodem są w rzeczywistości funkcjami (czyli również obiektami).

Możemy więc stworzyć klasę Person, w której utworzymy getter do pobrania pełnego imienia i nazwiska:

class Person {
    constructor (firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    doSomething() { return null; }

    get fullName() {
        const firstName = `${this.firstName[0].toUpperCase()}${this.firstName.slice(1)}`;
        const lastName = `${this.lastName[0].toUpperCase()}${this.lastName.slice(1)}`;
        return `${firstName} ${lastName}`;
    }
}

s. 126

Jeśli już chcemy porównywać klasy do funkcji (czy też precyzyjniej: konstruktorów), to należy zauważyć, że klasa to w rzeczywistości konstruktor wraz z jego prototypem. Powyższa klasa Person w kodzie ES5 mogłaby zostać wyrażona następująco:

function Person( firstName, lastName ) {
    this.firstName = firstName;
    this.lastName = lastName;
}

Person.prototype = {
    constructor: Person,
    doSomething() { return null; },

    get fullName() {
        const firstName = `${ this.firstName[ 0 ].toUpperCase()}${ this.firstName.slice( 1 )}`;
        const lastName = `${ this.lastName[ 0 ].toUpperCase()}${ this.lastName.slice( 1 ) }`;
        return `${ firstName } ${ lastName }`;
    }
};

Możliwość przypięcia gettera nie ma nic wspólnego z tym, że pod spodem klasy jest funkcja, ponieważ on sam jest przypinany do prototypu, nie konstruktora. Co więcej, nie widzę za bardzo sensu w takim tłumaczeniu, bo klasy są po prostu nową składnią – a to oznacza, że każdy ficzer musi być dla nich definiowany osobno. To, co się znajduje „pod spodem”, nie ma za bardzo znaczenia z perspektywy składni.

Na jednej z kolejnych stron (129) pada z kolei stwierdzenie, że nie da się usunąć gettera z instancji klasy przy pomocy operatora delete. To stwierdzenie wymaga pewnego doprecyzowania: jak najbardziej się da, tylko że ten getter znajduje się w prototypie, nie bezpośrednio w instancji. Stąd zamiast delete this.getter; należałoby zastosować delete Object.getPrototypeOf( this ).getter; (co jednak usunie tę własność dla wszystkich instancji danej klasy!) albo po prostu zdefiniować getter w konstruktorze klasy zamiast jako własność klasy.

Można też zauważyć, że bardzo sporo uwagi poświęcanej jest przypadkom brzegowym, jak np. rozpatrywanie klonowania obiektów, które są zamrożone, czy wykorzystywanie getterów do pobierania wartości własności obiektów zamiast metody, ponieważ do gettera nie można przekazać parametru zmieniającego pobieraną wartość. Nacisk jest też kładziony na wydajność, co przejawia się we wprowadzaniu różnych mikrooptymalizacji, np.

// Przykład skrócony wyłącznie do interesującego nas fragmentu.
const obj = {
    [Symbol.iterator]() {
        const elements = Object.entries( this ).reverse(); // 1
        let index = elements.length - 1;

        return {
            next() {
                if ( index >= 0 ) {
                    index--;
                    const [ k, v ] = elements.pop(); // 2

                    return { value: { k, v }, done: false };
                }

                return { value: undefined, done: true };
            }
        }
    }
};

Zadaniem powyższego iteratora jest przeiterowanie po całym obiekcie i zwrócenie po kolei nazw (kluczy) i wartości jego własności. Jak można zauważyć (1), pobiera informacje o zawartości obiektu przy pomocy metody Object.entries(). Metoda ta zwraca tablicę, którą odwracamy przy pomocy Array#reverse(). Kolejne własności obiektu są następnie zwracane przy pomocy Array#pop() (2) – a zatem przechodzimy tablicę od końca. Jak zaznaczono w książce, jest to związane z tym, że Array#pop() jest szybsze od Array#shift(). I jak najbardziej się zgodzę. Tylko że zaryzykuję stwierdzenie, żę w większości przypadków różnice są całkowicie pomijalne i zaczęłyby być zauważalne wyłącznie w przypadku naprawdę sporych kolekcji. Inna rzecz, że takie rzeczy mogą się często zmieniać i niewykluczone, że tego typu optymalizacja kiedyś będzie sprawiała, że kod będzie nieco wolniejszy zamiast szybszy. Zresztą pierwszy lepszy benchmark z brzegu odpalony dzisiaj (29 stycznia 2022) zarówno na Firefoksie, jak i Chrome’ie, pokazał, że Array#shift() jest szybsze. Czy to oznacza, że należy stosować Array#shift()? Tak… ale dlatego, że – przynajmniej dla mnie – łatwiej wtedy zrozumieć, co robi iterator:

// Przykład skrócony wyłącznie do interesującego nas fragmentu.
const obj = {
    [Symbol.iterator]() {
        const elements = Object.entries( this ); // 1

        return {
            next() {
                if ( elements.length > 0 ) { // 2
                    const [ k, v ] = elements.shift(); // 3

                    return { value: { k, v }, done: false };
                }

                return { value: undefined, done: true };
            }
        }
    }
};

W powyższym kodzie również używamy Object.entries(), ale nie odwracamy wynikowej tablicy (1). To pozwala nam się też pozbyć zmiennej index i po prostu sprawdzamy, czy w tablicy wciąż są jakieś elementy (2). Jeśli tak, pobieramy kolejną własność przy pomocy Array#shift() (3). Jak dla mnie ta wersja kodu jest prostsza, dzięki czemu także łatwiejsza do zrozumienia i bardziej nadająca się do wytłumaczenia działania mechanizmu iteratorów. A tak już na totalnym marginesie, przygotowałem prymitywny benchmark i na moim sprzęcie wychodzi na to, że wersja iteratora z Array#shift() jest szybsza.

Książka w niektórych miejscach jest też już nieaktualna. Najbardziej rzuca się w oczy fakt, że przy kopiowaniu obiektów nie jest wspomniane structuredClone(), które zostalo włączone do specyfikacji HTML 27 lipca 2021 roku. Jeśli wierzyć Helionowi, książka została wydana 10 listopada 2021 roku, więc w teorii wspomnienie o algorytmie klonowania strukturalnego mogłoby się znaleźć. Jednak sama treść książki wskazuje na to, że była pisana na początku 2021 roku… Pytanie zatem, czemu od momentu jej napisania do momentu jej publikacji minęło tyle czasu?

W książce jest też dość dużo literówek i zdarzają się błędy językowe. Nie są jakoś szczególnie rażące, ale odnoszę wrażenie, że natrafiałem na nie częściej, niż byłbym w stanie wybaczyć dowolnej książce. Co więcej, w jednym miejscu tekst wskazywałby na to, że prezentowany output aplikacji powinien zawierać niepoprawnie wyświetlający się tekst po polsku… ale tekst był całkowicie poprawny – jakby przeszedł zbyt wyczuloną korektę. Jest też pewna niekonsekwencja w redagowaniu kodów pokazywanych w książce: część zawiera bowiem angielskie komunikaty, podczas gdy inne – polskie. Dodatkowo odniosłem wrażenie, że książka się nagle urwała. Brakuje tu jakiegoś podsumowania. W e-booku nie ma nawet standardowego skorowidzu, jaki kojarzę z innych książek Heliona.

Jest też kwestia tytułu. Być może jest to związane z tym, jak długo i głęboko siedzę w webdevowym bagienku, ale nie wiem, czy nazwałbym techniki przedstawione w książce zaawansowanymi. Raczej oczekiwałbym po takiej książce opisu architektury OMT i praktycznego przykładu rozdziału logiki biznesowej od logiki renderowania na kilka wątków. Do tego zagadnienia takie jak wykorzystanie asynchronicznych iteratorów i generatorów do kontrolowania przepływu w aplikacji czy wykorzystanie natywnych strumieni do stworzenia prostej implementacji reaktywnej biblioteki (tak, da się).

I na koniec: od dłuższego czasu jestem zwolennikiem bardziej „naukowego” podejścia do materiałów dotyczących webdevu. Zmiana ta jest też widoczna tu, na WebKrytyku. W większości przypadków swoje opinie próbuję oprzeć na zewnętrznych, renomowanych źródłach. Bardzo często cytuję „ostateczne” źródło, czyli specyfikację danej rzeczy. Tego mi brakuje w tej książce (i w sumie w większości książek na rynku): odniesień do źródeł, specyfikacji, itd.

Podsumowując, książka wydaje się za bardzo teoretyczna. To, co nie przeszkadzało w przypadku wyrażeń regularnych, przeszkadza w przypadku bardziej złożonych ficzerów, które najlepiej zrozumieć w boju. Przez mocno teoretyczny charakter całość czyta się podobnie do dokumentacji. A to raczej nie jest to, czego oczekuję po książce.

Dodaj komentarz

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

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.