Ви не розумієте Angular 2.0 (Change Detection)

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Ви не розумієте Angular Change Detection. І саме тому «магія» ламається

У попередній статті я написав, що багато Angular-розробників не стільки розуміють Angular, скільки звикли, що він працює. У коментарях справедливо попросили конкретики: що саме треба розуміти? Де приклади? Що відбувається під капотом?

Тому почнемо з найважливішого — change detection.

Бо якщо ви не розумієте, як Angular вирішує, що саме треба перевірити і коли оновити UI, то всі інші теми — OnPush, Zone.js, signals, RxJS, SSR, hydration, performance — перетворюються на набір порад без фундаменту.

Angular здається магічним не тому, що він справді магічний. А тому, що він дуже добре приховує механіку.

Angular не оновлює DOM тому, що ви змінили змінну

Почнемо з простого прикладу:

@Component({
  selector: 'app-counter',
  template: `
    <button (click)="increment()">+</button>
    <p>{{ count }}</p>
  `,
})
export class CounterComponent {
  count = 0;

  increment(): void {
    this.count++;
  }
}

Здається, що все дуже просто: ми змінили count, і Angular оновив DOM.

Але це не зовсім правда.

Angular не сидить і не «слухає» кожну змінну у вашому класі. Звичайне поле класу — це просто поле класу. Коли ви пишете:

this.count++;

Angular не отримує автоматичного сигналу від JavaScript, що «ця конкретна змінна змінилась».

Насправді відбувається інше:

  1. Користувач клікнув на кнопку.
  2. Angular обробив подію.
  3. Після цього Angular запустив change detection.
  4. Під час перевірки Angular ще раз виконав template bindings.
  5. Він побачив, що значення count тепер інше.
  6. Angular оновив відповідну частину DOM.

Тобто важливий момент:

UI оновився не тому, що змінилася змінна.
UI оновився тому, що після події був запущений change detection cycle.

Це базова різниця, яку багато хто пропускає.

Що таке change detection на практиці

Change detection — це процес, під час якого Angular проходить по дереву views/components і перевіряє, чи змінилися значення, які використовуються в template.

Наприклад, якщо у template є:

<p>{{ user.name }}</p>
<button [disabled]="isLoading">Save</button>
<app-card [title]="course.title"></app-card>

Angular під час change detection перевіряє ці binding-и:

user.name
isLoading
course.title

Якщо нове значення відрізняється від попереднього — Angular оновлює DOM або передає новий input у дочірній компонент.

Але тут важливо розуміти: Angular не порівнює «все з усім». Він працює з внутрішньою структурою view, де template вже представлений як набір інструкцій і binding slots.

І тут ми підходимо до Ivy.

TView і LView: що Angular реально тримає під капотом

У сучасному Angular, після переходу на Ivy, компонент — це не просто клас і HTML-шаблон. Під капотом Angular працює з внутрішніми структурами, серед яких дуже важливі дві: TView і LView.

Спрощено:

TView — це статичний blueprint view.
LView — це конкретний runtime-стан view.

Або ще простіше:

TView — це як «клас».
LView — це як «інстанс класу».

TView зберігає інформацію, яка може бути спільною для всіх інстансів template, а LView зберігає runtime-дані конкретного view.

Уявімо компонент:

@Component({
  selector: 'app-user-card',
  template: `
    <h2>{{ user.name }}</h2>
    <p>{{ user.role }}</p>
  `,
})
export class UserCardComponent {
  @Input() user!: User;
}

На рівні Angular це не просто «HTML + клас». Angular компілює template у набір інструкцій. Умовно це можна уявити так:

if (creationMode) {
  elementStart(0, 'h2');
  text(1);
  elementEnd();

  elementStart(2, 'p');
  text(3);
  elementEnd();
}

textInterpolate(user.name);
textInterpolate(user.role);

Це не точний generated code для конкретної версії Angular, але ідея така: template перетворюється на інструкції створення і оновлення view.

TView

TView містить інформацію, яка не змінюється між різними інстансами одного template:

  • структура template;
  • metadata про nodes;
  • binding-и;
  • інструкції;
  • статична інформація, яку можна перевикористати.

Тобто Angular не аналізує HTML заново при кожному рендері. Він вже має скомпільований blueprint.

LView

LView — це runtime-масив, де Angular зберігає конкретний стан view:

  • посилання на component instance;
  • DOM nodes;
  • попередні значення binding-ів;
  • flags;
  • injector-related дані;
  • контекст view;
  • інформацію, потрібну для update pass.

