Când modifici un singur câmp într-un formular complex, de ce frameworkul trebuie să verifice întreg component tree-ul pentru a detecta ce s-a schimbat? Pentru că așa a fost proiectat în mod implicit. Fie că vorbim de Reactivity system (Vue), Reconciliation (React) sau Change Detection (Angular), tehnologiile de front-end au încercat să răspundă la aceeași întrebare fundamentală: cum știm când state-ul s-a schimbat și ce parte din UI trebuie actualizată? Răspunsul tradițional a fost conservator - când nu ești sigur ce s-a schimbat, verifici tot. Asta funcționează, dar costă.
De ce este așa? De ce nu sunt performante implicit? Pentru că la momentul creării lor, alternative nu existau sau nu se rentau. Spre exemplu, React a ales Virtual DOM pentru simplitate declarativă, iar Angular a ales Zone.js pentru Automatic Change Detection. Ambele au privilegiat developer experience față de performanță - un tradeoff rezonabil în trecut, dar unul care devine problematic azi.
Soluția modernă poate fi signals. Așa cum AJAX a înlocuit full-page reloadul cu update-uri parțiale, fine-grained reactivity înlocuiește component-level re-rendering cu update-uri chirurgicale la nivel de nod DOM.
Acest model este conceptual, menit să explice fundamentele mecanismului. Nu există (încă) o formă universal standardizată de signals în JavaScript. TC39, comitetul care decide ce intră în următoarele versiuni de JavaScript, are pe masa de lucru o propunere, dar deja fiecare framework (Angular, Solid, Vue etc.) implementează propriul mecanism cu diferențe semnificative de API și comportament.
În esență, signals sunt simple dar puternice. La nivel tehnic rudimentar, un signal folosește diverse soluții (cum ar fi closures) pentru a salva două lucruri: valoarea și lista de consumatori. Când un consumer (effect sau template) citește signalul, acesta din urmă îl înregistrează automat în lista de consumeri. Când valoarea se schimbă, signalul notifică toți consumerii din listă să se execute.
Cum am menționat și înainte, fiecare tehnologie de front-end a încercat să-și implementeze propria viziune și a avut o abordare proprie în ceea ce privește signals, menită să rezolve problemele de schimbare de state (și nu numai) la nivelul aplicațiilor web.
Ca să putem detalia puțin conceptul, am să mă refugiez în continuare în zona de Angular, mai explicit Angular Signals și să prezint câteva concepte introduse, care, deși sunt bazate pe modelul conceptual de mai sus, au dus lucrurile la un alt nivel.
Începând cu Angular v16, frameworkul dezvoltat de Google a luat foarte în serios topicul Signals și, de la o versiune la alta, exercită un interes crescut în a dezvolta Angular Signals, în special datorită îmbunătățirilor de performanță. Frameworkul elimină dependența de Zone.js (bundle size mai mic), iar Change Detection devine mai eficient prin tracking granular. Împreună cu alte concepte introduse începând cu Angular v16, putem spune că frameworkul traversează o nouă etapă de maturizare (a treia, dacă ținem cont de AngularJS și Angular v2+).
Deși echipa Angular pare să introducă signals peste tot ca fiind o soluție perfectă pentru orice context, realitatea este mai nuanțată: aplicațiile pot avea în continuare probleme de arhitectură, reactive context leakul poate introduce buguri subtile, iar complexitatea unor operații asincrone necesită în continuare RxJS.
Ce e Reactive Context (ca să putem detalia cum se întâmplă aceste leakuri de reactive context, menționate mai sus)? Angular Signals folosesc o variabilă globală numită activeConsumer, care este setată automat de Angular când un consumer (template, computed signal sau effect) se execută.
Spre exemplu când un computed este citit de către template, Angular setează ca activeConsumer computedul respectiv - aici suntem în reactive contextul despre care vorbim, adică activeConsumer nu e null. Signalul sau signalurile din respectivul callback vor verifica dacă există ceva în activeConsumer, iar dacă nu e null, vor pune în lista lor de consumeri respectivul computed și astfel se creează legătura între consumer (computed) și producer (signal) despre care am povestit mai sus la nivel conceptual.
Tot acest sistem prin care Angular setează variabila globală activeConsumer atunci când un consumer se execută, poartă denumirea de Dynamic Dependency Tracking. Avantajul major este că nu avem nevoie să facem managementul dependințelor în cazul folosirii de signals, dar așa cum am menționat și mai sus, există și dezavantaje în a folosi Angular Signals sau mai bine spus aspecte la care trebuie atenție sporită.
Să presupunem că avem o componentă Angular care trebuie să seteze în localStorage o listă de numere și un index, de fiecare dată când lista din componentă se schimbă. Ambele, atât lista, cât și indexul sunt signals.
O astfel de componentă necesită un effect care să seteze acele date în localStorage, precum în implementarea de mai jos:
export class LocalStorageActionsPanel {
someIndex: WritableSignal = signal(0);
someDataIds: WritableSignal
= signal([1]);
constructor() {
effect((): void => {
const dataIds: number[] = this.someDataIds();
const index: number = this.someIndex();
this.setDataToLocalStorage(dataIds, index);
});
private setDataToLocalStorage(
someDataIds: number[],
someIndex: number
): void {
// implementation here...
}
// some other related methods...
}
În implementarea de mai sus, orice schimbare a lui someIndex sau someDataIds va declanșa din nou efectul și va trimite datele în Local Storage.
De ce se întâmplă asta? Pentru că la prima execuție a effectului, Angular setează intern acel effect ca activeConsumer.
Când someIndex() sau someDataIds() au fost citite în interiorul effectului, fiecare signal a detectat că există un activeConsumer, adică effectul și l-a înregistrat în lista proprie de consumeri. Astfel, ambele signaluri au devenit dependențe reactive ale effectului. Iar la orice schimbare ulterioară a valorilor lor, ele notifică effectul să se execute.
În cazul lui someIndex s-a generat un reactive context leak, deoarece el nu trebuia să declanșeze executarea effectului. Acesta este un caz simplu și cu impact minor, iar soluția este să folosim funcția untracked(). Signalul din interiorul funcției untracked() nu va prelua activeConsumer în lista proprie de consumeri și deci, signalul nu va deveni dependință reactivă a effectului.
Deși pare că problema e rezolvată, metoda prin care trimitem datele în storage, poate la rândul ei să folosească signaluri sau să apeleze alte metode care o fac, iar asta implicit transformă citirea respectivelor signaluri în dependințe reactive - ceea ce ar însemna din nou reactive context leak.
effect((): void => {
const dataIds: number[] = this.someDataIds();
const index: number =
untracked((): number => this.someIndex());
this.setDataToLocalStorage(dataIds, index);
});
Folosind-ne imaginația putem să intuim cât de multe probleme am putea genera dacă în loc de un index am avea 10, iar în loc de o metodă apelată, am avea 3, care, la rândul lor, ar folosi 3-4 signaluri de care nu știm. Toate acele signaluri sau chiar și computed (deoarece computed poate fi și consumer și producer) ar deveni dependințe reactive ale effectului și ar genera zeci poate chiar sute de execuții ale effectului, inutile, care ar scădea performanța aplicației, ar genera buguri foarte complexe, impredictibilitate logică (pentru că ar fi greu să știm când se execută effectul), chiar și memory leaks în cazuri extreme (aplicații foarte complexe).
Acesta este și motivul pentru care nu se recomandă utilizarea excesivă a funcțiilor effect în aplicație, poate fi foarte problematic dacă nu-l construim corect și nu ne asigurăm că nu generează reactive context leakuri.
Ca să ne asigurăm că effect se execută doar la schimbarea listei, vom pune și apelul metodei în untracked(), iar effectul va arăta astfel:
effect((): void => {
const dataIds: number[] = this.someDataIds();
untracked((): void => {
const index: number = this.someIndex();
this.setDataToLocalStorage(dataIds, index);
});
});
Sumarizăm că: orice citire a unui signal într-un consumer devine dependință reactivă, atâta timp cât signalul nu este folosit în interiorul funcției untracked().
Fără îndoială Signals au reușit să îmbunătățească domeniul nostru de activitate și să ducă ideea și conceptul de fine-grained reactivity la următorul nivel, dar cum este și normal, vin cu o serie de precauții pe care noi trebuie să le studiem și să le avem în vedere când folosim signals.
Din punctul meu de vedere, la nivel de Angular, am trecut de la a folosi AsyncPipe, trackBy și diverse forme de unsubscribing pentru observables, la a analiza reactive contextul fiecărei parți din codul ce conține signals și la a mă asigura că nu arunc într-un haos logic toate schimbările venite odată cu Angular 16+. Rămâne de văzut ce ne rezervă viitorul cu privire la noua viziune signal-based a frameworkului…
https://andamp.io/insights/blog/signals-in-javascript-a-soon-standard-or-overhyped
https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob
https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p
de Ovidiu Mățan
de Ovidiu Mățan
de Ovidiu Mățan
de Ovidiu Mățan