Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik12 min czytania

Angular 22, sygnały i tryb bez Zone.js

Angular 22 domyślnie włącza OnPush, a nowe projekty startują bez Zone.js i na sygnałach. Jak migrować istniejącą aplikację i czego nie robić po drodze.

Angular 22, sygnały i tryb bez Zone.js

Angular to framework Google do budowania aplikacji przeglądarkowych, z wbudowanym wstrzykiwaniem zależności, routingiem, formularzami i klientem HTTP w jednym pakiecie. Bieżąca wersja to 22, licencja MIT.

Ta wersja domyka przemianę, która trwała kilka lat, i robi to na tyle wyraźnie, że materiały sprzed roku opisują w praktyce inny framework. Nowe projekty startują bez Zone.js, a wykrywanie zmian opiera się na sygnałach.

Co zmieniła dwudziesta druga wersja

Trzy rzeczy, w kolejności od najważniejszej.

Tryb bez Zone.js jest domyślny w nowych projektach od wersji 21, a dwudziesta druga tę decyzję utrzymuje. Zone.js nie jest już dołączany, a odświeżanie widoku wynika z sygnałów zamiast z przechwytywania wszystkich zdarzeń przeglądarki. Paczka jest mniejsza, pierwsze renderowanie szybsze, a model umysłowy prostszy.

Domyślną strategią wykrywania zmian dla komponentu jest teraz OnPush. Komponent bez jawnie ustawionego trybu sprawdzany jest tylko wtedy, gdy zmieni się jego wejście przez powiązanie w szablonie, zadziała jego obsługa zdarzenia albo ktoś oznaczy go do sprawdzenia. Poprzednie zachowanie wraca po ustawieniu strategii Eager, natomiast jest to zmiana łamiąca i przy aktualizacji starszej aplikacji trzeba sprawdzić ją w pierwszej kolejności.

Sygnały są dziś domyślnym sposobem trzymania stanu w komponencie, a nie dodatkiem obok znanego sposobu pracy: wejścia komponentów i zapytania o elementy widoku są stabilne od wersji 19. W tej wersji dołączyły do nich formularze oparte o sygnały, których interfejs trafił do publicznego zestawu. Dotychczasowe podejście oparte o strumienie nadal działa, natomiast nowe usuwa sporą część kodu, który przy formularzach był rytuałem.

Do tego drobniejsza, ale zauważalna zmiana: domyślnym narzędziem do testów w nowych projektach jest Vitest, a eksperymentalne buildery dla Jest i Web Test Runner zostały w tej wersji usunięte. Warstwy testów end to end framework nie dostarcza od czasu wycofania Protractora, więc dokłada się ją z zewnątrz, zwykle Playwrightem albo Cypressem, a polecenie ng e2e samo pyta, którego dostawcę zainstalować.

Co właściwie robi Zone.js i dlaczego jego brak to duża zmiana

Warto to zrozumieć, bo bez tego trudno ocenić, czy migracja Ci się opłaca.

Przez lata Angular odświeżał widok w sposób, który z zewnątrz wyglądał na magię. Zone.js podmieniał funkcje przeglądarki: obsługę zdarzeń, liczniki czasu, zapytania sieciowe. Po każdej takiej operacji framework wiedział, że coś mogło się zmienić, i sprawdzał całe drzewo komponentów.

Zaleta była ogromna: pisałeś zwykły kod, a widok aktualizował się sam. Wada również: framework nie wiedział, co się zmieniło, tylko że coś mogło, więc sprawdzał wszystko. Przy dużej aplikacji to znaczyło setki sprawdzeń przy każdym kliknięciu.

Sygnał odwraca ten układ. Wartość zawinięta w sygnał wie, kto ją czyta, więc jej zmiana odświeża dokładnie te miejsca, które od niej zależą. Nie ma sprawdzania całości, bo nie ma potrzeby zgadywania.

Praktyczny skutek jest taki, że framework przestał być wyjątkowy pod tym względem i zaczął działać podobnie do innych rozwiązań opartych o reaktywność. Osoba przychodząca z Reacta albo z Vue znajdzie tu znajome pojęcia zamiast osobnego świata.

Sygnały w praktyce

Code
TypeScript
import { Component, signal, computed } from '@angular/core'

@Component({
  selector: 'app-koszyk',
  template: `
    <p>Pozycji: {{ liczba() }}</p>
    <p>Razem: {{ suma() }} zl</p>
    <button (click)="dodaj()">Dodaj</button>
  `,
})
export class KoszykComponent {
  pozycje = signal<Pozycja[]>([])
  liczba = computed(() => this.pozycje().length)
  suma = computed(() => this.pozycje().reduce((s, p) => s + p.cena, 0))

