Hyrje: revolucioni i reaktivitetit në Angular
Që nga versionet e para të Angular, RxJS ka qenë motori i reaktivitetit: thirrjet HTTP, formularët, router-i dhe ngjarjet kalonin të gjitha përmes Observable-ve. Një mjet shumë i fuqishëm, por edhe i kërkueshëm: për të shfaqur një vlerë të thjeshtë të derivuar duheshin subject-e, operatorë dhe subscription-e për t'u mbyllur.
Me Signals ekipi i Angular ka sjellë një model më të thjeshtë për gjendjen e aplikacionit: një vlerë që lexohet, përditësohet dhe nga e cila varen automatikisht vlera të tjera. Rezultati është më pak kod, lexim më i drejtpërdrejtë dhe themelet për një Angular pa Zone.js. Në këtë artikull shohim si dhe kur ta kalojmë gjendjen nga RxJS te Signals, me një rifaktorizim të plotë.
RxJS kundrejt Signals: dy mjete për probleme të ndryshme
Dallimi më i dobishëm është ky: RxJS përshkruan ngjarje në kohë — klikime, mesazhe WebSocket, përgjigje HTTP, shtypje tastiere për t'u ngadalësuar — ndërsa Signals përshkruajnë gjendjen aktuale dhe vlerat që rrjedhin prej saj. Nuk janë konkurrentë: zgjidhin probleme të ndryshme, siç e kam shpjeguar më në detaje në këtë artikull.
| RxJS | Signals | Shënime |
|---|---|---|
BehaviorSubject<T> | signal<T>() | Vlera aktuale, e lexueshme në mënyrë sinkrone |
combineLatest() + map() | computed() | Varësitë gjurmohen automatikisht |
.subscribe() për efekte anësore | effect() | Vetëm drejt botës së jashtme |
Pipe-i async | leximi i vlera() në template | Asnjë subscription për t'u menaxhuar |
debounceTime, switchMap | mbeten RxJS | Urë me toObservable |
Ka edhe një avantazh në zbulimin e ndryshimeve: kur një template lexon një signal, Angular e di saktësisht cilët komponentë varen nga ajo vlerë dhe përditëson vetëm ata, në vend që të rikontrollojë gjithë pemën e komponentëve në çdo ngjarje.
Anatomia e rifaktorizimit: një rast real
Skenari është klasik: një dashboard me listën e produkteve, një fushë kërkimi teksti dhe numërimin e elementeve të filtruar. Ja versioni "old-school" me RxJS:
@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
private readonly products$ = new BehaviorSubject<Product[]>([]);
private readonly filter$ = new BehaviorSubject<string>('');
private readonly destroy$ = new Subject<void>();
visible: Product[] = [];
count = 0;
constructor(private api: ProductApi) {}
ngOnInit(): void {
this.api.list().pipe(takeUntil(this.destroy$))
.subscribe(list => this.products$.next(list));
combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
.pipe(
map(([list, term]) =>
list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
takeUntil(this.destroy$),
)
.subscribe(visible => {
this.visible = visible;
this.count = visible.length;
});
}
onFilter(term: string): void { this.filter$.next(term); }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Funksionon, por ka defektet tipike të kësaj qasjeje:
- Shumë kod rrethues: dy subject-e, një subject shkatërrimi, dy subscription-e dhe një
ngOnDestroy. - Rrjedhë e vështirë për t'u ndjekur: për të kuptuar nga vjen
countduhet lexuar i gjithë zinxhiri i operatorëve. - Rreziku i memory leak: mjafton të harrosh një
takeUntilqë një subscription të mbetet gjallë pas shkatërrimit të komponentit. - Gjendje e dyfishuar:
visibledhecountjanë kopje që duhen mbajtur të sinkronizuara me dorë.
Hap pas hapi: gjendja me Signals
Hapi 1: vlerat dhe input-et bëhen signal
Çdo BehaviorSubject që përfaqëson një gjendje bëhet një writable signal. Përditësohet me .set() kur zëvendësohet vlera dhe me .update() kur vlera e re varet nga ajo e mëparshmja:
readonly products = signal<Product[]>([]);
readonly filter = signal('');
onFilter(term: string): void {
this.filter.set(term); // replace the value
}
addProduct(p: Product): void {
this.products.update(list => [...list, p]); // derive from the previous one
}
Hapi 2: logjika e derivuar bëhet computed
combineLatest-i me map bëhet një computed(). Varësitë nuk deklarohen: Angular i zbulon duke gjurmuar cilët signal-e lexohen. Për më tepër, një computed është dembel dhe i memorizuar: rillogaritet vetëm kur ndryshon një varësi, dhe vetëm nëse dikush e lexon.
readonly visible = computed(() => {
const term = this.filter().toLowerCase();
return this.products().filter(p => p.name.toLowerCase().includes(term));
});
readonly count = computed(() => this.visible().length);
// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>
visible dhe count nuk janë më kopje për t'u sinkronizuar: janë gjithmonë koherente nga vetë ndërtimi.
Hapi 3: efektet anësore, me effect
effect() shërben për të komunikuar me botën e jashtme: ruajtje në localStorage, shkrim i një log-u, sinkronizim i një librarie të palëve të treta si një grafik ose një hartë. Nuk shërben, përkundrazi, për të mbajtur të rreshtuar dy signal-e: në atë rast përgjigjja është pothuajse gjithmonë një computed.
constructor() {
// OK: a side effect towards the outside world
effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}
// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));
// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);
Ura mes dy botëve: rxjs-interop
Thirrjet drejt backend-it — një API REST ose NestJS — mbeten natyrshëm Observable: HttpClient kthen stream-e, dhe aty duhen anulimi, ripërpjekjet dhe operatorët kohorë. Paketa @angular/core/rxjs-interop ofron dy ura:
toSignal(): e shndërron një stream, për shembull një përgjigje HTTP, në një signal për t'u përdorur drejtpërdrejt në template.toObservable(): e shndërron një signal në stream kur duhen operatorë kohorë sidebounceTime.
Ja një kërkim në kohë reale që i përdor të dyja: fusha e kërkimit është një signal, debounce-i dhe anulimi i kërkesave mbeten te RxJS, dhe rezultati kthehet te template-i si signal. Të dyja funksionet duhen thirrur në një kontekst injection, për shembull si inicializues fushash.
private readonly api = inject(ProductApi);
readonly query = signal('');
// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] as Product[] },
);
// Template: <input (input)="query.set($any($event.target).value)" />
// @for (p of results(); track p.id) { ... }
Model arkitekturor: një store i bazuar te Signals
Kur gjendja u duhet disa komponentëve, logjika zhvendoset në një service. Rregulli është enkapsulimi: signal-i i shkrueshëm mbetet privat, jashtë ekspozohet një version vetëm për lexim me .asReadonly(), dhe ndryshimet kalojnë vetëm përmes metodave të service-it.
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly(); // read-only from outside
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0));
add(item: CartItem): void { this._items.update(list => [...list, item]); }
clear(): void { this._items.set([]); }
}
Për aplikacione më të mëdha, Signal Store i @ngrx/signals e sjell të njëjtin model në nivel global, me një strukturë standarde për gjendjen, vlerat e derivuara dhe metodat.
Përfundime dhe listë kontrolli
- Përdor Signals për gjendjen sinkrone dhe UI-në: vlera aktuale, filtra, përzgjedhje, numërime.
- Përdor RxJS për ngjarjet asinkrone dhe rrjedhat në kohë: HTTP, WebSocket, debounce, anulim. Operatorët RxJS mbeten të çmuar.
- Përdor
computedpër gjithçka që derivohet, kurrë njëeffectqë kopjon vlera. - Bëji dy botët të bashkëpunojnë me rxjs-interop, në vend që të rishkruash gjithçka.
- Migro një komponent në një kohë, duke nisur nga ata me më shumë subscription-e manuale.
E ke migruar tashmë një pjesë të aplikacionit tënd te Signals, apo ke dyshime se ku ta vendosësh kufirin me RxJS? Lër një koment më poshtë me përvojën tënde: pyetjet më interesante do të bëhen ide për artikujt e ardhshëm.
