Angular Server-Side Rendering (SSR) – bezboleśnie

Wstęp

W Internecie można znaleźć ogromną ilość poradników opisujących jak przeprowadzić proces dodawania Server-Side Renderingu do Angulara z użyciem paczki Angular Universal. Niesety, mało kto pisze na temat problemów, które można napotkać w trakcie implementacji SSR. O ile dodanie Universala w Angularze 10 sprowadza się do odpalenia jednego skryptu (ng add @nguniversal/express-engine), to dostosowanie reszty istniejącego kodu nie jest już takie banalne, i można nad tym spędzić nawet kilka dni. Postanowiłem opisać najczęstsze problemy i rozwiązania na podstawie moich własnych doświadczeń wynikających z wprowadzania SSR dla dwóch aplikacji.

Wady i zalety Server-Side Renderingu

SSR nie jest złotym środkiem na wszystkie problemy aplikacji SPA (Single Page Application). Rozwiązuje on wiele z nich, ale nie jest to jedyny sposób, na rozwiązanie najczęstszych bolączek. Można do nich przede wszystkim zaliczyć potencjalne problemy z SEO, problemy z udostępnianiem linków w serwisach społecznościowych i nierzadko wolne pierwsze wyrenderowanie treści (FCP – First Contentful Paint).

Potencjalnie, Server-Side Rendering może pomóc z problemami z SEO – wyrenderuje treść strony w formacie czytelnym dla wszystkich botów. Usunie to problem z udostępnianiem linków w social mediach i poprawi SEO, gdyż cała treść będzie widoczna dla botów indeksujących.

Jeśli chodzi o wydajność, to nie jest już tak oczywiste. Teoretycznie, najwięcej możemy zyskać w przypadku stron, które mają duże moduły ładowanie na podstronach, które nie obfitują w dużą ilość elementów DOM i skomplikowanych styli. Jeśli mamy podstronę np. w module Blog i stworzymy sobie w nim dużą ilość widgetów, to cały kod odpowiedzialny za każdy z nich trzeba będzie pobrać przy wyświetlaniu podstrony, nawet jeśli niektóre z widgetów są używane tylko na liście postów, a inne w konkretnym wpisie. Powoduje to konieczność pobrania dużego pakietu kodu JS. W tym przypadku SSR powinien pomóc. Wyrenderuje nam gotowy plik html, który nie będzie zbyt dużo ważyć, bo w końcu widok artykułu rzadko kiedy ma duże drzewo DOM z głęboko zagnieżdżonymi elementami. Stylowanie takiego wpisu też nie jest z reguły bardzo skomplikowane. Co więcej, możemy skorzystać z warunkowego renderowania widgetów, jeśli kod jest wykonywany po stronie serwera, co pomoże jeszcze ograniczyć wielkość pliku html (bo tylko on jest zwracany przeglądarce w przypadku Server-Side Renderingu).

Natomiast jeśli aplikacja jest podzielona na wiele lazy loadowanych, niedużych modułów, to SSR może okazać się zbędnym balastem. Szczególnie, jeśli jest to duża aplikacja, która nie była od początku pisana pod SSR. Wydajność powinna być w takim przypadku wystarczająco dobra, a seo można zapewnić w inny sposób, na przykład używając Rendertrona, który nie wymaga modyfikacji po stronie aplikacji, a zapewnia czasami nawet lepsze rezultaty pod względem SEO (w SSR czasami trzeba się sporo napocić, żeby serwer renderował wszystkie asynchroniczne dane).

Server-Side Rendering vs Static Site Generators vs Rendertron

Jeśli naszym głównym problemem, któremu chcemy zaradzić, jest SEO, to mamy kilka możliwości do wyboru.