  dodaj() {
    this.pozycje.update((p) => [...p, nowaPozycja()])
  }
}

Trzy rzeczy warto tu zauważyć.

Odczyt sygnału wygląda jak wywołanie funkcji i to jest celowe: właśnie w momencie odczytu framework zapisuje, kto od tej wartości zależy. Zapomnienie nawiasów w szablonie daje wyświetlenie funkcji zamiast wartości i jest to najczęstsza pomyłka na początku.

Wartość pochodna liczy się sama i przelicza wyłącznie wtedy, gdy zmieni się coś, od czego zależy. Trzymanie sumy jako osobnego sygnału aktualizowanego ręcznie jest błędem, bo wtedy może rozjechać się z listą.

Aktualizacja przez podanie funkcji zmieniającej poprzednią wartość jest bezpieczniejsza niż ustawienie nowej na podstawie odczytanej wcześniej, zwłaszcza przy zmianach następujących szybko po sobie.

Do reagowania na zmianę sygnału czymś, co nie jest wartością pochodną, na przykład zapisem do pamięci przeglądarki albo wysłaniem zdarzenia, służy osobny mechanizm.

Code
TypeScript
import { effect, signal } from '@angular/core'

export class UstawieniaComponent {
  motyw = signal<'jasny' | 'ciemny'>('jasny')

  constructor() {
    effect(() => {
      localStorage.setItem('motyw', this.motyw())
    })
  }
}

Warto go używać oszczędnie. Efekt, który sam ustawia inny sygnał, tworzy pętlę trudną do prześledzenia, a większość rzeczy, do których kusi go użyć, jest w rzeczywistości wartością pochodną i należy do wyliczenia.

Migracja istniejącej aplikacji

Tu jest miejsce na trzeźwą ocenę, bo skala pracy zależy od tego, jak stara jest aplikacja.

Aplikacja na modułach z wykrywaniem zmian opartym o Zone.js nie przestanie działać. Nikt nie wyłącza starego trybu, a nowa wersja go obsługuje. To znaczy, że migracja jest wyborem, a nie przymusem, i można ją rozłożyć.

Rozsądna kolejność wygląda tak. Najpierw komponenty samodzielne, bo to zmiana mechaniczna i wspierana przez narzędzia automatyzujące. Potem wbudowane sterowanie przepływem w szablonach zamiast dawnych dyrektyw, również z pomocą narzędzia. Potem sygnały na stan lokalny komponentów, stopniowo, plik po pliku. Wyłączenie Zone.js na końcu, bo dopiero wtedy jest bezpieczne.

Code
Bash
ng generate @angular/core:standalone
ng generate @angular/core:control-flow
ng generate @angular/core:signals

Każde z tych poleceń przechodzi przez repozytorium i wprowadza zmiany samodzielnie, więc warto uruchamiać je pojedynczo, na osobnej gałęzi, i przeglądać wynik przed kolejnym. Przy dużej aplikacji pierwsze z nich potrafi dotknąć kilkuset plików naraz.

Ostatni krok jest tym, przy którym zespoły się potykają. Wyłączenie Zone.js w aplikacji, w której część stanu nadal siedzi w zwykłych polach klasy, sprawia, że widok przestaje się odświeżać w miejscach, których nikt nie ruszał. Objaw jest mylący: aplikacja działa, tylko liczby na ekranie zostają stare.

Praktyczna rada: przed wyłączeniem Zone.js sprawdź, czy każdy stan wpływający na widok jest sygnałem albo przechodzi przez rurę asynchroniczną w szablonie. Jeśli gdzieś zostało zwykłe pole zmieniane w wywołaniu zwrotnym, to jest miejsce, które przestanie działać.

Code
TypeScript
export class ZamowieniaComponent {
  zamowienia: Zamowienie[] = []

  ngOnInit() {
    this.api.pobierz().subscribe((z) => {
      this.zamowienia = z
    })
  }
}

Ten kod działa dopóki Zone.js przechwytuje zakończenie subskrypcji i wymusza odświeżenie. Po jego wyłączeniu lista pozostaje pusta na ekranie, mimo że pole w klasie ma poprawną wartość. Poprawka jest krótka i polega na zamianie pola na sygnał.

Code
TypeScript
export class ZamowieniaComponent {
  zamowienia = signal<Zamowienie[]>([])

  ngOnInit() {
    this.api.pobierz().subscribe((z) => {
      this.zamowienia.set(z)
    })
  }
}

Wstrzykiwanie zależności, czyli to, co zostało bez zmian

Przy całym szumie wokół sygnałów warto powiedzieć, co się nie zmieniło, bo to nadal jest najmocniejsza strona tego frameworka.