Тому коли Angular робить change detection, він не «магічно дивиться на компонент». Він проходить по структурі view, бере поточні значення binding-ів, порівнює їх із попередніми значеннями в LView, і якщо треба — оновлює DOM.

Це дуже важливо.

Бо коли ви пишете:

{{ getUserName() }}

Angular під час кожної перевірки має викликати цю функцію. Він не знає, чи вона чиста. Він не знає, чи вона дешева. Він просто бачить binding expression і виконує її в update phase.

Саме тому функції в template можуть бути проблемою.

Чому функції в template — це не просто «стиль коду»

Приклад:

<div>{{ calculateTotalPrice() }}</div>

calculateTotalPrice(): number {
  return this.items.reduce((total, item) => total + item.price, 0);
}

Здається невинно. Але ця функція буде викликатися під час change detection. І не обов’язково тільки тоді, коли змінився items.

Якщо change detection запустився через click, timer, HTTP response або іншу async-подію — Angular може знову перевірити цей binding.

Якщо таких функцій багато, якщо вони викликаються в @for, якщо всередині є фільтрація, сортування або складні обчислення — ви самі створюєте performance problem.

Краще:

totalPrice = computed(() => {
  return this.items().reduce((total, item) => total + item.price, 0);
});

<div>{{ totalPrice() }}</div>

Або у старішому підході — підготувати значення заздалегідь у компоненті або через pure pipe.

Проблема не в тому, що функція в template завжди зло. Проблема в тому, що багато хто не розуміє, коли і скільки разів вона буде викликана.

Zone.js: як Angular дізнається, що «щось сталося»

Тепер головне питання: а хто запускає change detection?

Класичний Angular багато років працював із Zone.js.

Zone.js патчить async APIs браузера: events, timers, promises, XHR/fetch та інші механізми. Його задача — дати Angular можливість зрозуміти, що відбулася асинхронна операція, після якої, можливо, треба перевірити UI.

Наприклад:

setTimeout(() => {
  this.count++;
}, 1000);

У чистому JavaScript змінна зміниться. Але framework має ще зрозуміти, що після цього треба оновити UI.

У Angular із Zone.js приблизний процес такий:

  1. Ви викликаєте async API, наприклад setTimeout.
  2. Zone.js вже пропатчив цей API.
  3. Callback обгортається у спеціальний zone-aware callback.
  4. Коли timer завершується, виконується не просто ваш callback, а callback у контексті Angular zone.
  5. Angular zone розуміє, що async task завершився.
  6. Коли microtask queue стає порожньою, спрацьовує onMicrotaskEmpty.
  7. Angular викликає ApplicationRef.tick().
  8. tick() запускає change detection для application view tree.

У дуже спрощеному вигляді це можна уявити так:

setTimeout(zoneAwareCb, 1000);

function zoneAwareCb() {
  try {
    originalCallback();
  } finally {
    // Zone.js / NgZone повідомляє Angular,
    // що async task завершилась
  }
}

Далі Angular чекає моменту, коли microtasks завершені, і запускає перевірку:

ngZone.onMicrotaskEmpty.subscribe(() => {
  applicationRef.tick();
});

Це не точна копія внутрішнього Angular-коду, але це правильна ментальна модель класичного zone-based Angular.

Саме тому у звичайному Angular UI оновлюється після:

setTimeout(...)
Promise.resolve(...).then(...)
httpClient.get(...).subscribe(...)
button click
input event

Не тому, що Angular «бачить всі змінні». А тому, що Zone.js допомагає Angular бачити async boundaries.

tick() — це не «оновити один компонент»

Важливий момент: ApplicationRef.tick() — це не «оновити саме той компонент, де щось змінилося».

У класичній моделі tick() запускає change detection для дерева application views. Далі Angular проходить по view tree і вирішує, які частини треба перевіряти, а які можна пропустити.

І тут з’являється ChangeDetectionStrategy.

Default vs OnPush: що реально змінюється

За замовчуванням Angular використовує ChangeDetectionStrategy.Default.

Це означає: коли change detection запущений, Angular зазвичай перевіряє компонент і його subtree.

@Component({
  changeDetection: ChangeDetectionStrategy.Default,
})
export class UserComponent {}

При OnPush поведінка інша:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {}

OnPush дозволяє Angular пропускати subtree компонента, якщо немає причин його перевіряти.

Але тут є дуже популярна помилка.

Багато хто думає:

OnPush означає, що Angular більше не перевіряє компонент.

Ні.

OnPush означає:

Angular перевіряє компонент тільки тоді, коли для цього є причина.

Типові причини:

  • змінився reference в @Input;
  • сталася event-подія всередині компонента;
  • спрацював async pipe;
  • викликали markForCheck();
  • змінився signal, прочитаний у template;
  • view був явно позначений dirty.

