По праву основной фичей Angular 18 стала Zoneless Change Detection. Именно с ней так и хочется разобраться.

Одна из ключевых особенностей Angular — без преувеличения, мощнейший механизм обнаружения изменений, который отвечает, как ни странно, за обнаружение изменений и обновление вьюх.

Перед тем как мы перейдем к Zoneless Change Detection, вкратце пробежимся по концепции механизма CD (Change Detection) и тому, как он реализуется с помощью zone.js.

Механизм CD в Angular

Сам механизм отвечает за обнаружение изменений и обновление вьюх в приложении. Он гарантирует, что вьюха всегда синхронизируется с моделью. Так же стоит отметить, что CD работает путем обхода дерева и проверки изменений в каждом компоненте. При обнаружении изменений он обновляет вьюху и распространяет изменения на все дочерние компоненты.

zone.js и CD

Сама по себе zone.js — это просто библиотека, которая предоставляет механизм для обертывания кода и его выполнения в определенном контексте или (как ни странно) зоне.

Если упростить, зоны позволяют нам отслеживать вызовы асинхронщины.

Выходит так, что каждый раз, когда создается компонент:

  1. Angular создает для него новую зону.

  2. Зона отслеживает изменения, которые происходят в компоненте.

  3. При необходимости запускает механизм обнаружения.

Пробежавшись по верхам, вернемся к главной теме. С выходом Angular 18 у нас появляется такая история как zoneless, что ��значает, что мы больше не будем использовать zone.js.

Звучит так, что придется все рефачить на сигналы…

Что ж, пробуем разбираться дальше.

Создадим новый проект с помощью следующей команды:

# ng-cli
ng new zoneless-app

# npx
npx ng new zoneless-app-npx

Идем в package.json убеждаемся, что версия 18 и идем дальше

package.json
package.json

Следующим шагом идем в angular.json и удаляем ‘zone.js’ из полифилов

тут нужно удалить строку - "zone.js"
тут нужно удалить строку - "zone.js"

Далее открываем ‘app.config.ts’ и видим, что у нас появился новый провайдер

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    // используем zone.js
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes)
  ]
};

Основная идея заключается в том, что теперь мы можем настроить нужен нам провайдер CD с зоной или без нее. Следующим шагом заменим его на provideExperimentalZonelessChangeDetection импортируя его из ‘@angular/core’

import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    // не используем zone.js
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes)
  ]
};

С этим провайдером zone.js не будет использоваться в приложении.

Тут стоит оговориться, что фича эксперементальная и использовать ее пока рано, а подробнее о ней можно почитать в доке.

Переходим к компонентам, чтобы уже посмотреть как это работает, создаем дочерний компонент со следующим содержимым:

import { Component, OnInit } from "@angular/core";

@Component({
  standalone: true,
  selector: "app-child",
  template: <div>Current value: {{ currentValue }}</div>  
})
export class ChildComponent implements OnInit {
  public currentValue = 0;
  
  ngOnInit(): void {
    this.currentValue += 1;
  }
}

Добавив ‘this.currentValue += 1;’ в ngOnInit мы видим, что все впорядке и в браузере currentValue будет отображаться как 1. Это происходит потому, что обновление синхронное и будет работать без зоны.

Видим значение
Видим значение

Но стоит добавить немного асинхронщин в виде setTimeout и все становится немного сложнее.

import { Component, OnInit } from "@angular/core";

@Component({
  standalone: true,
  selector: "app-child",
  template: <div>Current value: {{ currentValue }}</div>  
})
export class ChildComponent implements OnInit {
  public currentValue = 0;
  
  ngOnInit(): void {
    setTimeout(() => {
      this.currentValue += 1;
    }, 1000)
  }
}

В этом случае мы не увидим никаких изменений в браузере. Тут мы должны вручную запускать цикл обнаружения изменений, потому что автоматического обнаружения больше нет. Руками это можно сделать добавив changeDetectorRef и вызвать внутри таймаута markForCheck(); или detectChanges();

import { ChangeDetectorRef, Component, inject, OnInit } from "@angular/core";

@Component({
  standalone: true,
  selector: "app-child",
  template: <div>Current value: {{ currentValue }}</div>  
})
export class ChildComponent implements OnInit {
  private changeDetectorRef = inject(ChangeDetectorRef);
  
  public currentValue = 0;
  
  ngOnInit(): void {
    setTimeout(() => {
      this.currentValue += 1;
      this.changeDetectorRef.markForCheck();
    }, 1000)
  }
}

После этих действий мы видим, что значение снова изменилось т.к. мы отметили его “нуждающимся в изменениях”.

Из чего мы можем сделать вывод, что если мы планируем использовать zoneless, то самое время начать использовать onPush иначе придется дергать изменения руками, а еще это упростит обновление на новую версию в будущем.

Сигналы

Целиком и полностью уверен, что сигналы в этом случае будут работать из коробки, но все же убедимся в этом.

Меняем все на сигналы:

import { Component, OnInit, signal } from "@angular/core";

@Component({
  standalone: true,
  selector: "app-child",
  template: <div>signal: {{ currentSignal() }}</div>  
})
export class ChildComponent implements OnInit {
  public currentSignal = signal(0);
  ngOnInit(): void {
    setTimeout(() => {
      this.currentSignal.set(1);
    }, 1000);
  }
}

И в этом случае мы получаем вполне ожидаемое поведение, что подтверждает мои догадки.

Async pipe

И последним, но не по значению, проверим всеми любимый async pipe

Исправим компонент, чтобы его можно было использовать:

import { Component } from "@angular/core";
import { interval } from "rxjs";
import { AsyncPipe } from "@angular/common";

@Component({
  standalone: true,
  selector: "app-child",
  imports: [
    AsyncPipe
  ],
  template: <div>Async pipe: {{ currentValue$ | async }}</div>  
})
export class ChildComponent {
  public currentValue$ = interval(1000);
}

И так же видим, что значение в браузере меняется каждую секунду, что так же подтверждает работоспособность rxjs.

Вывод

И так, если подытожить все вышесказанное, то основная проблема zoneless заключается в том, что автоматические обновления не будут запускаться как и раньше, а приложение без зоны по факту не принесет никакой оптимизации. Да размер бандла меньше, ведь мы не используем zone.js но основная идея остается той же: мы вносим изменения и говорим ангуляру о том что, нужно чекнуть обновления. Цикл так же пройдется по дереву, проверяя что нужно обновить.

А если мы хотим какой-то оптимизации, то как будто стоит смотреть в сторону сигналов, но с ними тоже нужно разбираться, а этим я вскоре займусь у себя в телеге.