Maciej Sikora: Renderowanie po stronie serwera z użyciem Universal Javascript

Maciej Sikora: Renderowanie po stronie serwera z użyciem Universal Javascript
Obecne oprogramowanie internetowe przenosi ciężar obsługi aplikacji na stronę przeglądarki, zakres obejmuje przetwarzanie widoku, walidację, animacje czy też dużą część logiki aplikacji. Jeśli wszystko dzieje się w przeglądarce, to jak pogodzić równocześnie dostarczanie widoku ze strony serwera? Pełna rezygnacja z przetwarzania widoku na backendzie zmniejsza dostępność naszej aplikacji i dostarcza do klienta pusty szablon, który dopiero w środowisku przeglądarki zostaje uzupełniony odpowiednim interfejsem. Jeśli nie chcemy rezygnować z renderowania po stronie serwera, ale równocześnie nie chcemy duplikować funkcjonalności, możemy skorzystać z Universal JavaScript.

I. Co daje nam renderowanie po stronie serwera

Aplikacja nieposiadająca renderowania po stronie serwera jest dostępna tylko dla odbiorców dysponujących odpowiednim oprogramowaniem - przeglądarką internetową z włączoną obsługą Javascript. Aplikacja jest niedostępna lub trudno dostępna dla wszelkiego rodzaju innych odbiorców jak czytniki tekstowe, roboty internetowe, przeglądarki bez Javascript. Charakterystyczny dla tego typu aplikacji jest również długi czas ładowania, spowodowany dodatkowymi żądaniami ajax i nakładaniem danych na interfejs.

Pre-renderowana aplikacja nie ma tych ograniczeń. Umożliwia również odpowiednie indeksowanie witryny przez wyszukiwarki internetowe. Inicjacja interfejsu jest niemal natychmiastowa i nie wymaga dodatkowej komunikacji z serwerem. Renderowanie po stronie serwera rekompensuje również problemy z wydajnością na słabszych urządzeniach.

Diagram 1: Porównanie przebiegu uruchomienia aplikacji bez renderowania po stronie serwera (po lewej) do aplikacji posiadającej renderowanie po stronie serwera (po prawej)

II. Czym jest Universal JavaScript

Universal JavaScript to kod Javascript który może być uruchomiony po stronie klienta i po stronie serwera.

Kod uniwersalny może zostać uruchomiony po stronie serwera (node.js) i po stronie przeglądarki bez dodatkowych zmian dostosowujących. Efekt działania po obu stronach (klient i serwer) może być inny, natomiast obie strony powinny używać jednego, uniwersalnego źródła. Różnica w wynikach jest spowodowana innymi celami wykonania. Celem serwera jest wysłanie do klienta wyrenderowanej i gotowej do użycia strony html, celem kodu uruchomionego na przeglądarce jest pełna interakcja z użytkownikiem.

Jak więc możliwe, że tak różne cele mogą być spełnione za pomocą tego samego kodu źródłowego? Daje się to osiągnąć, jeśli biblioteki i komponenty, których używa aplikacja są całkowicie niezależne od środowiska lub elementy wchodzące w skład takiego programu “zdają sobie sprawę” z różnic środowisk i wykonują operacje inaczej. Dobrym przykładem jest jQuery, które zostało stworzone z potrzeby ujednolicenia dostępu do drzewa DOM na różnych środowiskach uruchomieniowych. Było to coś przełomowego, zdejmującego z programisty potrzebę pisania oddzielnego kodu na każdą przeglądarkę - wystarczyło użyć metody jQuery i na każdej przeglądarce efekt był ten sam! Tę samą cechę, lecz związaną z różnicami pomiędzy serwerem a przeglądarką, powinien mieć każdy uniwersalny komponent czy biblioteka.

III. Użycie Universal Javascript do tworzenia uniwersalnych komponentów

Uniwersalny komponent jest to odseparowana i samodzielna część aplikacji używająca Universal JavaScript.