Тому OnPush — це не чарівна оптимізація. Це контракт.

Ви ніби кажете Angular:

Я буду будувати data flow більш передбачувано. Не перевіряй мене без потреби.

Але якщо ви при цьому мутуєте об’єкти, робите хаотичні side effects і не розумієте, хто оновлює state — OnPush не врятує. Він просто зробить баги менш очевидними.

Класична помилка з мутацією

Приклад:

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h2>{{ user.name }}</h2>
  `,
})
export class UserCardComponent {
  @Input() user!: User;
}

Батьківський компонент:

this.user.name = 'Stanislav';

Що тут не так?

Reference об’єкта user залишився той самий. Для OnPush компонента це може означати, що input «не змінився», бо Angular бачить той самий object reference.

Краще:

this.user = {
  ...this.user,
  name: 'Stanislav',
};

Тут створюється новий reference. Angular має зрозумілий сигнал: input змінився.

Саме тому immutable updates — це не просто «модно». Це спосіб зробити change detection передбачуваним.

А тепер signals: що змінилося

Signals — це одна з найважливіших змін в Angular за останні роки.

Але дуже часто їх пояснюють неправильно:

Signals — це заміна BehaviorSubject.

Ні. Це дуже слабке пояснення.

BehaviorSubject — це stream-based модель із RxJS.
signal — це synchronous reactive primitive, який зберігає значення і дозволяє Angular відстежувати, де це значення було прочитане.

Приклад:

count = signal(0);

increment(): void {
  this.count.update(value => value + 1);
}

Template:

<p>{{ count() }}</p>

Що тут важливо?

Коли template читає:

count()

Angular може запам’ятати, що цей view залежить від цього signal.

Коли значення signal змінюється, Angular може позначити відповідний component/view як такий, що потребує оновлення.

Це принципово інша якість порівняно зі звичайним class field.

Звичайне поле:

count = 0;

Angular не знає, хто його читав і хто від нього залежить.

Signal:

count = signal(0);

Angular може побудувати dependency relationship між state і view.

Signals не означають «DOM оновлюється миттєво»

Це ще одна важлива помилка.

Коли ви робите:

this.count.set(10);

Це не означає, що Angular прямо в цю ж мілісекунду синхронно переписав DOM.

Правильніше думати так:

  1. Signal отримав нове значення.
  2. Angular знає, які consumers залежать від цього signal.
  3. Відповідний view/component позначається dirty.
  4. Під час change detection Angular оновлює DOM.

Тобто signals роблять Angular більш точним у розумінні залежностей, але не скасовують саму ідею rendering/update cycle.

Що signals змінили в mental model Angular

До signals типовий Angular state часто виглядав так:

isLoading = false;
user: User | null = null;
items: Item[] = [];

Або через RxJS:

user$ = this.userService.user$;
items$ = this.itemsService.items$;

У першому випадку Angular не знає залежностей. Є просто поля класу.

У другому випадку залежність частково переноситься в async pipe. async pipe підписується на observable і mark-ає view для перевірки, коли приходить нове значення.

Signals дають іншу модель:

user = signal<User | null>(null);
items = signal<Item[]>([]);

activeItems = computed(() => {
  return this.items().filter(item => item.active);
});

Тут Angular бачить не просто «нове значення прийшло». Він бачить граф залежностей:

items -> activeItems -> template

Якщо items змінюється, activeItems стає invalidated. Якщо template читає activeItems(), Angular знає, що цей view залежить від цього derived value.

Це робить локальний UI state набагато більш передбачуваним.

Але signals не вирішують архітектуру за вас

Signals — це інструмент, а не архітектура.

Поганий код із signals усе ще буде поганим кодом.

Наприклад:

user = signal<User | null>(null);
user$ = new BehaviorSubject<User | null>(null);
userState = this.store.selectSignal(selectUser);

Якщо в одному компоненті одночасно є:

  • signals;
  • RxJS subjects;
  • store selectors;
  • manual subscriptions;
  • effects;
  • services with mutable state;

але немає зрозумілої моделі, хто є source of truth — signals не допоможуть. Вони просто стануть ще одним шаром хаосу.

Питання не в тому, чи використовувати signals.

Питання в іншому:

Де живе state?
Хто має право його змінювати?
Хто від нього залежить?
Це локальний UI state чи application state?
Це значення чи stream подій?
Тут потрібен signal, computed, effect, observable чи store?

Без цих питань signals легко перетворюються на «новий BehaviorSubject», тільки з іншим синтаксисом.

RxJS не помер

Після появи signals частина розробників почала сприймати RxJS як щось застаріле.

Це теж неправильна крайність.

Signals добре підходять для:

  • локального UI state;
  • derived state;
  • synchronous state;
  • readable template state;
  • component-level reactivity;
  • простих залежностей між значеннями.

RxJS усе ще сильний для:

  • streams;
  • WebSocket;
  • debounce/throttle;
  • cancellation;
  • retry;
  • complex async flows;
  • event streams;
  • HTTP orchestration;
  • combining multiple async sources.

Наприклад, search input:

searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query => this.api.search(query))
);

Це все ще природний кейс для RxJS.

Але коли результат уже прийшов і ви хочете показати його в UI, signal може бути дуже зручним способом тримати локальний state.

Проблема починається тоді, коли команда не домовилась, де межа.

Як signals впливають на OnPush

Раніше OnPush сильно зав’язувався на reference changes, async pipe, events і ручне mark-ання.

З signals Angular отримує точнішу інформацію:

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>{{ count() }}</p>
  `,
})
export class CounterComponent {
  count = signal(0);
}