Server-Side Rendering będzie dobrym wyborem, jeśli nasza strona jest bardzo dynamiczna, posiada sparametryzowane ściezki, które są tworzone na biężaco, i posiada dane odświeżane na bieżąco. Mówimy tutaj na przykład o serwisie z wiadomościami, społecznościowym, albo jakiejkolwiek aplikacji, która opiera się na interakcjach pomiędzy użytkownikami. Wtedy to serwer zajmuje się dostarczaniem odpowiednich plików, a my nie musimy przebudowywać aplikacji z każdą aktualizacją, jak to ma w przypadku generatorów stron statycznych (SSG – Static Site Generators). Jeśli zależy Ci na wydajności i SEO i nie boisz się ubrudzić dostosowując stronę do pracy po stronie serwera, to powinieneś wybrać SSR. Rozwiązanie to ma jednak jedną bardzo istotną wadę – właściwie blokuje możliwość używania PWA.

Static Site Generators najlepiej sprawdzą się w przypadku stron wizytówek, małych blogów i innych stron, których zawartość nie zmienia się w zawrotnym tempie. Będzie to wtedy najlepsza opcja, zarówno pod względem wydajnościowym, jak i SEO. Generatory stron statycznych zwracają gotowy plik html, który posiada wszystkie teksty i jest zoptymalizowany pod względem wydajności. Zapewnia to dobre wyniki w testach prędkości strony, jak i w indeksowaniu przez boty. Znaczącym problemem jest natomiast brak dynamiczności. Każde dodanie nowej, sparametryzowanej ścieżki (np. nowy artykuł na blogu) wymusza wyrenderowanie strony od nowa i ponowne wrzucenie jej kodu na serwer. Jeśli masz prostego bloga, albo wizytówkę napisaną z użyciem JS-owego frameworka i nie potrzebujesz najnowszych danych na swojej stronie, ale jednocześnie zależy Ci na SEO i wydajności, to wybierz SSG.

Trzecią opcją jest Rendertron, który nie ma żadnego wpływu na wydajność, ale zapewnia wszystkie zalety powyższych rozwiązań w kwestii SEO. Jest także banalny do zaimplementowania. Wystarczy wrzucić własną instancję Rendertrona na jakiś serverlessowy serwis, a następnie z poziomu serwera przekierowywać boty na przygotowaną przez Rendertrona statyczną wersję strony. W gruncie rzeczy, otrzymuje zupełnie to samo w kwestii SEO, co używając generatora stron statycznych, ale nasza aplikacja nie musi być przebudowywana jeśli pojawią się nowe dane. Można powiedzieć, że Rendertron łączy w sobie zalety SSR i SSG, niwelując za razem ich największe wady. Jeśli wydajność nie jest dla Ciebie priorytetem, ale chcesz poprawić SEO i nie chcesz bawić się w dostosowywanie aplikacji, to zdecydowanie powinieneś wybrać Rendertrona.

Problemy w trakcie implementacji SSR

Jeśli już wiesz, że Server-Side Rendering najlepiej spełnia Twoje potrzeby, to powinieneś wiedzieć, jakie problemy możesz spotkać dostosowując swoją aplikację do współpracy z serwerem.

Jako, że SSR działa na Node.js, to Twoja aplikacja nie będzie mieć dostępu do obiektów typowo przeglądarkowych, np. document, window, localStorage, czy location. Oczywiście, można je zamockować używając dodatkowych bibliotek po stronie serwera, ale nie wszystkie metody będą w nich dostępne, przez co aplikacja może nam się wykrzaczyć i Server-Side Rendering nie zadziała. O ile można dostosować własny kod i odpalać go tylko po stronie przeglądarki wykonując proste sprawdzenie (o czym dalej), to sytuacja ma się zupełnie inaczej z paczkami i bibliotekami. Nie wszystkie są dostosowane do SSR i będą powodować błędy, odwołując się do metod, które nie istnieją w Node.js.

Jedną z dużych bolączek Server-Side Renderingu jest także niemożność użycia PWA (Progressive Web Apps). Opierają się one na Service Workerach, które spowodują błąd próbują zarejestrować się po stronie serwera. Co więcej, nie miały by one nawet żadnej wartości, gdyż przy pierwszym wejściu ładowany jest sam plik html, więc Service Worker nie miałby co cache’ować, gdyż zapytania do API i pliki są pobierane przez serwer, a przeglądarka tylko odczytuje sobie gotowy html.