Wstrzykiwanie zależności działa tu inaczej niż w większości rozwiązań przeglądarkowych: klasa deklaruje, czego potrzebuje, a framework dostarcza to przy tworzeniu. Nie ma ręcznego przekazywania zależności przez kolejne poziomy ani kontenerów budowanych samodzielnie.

Praktyczne skutki są dwa i oba widać przy dłuższym utrzymaniu. Podmiana implementacji na potrzeby testu sprowadza się do jednej linii konfiguracji, a nie do przerabiania kodu. Oraz: usługa zadeklarowana raz jest widoczna w całej aplikacji bez importowania jej po ścieżce, więc przeniesienie pliku nie łamie dwudziestu miejsc.

Zakres widoczności usługi jest tu decyzją projektową, a nie szczegółem. Usługa dostępna w całej aplikacji istnieje w jednym egzemplarzu i żyje tak długo jak aplikacja. Usługa podana przy komponencie powstaje razem z nim i ginie razem z nim, co przy stanie związanym z konkretnym ekranem jest właściwe, a przy stanie wspólnym prowadzi do trudnych do znalezienia niespodzianek.

Nowsza składnia pozwala pobrać zależność funkcją zamiast przez konstruktor, co upraszcza dziedziczenie i pozwala używać wstrzykiwania w funkcjach pomocniczych. Warto ją znać, bo starsze przykłady w sieci pokazują wyłącznie wariant konstruktorowy.

Formularze i praca z danymi

Formularze są tu rozbudowane bardziej niż w większości rozwiązań i to bywa argumentem rozstrzygającym przy aplikacjach biznesowych.

Dotychczasowe podejście oparte o strumienie dawało pełną kontrolę i sporo kodu: definicja pól, reguły sprawdzania, obsługa zmian, powiązanie z widokiem. Przy formularzu z trzydziestoma polami, warunkową widocznością i regułami między polami ta rozwlekłość była realnym kosztem.

Nowe podejście oparte o sygnały usuwa dużą część tego rytuału, bo stan pola jest sygnałem, a reguły sprawdzania wartościami pochodnymi. Widoczność pola zależna od innego pola przestaje wymagać subskrypcji i ręcznego sprzątania po niej.

Warto natomiast pamiętać, że oba podejścia będą przez jakiś czas współistnieć, więc w jednej aplikacji można trafić na dwa sposoby robienia tego samego. Przy migracji sensownie jest ustalić, że nowe formularze powstają w nowym podejściu, a stare przepisuje się przy okazji zmian, zamiast planować jednorazowe przejście przez wszystkie.

Osobna rzecz to sprawdzanie po stronie serwera. Reguły opisane w formularzu dotyczą przeglądarki i nie zastępują sprawdzenia na zapleczu, choćby dlatego, że żądanie da się wysłać z pominięciem interfejsu. To jest oczywiste i mimo to regularnie pomijane przy formularzach, które wyglądają na dokładnie zabezpieczone.

Angular kontra alternatywy

RozwiązanieZakresKrzywa wejściaKiedy wybrać
AngularFramework z całym wyposażeniemStromaDuży zespół, długi horyzont, jasne reguły
ReactBiblioteka widokuŁagodna na starcieElastyczność, największy rynek pracy
VueFramework pośredniNajłagodniejszaMniejszy zespół, szybki start
Next.jsFramework na ReakcieŚredniaStrony z treścią i indeksowaniem

Główny argument za pierwszym wierszem jest organizacyjny, nie techniczny. Framework narzuca strukturę: gdzie leży logika, jak wygląda wstrzykiwanie zależności, jak buduje się formularze, jak testuje. Przy zespole liczącym dwadzieścia osób i aplikacji utrzymywanej przez pięć lat ta jednolitość jest warta więcej niż elastyczność.

Argument przeciw jest równie prosty. Przy zespole trzyosobowym i aplikacji, która ma powstać w kwartał, ten sam zestaw reguł jest kosztem. Do tego dochodzi rozmiar ekosystemu i rynku pracy, gdzie pierwsza alternatywa ma przewagę trudną do podważenia.

Warto też zauważyć, że różnica między tym frameworkiem a resztą zmniejszyła się właśnie przez opisywaną zmianę. Reaktywność oparta o sygnały jest dziś wspólnym mianownikiem, więc argument „tu jest zupełnie inaczej" stracił sporo mocy.

Wydajność poza wykrywaniem zmian

Tryb bez Zone.js usuwa jedno wąskie gardło, a przy dużych aplikacjach zostają jeszcze trzy, warte sprawdzenia w tej kolejności.

Pierwsze to rozmiar pierwszej paczki. Framework z całym wyposażeniem waży swoje, więc ładowanie na żądanie tras, do których użytkownik może nigdy nie zajrzeć, robi tu większą różnicę niż w rozwiązaniach lżejszych. Panel administracyjny doładowywany po wejściu na jego adres to najprostsza wygrana, jaka istnieje.

