Wstęp#

Po uporządkowaniu stylów CSS i optymalizacji obrazów, zająłem się warstwą JavaScript. Na pierwszy rzut oka sytuacja wyglądała niewinnie. Strona ładowała dwa pliki: skrypt z motywu oraz mój własny kod obsługujący przełączanie koloru motywu. Łącznie ważyły one niespełna trzy kilobajty. Przy tak małym rozmiarze mogłoby się wydawać, że optymalizacja w tym miejscu to sztuka dla sztuki.

Prawdziwe wyzwanie kryło się jednak w nagłówkach HTTP. Zauważyłem, że oba pliki miały ustawiony roczny czas życia w pamięci podręcznej. Niestety, w ich nazwach brakowało unikalnego identyfikatora, czyli tak zwanego fingerprintu. To stwarzało poważne ryzyko. Oznaczało bowiem, że po wdrożeniu jakichkolwiek poprawek, użytkownicy nadal korzystaliby ze starej wersji kodu zapisanej w przeglądarce.

Scenariusz był prosty i niebezpieczny. Mógłbym naprawić błąd w kodzie i wysłać zmiany na serwer, a odbiorcy i tak by tego nie zobaczyli. Przeglądarka po prostu zaserwowałaby im nieaktualny plik z cache. W środowisku produkcyjnym nie możemy liczyć na to, że użytkownik domyśli się i wymusi pełne odświeżenie strony.

Diagnoza problemu#

Analiza w narzędziach deweloperskich potwierdziła moje obawy. Miałem dwa osobne pliki o łącznej wadze blisko ośmiu kilobajtów przed kompresją. Cache ustawiony na rok jest świetny dla wydajności, ale fatalny bez wersjonowania plików. Każda modyfikacja kodu stawała się problematyczna, bo mechanizm, który miał przyspieszać stronę, w praktyce blokował dostarczanie aktualizacji.

Wdrożenie rozwiązania#

Zdecydowałem się upiec dwie pieczenie na jednym ogniu. Zamiast utrzymywać dwa osobne pliki, połączyłem je w jeden zoptymalizowany pakiet, który w nazwie zawiera unikalny skrót treści.

Porównując stan przed i po zmianach, widać wyraźną poprawę. Zredukowałem liczbę zapytań do serwera o połowę. Dzięki lepszej minifikacji i połączeniu plików, waga przesyłanych danych spadła o ponad trzydzieści procent.

Najważniejszy jest jednak zysk architektoniczny. Dzięki unikalnemu skrótowi w nazwie pliku, cache stał się bezpieczny. Każda zmiana w kodzie automatycznie zmienia adres URL skryptu, co wymusza na przeglądarce pobranie nowej wersji. Dodatkowo zyskałem wyższy poziom bezpieczeństwa dzięki weryfikacji integralności zasobów. Rozwiązanie jest więc nie tylko lżejsze, ale przede wszystkim bardziej przewidywalne w utrzymaniu.

Implementacja krok po kroku#

Zacznijmy od przygotowania środowiska. Aby nasze skrypty mogły zostać przetworzone przez Hugo Pipes, musimy zmienić ich lokalizację. Hugo ma w tej kwestii jasne zasady. Pliki umieszczone w katalogu /static są po prostu kopiowane jeden do jednego. Dlatego pierwszym krokiem, jaki wykonałem, było przeniesienie plików do katalogu /assets. To jedyne miejsce, w którym silnik może modyfikować i optymalizować nasz kod.

Następnie przyjrzałem się strukturze mojego motywu. Zauważyłem, że ładowanie skryptów odbywa się w pliku odpowiedzialnym za stopkę strony. Aby przejąć kontrolę nad procesem łączenia plików, musiałem nadpisać ten fragment. Utworzyłem więc własną, lokalną wersję pliku footer.html. Dzięki temu moje zmiany będą miały priorytet nad domyślnymi ustawieniami motywu.

# Tworzymy override motywu
touch layouts/partials/footer.html

Teraz przejdźmy do najważniejszej części, czyli samej konfiguracji w nowym pliku. Poniższy kod realizuje logikę łączenia i optymalizacji zasobów.

<footer class="footer">
  <div class="footer__inner">
    {{ if $.Site.Copyright }}
      <div class="copyright copyright--user">
        <span>{{ $.Site.Copyright | safeHTML }}</span>
    {{ else }}
      <div class="copyright">
        <span>© {{ now.Year }} Powered by <a href="https://gohugo.io">Hugo</a></span>
    {{ end }}
      </div>
  </div>
</footer>

{{- $menu := resources.Get "js/menu.js" | js.Build -}}
{{- $code := resources.Get "js/code.js" | js.Build -}}
{{- $bundle := slice $menu $code | resources.Concat "bundle.js" | minify | fingerprint -}}
  
<script type="text/javascript" src="{{ $bundle.RelPermalink }}" integrity="{{ $bundle.Data.Integrity }}"></script>

<!-- Extended footer section-->
{{ partial "extended_footer.html" . }}

Wyjaśnię teraz, co dokładnie dzieje się w tym kodzie. Cały proces opiera się na kilku kluczowych funkcjach. Najpierw używam polecenia resources.Get, aby pobrać pliki z katalogu assets. Następnie za pomocą js.Build przetwarzam skrypty dostarczone przez motyw.

Kolejnym etapem jest stworzenie jednej listy plików przy użyciu funkcji slice. To właśnie te elementy sklejamy w jedną całość za pomocą polecenia resources.Concat.