Jako przykład, stwórzmy komponent odpowiedzialny za wspomniane wcześniej drzewko DOM i zdarzenia z nim związane. Załóżmy, że chcemy reagować na kliknięcie przycisku. Zarówno zdarzenia kliknięcia oraz drzewa DOM nie ma po stronie node.js (pamiętajmy - jesteśmy na serwerze), więc - biorąc te fakty pod uwagę - piszemy następujący uniwersalny komponent:

       
        1. // object creates gate to original document api
        2. const documentModel = () => {
        4.   const element = {
        5.    // dummy object for the server side
        6.    addEventListener: () => {}
        7.   };
        8.   if (typeof document === 'undefined') {
        9.   // dummy object for the server side
        10.    let document = {
        11.      getElementById: () => element
        12.    }
        13.  }
        14.  return {
        15.    findById: (id) => document.getElementById(id)
        16.  }
        17.}();

W linii 2 tworzymy funkcję o nazwie documentModel, którą w momencie deklaracji również wywołujemy w linii 17 - w efekcie mamy obiekt (zwrócony w linii 14) posiadający metodę findById. W liniach 4 i 10 mamy deklarację obiektów symulujących zachowanie drzewa DOM w środowisku node.js. Fakt, że po stronie serwera nie potrzebujemy żadnego alternatywnego zachowania powoduje, że są to obiekty nierealizujące żadnej akcji, tzw. dummy objects. Najważniejsza dla uniwersalności powyższego kodu jest linia 8, sprawdza ona istnienie globalnego obiektu document i jeśli go nie ma (czyli kod wykonywany jest na serwerze), to tworzymy nasz specjalny obiekt symulujący istnienie obiektu document. Ten sam kod na kliencie używa realnego drzewka DOM i obiektu globalnego document.

Oto jak może wyglądać dodanie nasłuchu na przycisk w naszym uniwersalnym kodzie:

       
		documentModel.findById('button').addEventListener('click', () => {
          console.log('button click');
        });

Używając specjalnego uniwersalnego komponentu do obsługi obiektu document istniejącego tylko na przeglądarce możemy być spokojni o brak błędów i wyrzuconych wyjątków po stronie serwera. Komponent documentModel jest uniwersalny, jako że ten sam kod działa zarówno po stronie klienta jak i serwera.

Bardziej przekonującym przykładem na uniwersalność może być komponent renderujący widok. Wynikiem renderowania widoku po stronie serwera jest zawsze pełny html strony ze wszystkimi znacznikami, jest to spowodowane faktem, że serwer ma za zadanie zwrócić do klienta pełną stronę. Wynikiem działania po stronie przeglądarki jest wyrenderowanie części interfejsu. Nasz uniwersalny “renderer” musi zaspokoić obie potrzeby.

       
        1. const renderer = () => {
        2.  let layout;
        3.  const isServer = typeof document === 'undefined';
        4.  return {
        5.    setLayout: (layoutParam) => {
        6.      layout = layoutParam;
        7.    },
        8.    render: (id, html) => {
        9.      if (!isServer) {
        10.      documentModel.findById(selector).innerHtml = html;
        11.    } else {
        12.        return layout.replace(`<div id="${id}"></div>`, `<div id="${id}">${html}</div>`);
        13.    }
        14.  }
        15.  }
        16. }();

W powyższym kodzie należy zwrócić uwagę głównie na linie 10 i 12. W tych miejscach widać inne zachowanie funkcji render w zależności od miejsca uruchomienia:

  • dla przeglądarki funkcja ustawia podany kod html w środku elementu o podanym id
  • na serwerze ta sama funkcja wykonuje operację tekstową zamieniając część tekstu na część z dodatkowym podanym html.

Przykładowe użycie komponentu po stronie przeglądarki:

       renderer.render(‘app’, ‘This content is rendered by the browser’);

Przykładowe użycie komponentu po stronie serwera:

   
        renderer.setLayout(`
          <html>
          <body>
          <div id="app"></div>
          </body>
          </html>`);
        renderer.render('app', 'This content is rendered by the server');

Jak widać dopuszczalne jest inne użycie uniwersalnego komponentu. Należy także wiedzieć, że 100% kodu nie będzie uniwersalna. Cześć inicjalizująca musi być inna ze względu na różnice w środowiskach. Celem jest zminimalizowanie tej nie-uniwersalnej nadbudowy która ma tylko inicjalizować część uniwersalną.

IV. Użycie Universal Javascript do realizacji aplikacji uniwersalnych

Wiemy czym jest Universal JavaScript, wiemy jak tworzyć uniwersalne komponenty, a to wszystko pozwala na realizację uniwersalnych aplikacji (Universal Application).