Rozwiązania częstych problemów

Brak zawartości w app-root

Brak jakiegokolwiek kodu HTML pomiędzy tagami app-root jest spowodowany błędami występującymi podczas renderowania strony po stronie serwera. Są one wskazywane w konsoli. Tam, gdzie odpaliliśmy skrypt serwujący stronę w trybie SSR. Żeby zawartość była renderowana, proces renderowania musi przebiec bezbłędnie. Ostrzeżenia są dopuszczalne.

Błędy związane z document, window, localStorage, location, itp.

Serwer (node.js) nie ma dostępu do API stricte przeglądarkowych. To do nich należą właśnie document, window, itp. Możemy je emulować instalując odpowiednie paczki, korzystać z Angularowych implementacji niektórych API (np. Document i Location), lub odpalać skrypty korzystające z API przeglądarkowych tylko w środowisku przeglądarki. Można to sprawdzić korzystając z poniższego kodu:


// odpalanie kodu tylko w środowisku przeglądarki
// dowolny plik .ts

// wymagane importy
import { Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

// wstrzyknięcie zależności
constructor(
  @Inject(PLATFORM_ID) private platformId
) {}

// wykorzystanie
if (isPlatformBrowser(this.platformId)) {
  // kod, który ma być wykonany tylko w przeglądarce
}


// emulowanie API na przykładzie window
// server.ts

const domino = require('domino');

const win = domino.createWindow(template);

global.window = win;

Błędy spowodowane przez zewnętrzne biblioteki

Nie wszystkie biblioteki i paczki są przygotowane do pracy w środowisku serwerowym. Można albo zastąpić problematyczną bibliotekę inną, albo ładować ją dynamicznie tylko po stronie przeglądarki. Przykładowe ładowanie dynamiczne na przykładzie biblioteki Leaflet:


// dynamiczne ładowanie zewnętrznej biblioteki (Leaflet)
// dowolny plik .ts

// wymagane importy
import { Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

// deklarujemy zmienną globalną eksportowaną przez bibliotekę, w tym przypadku jest to L
// dla innych bibliotek może nie być potrzeby deklarowania takiej zmiennej
declare var L;

constructor(
  @Inject(PLATFORM_ID) private platformId
) {}

// dynamiczne ładowanie
if (isPlatformBrowser(this.platformId)) {
  L = require('leaflet');
}

Błędy w trakcie pobierania danych (HTTP)

W przypadku błędów http ciężko o prostą diagnozę, jeśli nie miało się z nimi wcześniej styczności. Komunikat błędu odnosi się tylko i wyłącznie do wywołań funkcji RxJS-owych. Trace błędu nie zawiera żadnych informacji o naszym kodzie, więc początkowo możemy myśleć, że to problem któregoś z operatorów. Na szczęście, z reguły problem jest trywialny i dotyczy adresu zapytania. Adresy względne nie są obsługiwane przez serwer. Jedyna możliwość to adresy bezwzględne.

Uwaga! Nieskładne przemyślenia autora!

Przy każdym odświeżeniu strony z odpalonym devowym SSR dostawałem dwa błędy RxJS-a. Nie było tam informacji o moim kodzie, jedynie o problemach w pliku main.js i o operatorach RxJS-owych. Podczas gdy szukałem jakiejś wskazówki odnośniego tego, co może to powodować, natknąłem się w Internecie na informacje, że wszystkie requesty muszą iść na bezwzględne adresy URL, ale kompletnie to zignorowałem, ponieważ adresy zaczynające się od // są bezwzględne, prawda? Prawda? No, niestety okazało się, że jednak nie są. (jeśli ktoś nie wie: // na początku adresu informuje przeglądarkę, by priorytetyzowała https, ale jeśli nie będzie certyfikatu, to użyje http). W ten sposób nieświadomy ja kompletnie olałem znaczenie ścieżki API i szukałem problemu tam, gdzie go nie ma, przez bite półtóra dnia. Zauważyłem, że kod w tapach w ogóle się nie wykonuje. Pierwszy request, który w moim kodzie był wykonywany, zawierał w sobie switchMapa, co razem z błędem wywalanym przez konsolę skierowało mnie w stronę operatorów i tam szukałem problemu. Ostatecznie, po podpięciu interceptora http okazało się, że żaden z requestów nie zwraca danych. Gdy dodatkowo dodałem catchError do interceptora, odkryłem, że kod zawarty w nim się wykonuje. Cała zagadka się rozwiązała, a winnym był brak protokołu w linku do API.

Koniec nieskładnych przemyśleń autora

Wskazówki odnośnie debugowania i unikania problemów

Pisanie kodu na SSR w trybie development

Instalując w projekcie nguniversal, CLI automatycznie dodaje do pliku package.json nowe skrypty pozwalające pracę z Server-Side Renderingiem. Poza skryptami do budowania projektu, mamy też do dyspozycji możliwość odpalenia projektu w trybie deweloperskiego SSR. Odpalimy go poleceniem npm run dev:ssr. Aplikacja zachowuje się wtedy tak samo, jak przy ng serve, z tą różnicą, że pracujemy w trybie SSR. Jeśli dodamy nową bibliotekę, nowy feature, albo usuniemy potrzebny kod, od razu dowiemy się, co było problemem, ponieważ konsola wyrzuci nam błąd.

HTTP Interceptor listujący adresy URL

Tak jak opisywałem wyżej – błędy http nie są w zbyt oczywisty sposób wskazywane przez komunikaty. Z tego względu, polecam stworzenie Interceptora, który będzie wyświetlać wszystkie adresy, na które wykonujemy requesty. W ten sposób będziemy wiedzieć, które żądanie sprawia problemy. Dodatkowo, możemy podpiąć sobie catchError pod zwracanego Observable z requesta, żeby zapewnić sobie jeszcze więcej informacji na temat błędu. Przykładowy kod takiego Interceptora:


import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable, of } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class HttpInterceptorService implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    console.log(req.url);
		
    return next.handle(req).pipe(
      catchError(err => {
        console.error(err);
				
        return of(err);
      })
    );
  }
}