Aby zadbać o wydajność, wynikowy plik poddajemy minifikacji. Usuwamy z niego zbędne spacje i skracamy nazwy zmiennych. Na samym końcu generujemy unikalny skrót za pomocą funkcji fingerprint. Jest to niezbędne dla mechanizmu cache busting oraz bezpieczeństwa. Dzięki atrybutowi integrity przeglądarka ma pewność, że załadowany kod nie został w międzyczasie podmieniony. Warto też zwrócić uwagę na atrybut defer, który zapewnia, że ładowanie skryptu nie zablokuje renderowania reszty strony.

Weryfikacja wdrożenia#

Samo napisanie kodu to dopiero połowa sukcesu. Teraz musimy sprawdzić, czy proces budowania faktycznie generuje to, czego oczekujemy. Uruchomiłem build produkcyjny z flagą minify, aby zobaczyć finalny efekt.

hugo --minify --gc

# Sprawdź czy bundle został utworzony z hashem
ls -lh public/bundle.min.*.js
# Powinno pokazać: bundle.min.0893e8471a48215d547719fd68f2fef204b076c01da7eb4883ee07be5809f463.js

Pierwsza rzecz, na którą zwróciłem uwagę, to nazwa pliku. Obecność długiego ciągu znaków po bundle.min potwierdza, że fingerprinting zadziałał poprawnie. Następnie zajrzałem do wygenerowanego kodu HTML, aby upewnić się, że ścieżki zostały poprawnie podmienione.

grep "bundle.min" public/index.html

Szukamy tutaj konkretnie atrybutu integrity. To on jest naszym gwarantem bezpieczeństwa.

<script src="/bundle.min.abc123def456.js" integrity="sha256-..." defer></script>

Na koniec przeprowadziłem standardowy “smoke test”. Uruchomiłem hugo serve i ręcznie przeklikałem kluczowe elementy interfejsu: rozwijane menu, przycisk kopiowania w blokach kodu oraz przełącznik motywu. Czyli czy funkcjonalności zapewniane przez java script dalej działają.

Wyniki optymalizacji#

Rzućmy okiem na twarde dane. Zestawiłem metryki przed i po wdrożeniu zmian. Wartości mogą wydawać się małe, ale w skali całego serwisu robią różnicę.

MetrykaPrzedPoZmiana
Liczba plików JS21-50%
Transfer (GZIP)2.8 KB1.8 KB-36%
Rozmiar (raw)7.7 KB4.0 KB-48%
Requesty HTTP21-50%
MinifikacjaCzęściowaPełnaTAK
FingerprintingBRAKTAKTAK
SRI integrityBRAKTAKTAK
Bezpieczeństwo cacheNISKAWYSOKATAK

Dlaczego te liczby są istotne?#

Patrząc na tabelę, chciałbym zwrócić waszą uwagę na trzy kluczowe aspekty, które realnie wpływają na jakość naszej aplikacji.

Po pierwsze: Bezpieczeństwo cache’owania.#

To jest dla mnie najważniejsza zmiana. Dzięki fingerprintingowi, każda, nawet najmniejsza zmiana w kodzie JS generuje zupełnie nową nazwę pliku. Co to oznacza w praktyce? Możemy ustawić bardzo agresywny czas cache’owania w przeglądarce, nawet na rok. Nie musimy się martwić, że użytkownik zobaczy starą wersję skryptu, bo przy nowym deploymencie HTML po prostu wskaże na nowy plik. Eliminujemy w ten sposób klasyczny problem “u mnie działa, wyczyść cache”.

Po drugie: Redukcja rozmiaru.#

Udało mi się zejść z transferem o ponad 30%. Oszczędziliśmy prawie połowę wagi niespakowanego kodu. Wynika to głównie z faktu, że teraz minifikujemy również theme-switcher.js, który wcześniej był kopiowany wprost z katalogu static. Mniej bajtów to szybsze ładowanie, zwłaszcza na urządzeniach mobilnych.

Po trzecie: SRI Integrity.#

Wprowadziliśmy mechanizm Subresource Integrity. Jeśli z jakiegoś powodu plik na serwerze zostałby podmieniony lub uszkodzony, przeglądarka to wykryje dzięki sumie kontrolnej i zablokuje wykonanie kodu. To dodatkowa warstwa bezpieczeństwa, którą dostajemy w zasadzie za darmo.

Uwaga na kolejność skryptów#

Jest jedna pułapka techniczna, o której muszę wspomnieć. Funkcja slice, której użyliśmy do łączenia plików, respektuje kolejność argumentów. Jeśli wasze skrypty mają zależności – na przykład skrypt przełącznika motywu korzysta z funkcji zdefiniowanych w menu – musicie zachować odpowiedni porządek.

{{- $bundle := slice $menu $code ... -}}
         kolejność     ↑      ↑
                       1      2

W moim przypadku moduły były niezależne, więc kolejność była dowolna. Jednak jeśli w konsoli zobaczycie błędy typu “X is not defined”, w pierwszej kolejności sprawdźcie, czy biblioteka bazowa jest ładowana przed skryptem, który z niej korzysta.

Podsumowanie#

Optymalizacja JavaScript w Hugo to dla mnie podręcznikowy przykład zadania typu “mały wysiłek, duży efekt”. Cała operacja zajęła mi około 15 minut.

W zamian zyskaliśmy stabilność i wydajność. Użytkownik końcowy dostaje stronę, która przy kolejnych wizytach ładuje skrypty w 0 milisekund z pamięci podręcznej. My jako deweloperzy zyskujemy spokój ducha i mamy pewność, że po wdrożeniu nikt nie zgłosi błędu wynikającego z przestarzałego kodu w cache. Dodatkowo, dzięki SRI, podnieśliśmy poziom bezpieczeństwa.

Jeśli jeszcze nie wdrożyliście fingerprintingu w swoich projektach na Hugo, zdecydowanie polecam dopisać to do najbliższego sprintu.

Źródła#