Drugie to listy. Renderowanie tysiąca wierszy jest kosztowne niezależnie od sposobu wykrywania zmian, bo koszt leży w tworzeniu elementów w dokumencie. Rozwiązaniem jest wirtualizacja albo stronicowanie, a nie kolejna optymalizacja reaktywności.

Trzecie to funkcja identyfikująca elementy listy. Bez niej zmiana jednego wiersza powoduje odtworzenie całej listy, co przy dłuższych zestawach widać gołym okiem. To jest jednowierszowa poprawka o nieproporcjonalnie dużym skutku.

Warto mierzyć w tej kolejności, bo intuicja zwykle prowadzi w odwrotną stronę. Zespoły przepisują komponenty na sygnały licząc na przyspieszenie, podczas gdy czas idzie na pobranie paczki i na renderowanie listy, której nikt nie zwirtualizował.

Typowe błędy

Pierwszy to pominięcie nawiasów przy odczycie sygnału w szablonie. Wyświetla się wtedy funkcja zamiast wartości, a błąd nie zawsze zgłasza się jako błąd.

Drugi to wyłączenie Zone.js przed przeniesieniem stanu na sygnały. Widok przestaje się odświeżać w miejscach, których nikt nie zmieniał, a objaw wygląda jak zepsute dane.

Trzeci to trzymanie wartości wyprowadzonych jako osobnych sygnałów aktualizowanych ręcznie. Prędzej czy później rozjadą się ze źródłem.

Czwarty to używanie efektów tam, gdzie wystarczy wartość pochodna. Efekt służy do skutków ubocznych, a nie do liczenia, i użyty do liczenia potrafi tworzyć pętle.

Piąty to przenoszenie wszystkiego ze strumieni na sygnały naraz. Strumienie nadal mają sens przy zdarzeniach rozłożonych w czasie, na przykład przy odpytywaniu albo wygaszaniu wpisywania, i przepisywanie ich na siłę pogarsza kod.

Szósty to migracja bez sięgnięcia po narzędzia automatyzujące. Przejście na komponenty samodzielne i nową składnię sterowania przepływem wykonuje się poleceniem, a ręczne robienie tego jest stratą czasu.

Siódmy to podanie usługi przy komponencie tam, gdzie stan ma być wspólny. Każda instancja komponentu dostaje wtedy własny egzemplarz usługi, a objaw wygląda jak gubione dane, nie jak błąd konfiguracji.

Ósmy to poleganie wyłącznie na regułach sprawdzania z formularza. Żądanie da się wysłać z pominięciem interfejsu, więc te same reguły muszą istnieć po stronie serwera.

FAQ

Czy muszę migrować na tryb bez Zone.js?

Nie. Nowe projekty startują tak domyślnie, natomiast istniejąca aplikacja korzystająca z Zone.js działa dalej i nikt nie zapowiedział wyłączenia tego trybu. Migracja jest wyborem opłacalnym przy dużych aplikacjach, gdzie sprawdzanie całego drzewa przy każdym zdarzeniu kosztuje.

Czy sygnały zastępują RxJS?

Częściowo. Do stanu w komponencie i wartości pochodnych sygnały są prostsze i wystarczają. Strumienie zostają przydatne przy zdarzeniach rozłożonych w czasie: wygaszaniu wpisywania, łączeniu kilku źródeł, anulowaniu poprzedniego zapytania. Przepisywanie tych przypadków na sygnały pogarsza kod.

Od czego zacząć migrację starej aplikacji?

Od komponentów samodzielnych i nowej składni sterowania przepływem, bo obie zmiany wykonują narzędzia automatyzujące. Potem sygnały na stan lokalny, plik po pliku. Wyłączenie Zone.js zostaw na koniec, bo jest bezpieczne dopiero wtedy, gdy stan wpływający na widok jest już reaktywny.

Czy Angular nadaje się do małych projektów?

Nadaje się, natomiast rzadko jest najlepszym wyborem. Wartość tego frameworka leży w narzuconej strukturze, która opłaca się przy dużym zespole i długim utrzymaniu. Przy aplikacji na kwartał ta sama struktura jest kosztem.

Co z testami po tej wersji?

W nowych projektach domyślnym narzędziem jest Vitest, a eksperymentalne buildery dla Jest i Web Test Runner zostały w tej wersji usunięte. Projekty na Karmie działają dalej, a przejście na Vitest ma osobną migrację uruchamianą przy aktualizacji, więc jest to zmiana dotycząca startu, nie przymus przy utrzymaniu.

Dokumentacja stoi na stronie frameworka, a opis przejścia na tryb bez Zone.js w przewodniku migracji.