Wybór odpowiedniej biblioteki

Często zdarza się, że jakaś biblioteka zewnętrzna powoduje problemy, bo nie jest przystosowana pod Server-Side Rendering. By uniknąć potencjalnych problemów, najprościej będzie wygoogle’ować nazwę biblioteki z dopiskiem SSR (np. gsap ssr). Jeśli pierwsze, co zobaczymy, to lista issues na githubie związanych z SSR, lepiej poszukajmy czegoś innego.

Ostrożnie z RxJS-owymi operatorami timer i interval

Mogą one doprowadzić to gateway timeoutów, które spowodują błędy i przerwą poprawne renderowanie treści przez serwer. Warto także unikać setTimeouta.

Wyłącz Javascript

Wyłączenie skryptów JS pozwoli Ci skupić się na tym, co najważniejsze – co renderuje serwer. Dzięki wyłączeniu skryptów, Angular nie odpali się i nie zastąpi wyrenderowanego HTML-a dynamicznym contentem. Zobaczysz tylko to, co wypluł serwer.

Operacje na nativeElement zastąp Rendererem.

Wszystkie operacje na obiekcie nativeElement danego elementu opierają się na DOM-ie, który nie jest dostępny z poziomu serwera. Zamiast tego, manipuluj właściwościami elementów używając Renderera. Unikniesz błędów serwera i sprawisz, że Twoja aplikacja będzie bardziej uniwersalna, co może się przydać gdybyś chciał wykorzystać napisany kod np. do stworzenia aplikacji desktopowej.

Zastąp API przeglądarki abstrakcjami dostępnymi w Angularze.

Zamiast korzystać bezpośrednio z obiektu document lub location, skorzystaj z ich Angularowych abstrakcji (Document, Location). Oferują one podobną funkcjonalność, ale są przystosowane do pracy po stronie serwera.