Якщо count зміниться, Angular знає, що template цього компонента читав count().

Тому компонент може бути позначений для оновлення без того, щоб змінювався @Input.

Це дуже важлива зміна.

OnPush із signals стає природнішим, бо dependency tracking стає явнішим.

Але це не означає, що можна перестати думати про data flow. Навпаки — signals роблять data flow видимішим, і погана архітектура стає помітнішою.

А що із zoneless?

Окрема важлива тема — рух Angular у сторону zoneless.

Ідея проста: якщо Angular має signals, explicit events, manual notification APIs і більш точне dependency tracking, йому вже не обов’язково завжди покладатися на глобальне патчення async APIs через Zone.js.

Класичний zone-based Angular думає приблизно так:

Щось async завершилось?
Добре, запустимо change detection і перевіримо, що змінилось.

Zoneless-підхід рухає Angular ближче до моделі:

State сам повідомляє, що він змінився.
Angular знає, які views залежать від цього state.
Перевіряємо тільки те, що треба.

Це не означає, що Zone.js був «помилкою». Він вирішував реальну проблему: як framework має дізнатися, що після async operation, можливо, треба оновити UI.

Але signals і нова модель reactivity дають Angular можливість рухатися до більш явного і точного механізму.

І це, на мою думку, одна з найважливіших змін у сучасному Angular.

Типовий шлях хаотичної оптимізації

У багатьох проєктах performance problems виглядають приблизно так:

  1. Додаток починає гальмувати.
  2. Команда ставить OnPush всюди.
  3. Щось перестає оновлюватися.
  4. Додають detectChanges().
  5. Десь додають markForCheck().
  6. Десь обгортають у setTimeout.
  7. Десь переносять логіку в RxJS.
  8. Десь додають signals.
  9. Через місяць ніхто не розуміє, що реально оновлює UI.

Це не оптимізація. Це боротьба із симптомами.

Правильний підхід починається не з OnPush, не з signals і не з memoization.

Він починається з питань:

  • Що саме запускає change detection?
  • Який state змінився?
  • Який view залежить від цього state?
  • Чи є тут зайві binding-и?
  • Чи викликаються функції в template?
  • Чи мутуються об’єкти?
  • Чи є один source of truth?
  • Чи не змішали ми signals, RxJS і store без правил?

Приклад «як не треба»

@Component({
  selector: 'app-course-list',
  template: `
    @for (course of getFilteredCourses(); track course.id) {
      <app-course-card
        [course]="course"
        [progress]="calculateProgress(course)"
      />
    }
  `,
})
export class CourseListComponent {
  courses: Course[] = [];
  search = '';

  getFilteredCourses(): Course[] {
    return this.courses.filter(course =>
      course.title.toLowerCase().includes(this.search.toLowerCase())
    );
  }

  calculateProgress(course: Course): number {
    return course.completedLessons / course.totalLessons * 100;
  }
}

Проблеми:

  • getFilteredCourses() викликається під час change detection;
  • кожного разу створюється новий масив;
  • calculateProgress(course) викликається для кожного item;
  • якщо список великий, це швидко стає дорогим;
  • складно зрозуміти, що саме і коли перераховується.

Варіант краще із signals