Diagram 1: Porównanie zwykłej aplikacji przeglądarkowej (po lewej) do aplikacji uniwersalnej (po prawej)

Aplikacja Uniwersalna, to aplikacja przeglądarkowa posiadająca pre-renderowanie po stronie serwera z użyciem Universal JavaScript.

Aplikacja uniwersalna używa komponentów i bibliotek uniwersalnych. Wszystkie zależności znajdujące się w uniwersalnym kodzie muszą być tak samo uniwersalne. Znalezienie uniwersalnych bibliotek nie jest trudne, w takiej aplikacji możemy na przykład użyć: lodash, underscore, moment, axios, react, redux i wiele innych bardzo znanych repozytoriów - są one uniwersalne.

Diagram 2: Przepływ w aplikacji uniwersalnej.

Dla szerszego wyjaśnienia przepływu w aplikacji uniwersalnej spróbuję prześledzić jedno wywołanie takiej aplikacji.

  1. wejście na aplikację wykonuje użytkownik za pośrednictwem przeglądarki.
  2. przeglądarka łączy się z serwerem pre-renderującym (node.js), który uruchamia kod uniwersalny i łączy się z serwerem API
  3. serwer wysyła do przeglądarki gotowy kod html.
  4. dalszą część obsługi przejmuje przeglądarka, która kontynuuje pracę z użytkownikiem z użyciem tego samego uniwersalnego kodu.

Warto zauważyć, że z perspektywy serwera API zarówno przeglądarka jak serwer node.js jest tym samym, jest to po prostu klient wysyłający żądania o zasoby.

Aplikacja Uniwersalna nie jest aplikacją serwerową, jest to aplikacja frontendowa z dodatkowym pre-renderowaniem po stronie serwera.

Co oznacza, że aplikacja nie jest serwerowa? Otóż, podstawowym środowiskiem uruchomienia aplikacji uniwersalnej jest przeglądarka, Aplikacja nie powinna robić nic dodatkowego, poza tym, co zrobiłaby w samej przeglądarce. Nie powinna mieć żadnych konektorów do baz danych lub dostępu do zasobów serwera. Celem wykonania w Node.js jest tylko jej pre-renderowanie. Realna aplikacja serwerowa napisana w dowolnej technologii powinna być całkowicie odseparowana i dostarczać dane poprzez odpowiednie API.

Diagram 3: Uniwersalna aplikacja odseparowana od technologii aplikacji serwerowej

Dla większego zrozumienia i analizy zapraszam do zapoznania się z kodem przykładowej aplikacji uniwersalnej. Przykład udostępniony jest na licencji MIT - http://bit.ly/2tuByJY.

V. Jakich frameworków używać w realizacji aplikacji uniwersalnych

Najważniejsze frameworki i komponenty starają się być uniwersalne. Zarówno React.js jak i Angular czy Ember mają możliwość realizacji kodu uniwersalnego. React.js ze swoim wirtualnym DOM wydaje się naturalnym wyborem, zaś poprzez użycie metody renderToString, pozwala na pełne wyrenderowanie aplikacji do zmiennej tekstowej. Wraz z React.js polecam użycie Redux, który po stronie serwera jest jeszcze bardziej użyteczny. Serwer renderuje html aplikacji na danym stanie, dzięki Redux możemy zarządzać stanem w jednym miejscu i po jego modyfikacji wykonać procesowanie widoków.

Przykład aplikacji uniwersalnej z użyciem React.js, Redux i Axios na licencji MIT - http://bit.ly/2tZ2Hqo.

VI. Czy warto realizować aplikacje uniwersalne

Przed odpowiedzią należy zadać sobie pytanie, czy dla realizowanej aplikacji dostępność ma znaczenie, czy ważne są czytniki tekstowe, roboty internetowe, odbiorcy inni niż przeglądarki internetowe? Jeśli nie, realizacja uniwersalnej aplikacji jest zbędnym dodatkowym trudem. Jeśli jednak aplikacja powinna wysyłać do przeglądarki pre-renderowany html, to zamiast pisać całkowicie oddzielny kod do procesowania widoku w aplikacji serwerowej, powinniśmy zdecydować się na Universal Javascript, który pozwoli na użycie jednego kodu i ułatwi jego przyszłe utrzymanie.