Wskazówki optymalizacyjne

Wyłączenie renderowania elementów na serwerze

Jeśli mamy na stronie komponenty, które nie są istotne ze względu na SEO, albo nie znajdują się w viewporcie, to możemy opóźnić ich ładowanie po stronie serwera używając dyrektywy:


import { Directive, OnInit, ViewContainerRef, TemplateRef, PLATFORM_ID, Inject } from '@angular/core';
import { isPlatformServer } from '@angular/common';

@Directive({
  selector: '[ssrNoRender]'
})
export class SsrNoRenderDirective implements OnInit {
  constructor(
    private viewContainer: ViewContainerRef,
    private templateRef: TemplateRef<any>,
    @Inject(PLATFORM_ID) private platformId
  ) { }

  ngOnInit() {
    if (isPlatformServer(this.platformId)) {
      this.viewContainer.clear();
    }
    else {
      this.viewContainer.createEmbeddedView(this.templateRef);
    }
  }
}

Powoduje ona, że element nie jest renderowany, jeśli kod został wykonany po stronie serwera. Co to oznacza w praktyce? Komponent, który pobiera dużą ilość danych i wykonuje na nich operacje nie wyrenderuje się na serwerze, co pozwoli na znaczne ograniczenie ilości danych i użycia procesora. Jeśli dana podstrona ma kilka takich komponentów, możemy bardzo dużo zyskać na czasie ładowania. Domyślnie, dyrektywa ta ładuje dany element od razu, gdy kod jest wykonywany w przeglądarce. Można by ją trochę zmodyfikować, żeby odpalała dany element, kiedy ten znajduje się w viewporcie. W ten sposób, użytkownik, który nigdy nie zjedzie na sam dół strony, nie pobierze niepotrzebnych mu informacji. Polepszymy w ten sposób UX i wydajność, potencjalnie minimalnie tracąc na SEO jeśli zdecydujemy się ukryć dane, które mogłyby pomóc w pozycjonowaniu.

Opóźnianie bibliotek / skryptów zewnętrznych

Wiele skryptów z zewnętrznych serwisów, jak Hotjar, Analytics, czy Facebook Pixel, ma swoje wymagania odnośnie miejsca i sposobu ładowania. Niektóre wymagają, by umieścić je w znaczniku <head>. O dodaniu atrybutu async lub defer czasami nic nie wspominają. Jednakże, jeśli nie jest to skrypt, który jest niezbędny do działania strony, to z powodzeniem można przesunąć go pod koniec body i dodać mu atrybut defer. W ten sposób użytkownik najpierw otrzyma treść, po którą przyszedł, a potem zobaczy reklamy czy też jego sesja zacznie być nagrywana. Jeśli zależy nam na wydajności, to warto zastanowić się, czy takie podejście nie będzie miało więcej zalet niż wad. Zależy to oczywiście od wymagań biznesowych – prawie nikt nie będzie chciał opóźniać ładowania Google Analytics. Osobiście nie widzę sensu w nagrywaniu sesji, albo w śledzeniu aktywności zakupowej użytkownika, który widzi tylko biały ekran, a dzięki opóźnieniu skryptów, polepszymy wydajność strony i, potencjalnie, jej pozycję w rankingu wyszukiwania.

Banalna alternatywa dla SSR

Jeśli nie narzekamy na wydajność, chcielibyśmy używać PWA, albo po prostu nie chce nam się modyfikować istniejącego kodu, to możemy skorzystać z Netlify. Posiada on możliwość prerenderowania, dzięki czemu boty indeksujące dostają czysty html z zawartością strony, a serwisy społecznościowe poprawnie wyświetlają metatagi strony. Niwelujemy w ten sposób jedną z największych bolączek aplikacji SPA, jednocześnie nie dodając sobie zbyt wiele roboty – włączenie prerenderingu na Netlify to tylko jedno kliknięcie.

Komentarze

Dodaj komentarz