@Component({
  selector: 'app-course-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (course of filteredCourses(); track course.id) {
      <app-course-card
        [course]="course"
        [progress]="course.progress"
      />
    }
  `,
})
export class CourseListComponent {
  courses = signal<Course[]>([]);
  search = signal('');

  filteredCourses = computed(() => {
    const search = this.search().toLowerCase();

    return this.courses()
      .filter(course => course.title.toLowerCase().includes(search))
      .map(course => ({
        ...course,
        progress: course.completedLessons / course.totalLessons * 100,
      }));
  });
}

Це не ідеальний універсальний рецепт, але mental model тут кращий:

  • courses — source state;
  • search — source state;
  • filteredCourses — derived state;
  • template просто читає готове значення;
  • Angular бачить signal dependencies;
  • логіка не захована у функціях template.

Але і тут можна зробити погано

Signals не гарантують автоматично хороший performance.

Наприклад:

filteredCourses = computed(() => {
  return this.courses()
    .filter(...)
    .sort(...)
    .map(...)
    .filter(...)
    .map(...);
});

Якщо courses великий, якщо computed часто invalidates, якщо всередині важкі обчислення — це теж може бути проблемою.

Signals дають кращу модель залежностей, але вони не скасовують алгоритмічну складність.

Що треба зрозуміти Angular-розробнику

На мою думку, Angular-розробник має розуміти не тільки API, а хоча б базову модель виконання.

Не обов’язково знати весь Angular source code. Не треба щодня читати LView руками. Але треба мати правильну mental model.

Мінімум, який варто розуміти:

  1. Angular не оновлює DOM одразу після зміни звичайної змінної.
  2. UI оновлюється під час change detection.
  3. Zone.js у класичній моделі допомагає Angular дізнатися про async tasks.
  4. onMicrotaskEmpty може призвести до ApplicationRef.tick().
  5. tick() запускає перевірку view tree.
  6. Ivy працює з TView і LView, а не з «магічним HTML».
  7. TView — це blueprint, LView — runtime state.
  8. OnPush не «вимикає change detection», а дозволяє пропускати subtree.
  9. Мутації об’єктів ламають передбачуваність.
  10. Signals додають dependency tracking між state і consumers.
  11. Signals не є повною заміною RxJS.
  12. Zoneless — це рух до більш явного повідомлення Angular про зміни.

Якщо це розуміння є, Angular перестає бути чорним ящиком.

Angular не став простішим. Він став чеснішим

Мені здається, сучасний Angular рухається в правильному напрямку.

Standalone components зменшили залежність від NgModules.
New control flow зробив templates чистішими.
Signals дали зрозумілішу модель локальної реактивності.
Zoneless-підхід поступово зменшує залежність від глобальної магії Zone.js.

Але є нюанс.

Angular не став «простим». Він став сучаснішим. І, можливо, навіть чеснішим.

Раніше Angular частіше казав:

Просто пиши код, я сам розберуся.

Новий Angular поступово рухається до іншої моделі:

Опиши state явно. Опиши залежності явно. Не ховай хаос за магією.

І це добре.

Але це також означає, що розробникам треба дорости до цієї моделі. Бо якщо просто замінити BehaviorSubject на signal, *ngFor на @for, modules на standalone components, а Default на OnPush, але не змінити спосіб мислення — фундаментально нічого не зміниться.

Буде той самий хаос, тільки з новим синтаксисом.

Висновок

Angular не проблема. Проблема в ілюзії, що якщо код працює, то ми розуміємо, чому він працює.

Change detection — це не магія.
Zone.js — це не магія.
Signals — це не магія.
OnPush — це не магічна оптимізація.
TView і LView — це не абстрактні слова з внутрішнього source code, а частина того, як Angular реально представляє і оновлює ваш UI.

Angular бере на себе багато складності. І це його сила. Але якщо ви ніколи не цікавитесь, яку саме складність він приховує, ви рано чи пізно почнете боротися не з причиною проблем, а з наслідками.

Більшість Angular-багів не «виникають самі по собі».

Вони виникають там, де команда не розуміє:

  • хто змінив state;
  • хто залежить від цього state;
  • що запустило change detection;
  • чому компонент перевірився або не перевірився;
  • чому DOM оновився або не оновився.

І саме тому Angular варто вивчати не тільки через API, а через модель виконання.

Бо Angular — це не просто framework, який «якось рендерить компоненти».

Angular — це runtime-система.
І якщо ви пишете на Angular серйозні продукти, вам потрібно розуміти хоча б базово, як ця система дихає.

Якщо цікаво, у наступній частині можна окремо розібрати OnPush: коли він реально допомагає, коли нічого не змінює, і чому detectChanges() часто є не рішенням, а симптомом проблеми.

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Забудьте ви за це Zoneless + signals ось що варто знати

Легасі проекти, плюс співбесіди. Так що почитати варто

Підписатись на коментарі