По праву основной фичей 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 но основная идея остается той же: мы вносим изменения и говорим ангуляру о том что, нужно чекнуть обновления. Цикл так же пройдется по дереву, проверяя что нужно обновить.

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

Комментарии (4)


  1. nin-jin
    01.07.2024 11:22

    Помню, как ещё из 4 ангуляра выпиливал и zone, и rx, чтобы он так адски не тормозил. Кто все те синьор архитекторы, что жевали эту детскую поделку до 18 версии?


    1. CNRL
      01.07.2024 11:22
      +3

      Ангуляр даже не пробовал, лучше мола ничего нет.


      1. yuriy-bezrukov
        01.07.2024 11:22

        Разве есть что-то кроме мола?

        Кажется, слышал, что javascript написан на моле


    1. vikaz
      01.07.2024 11:22

      В крупных проектах, по умолчанию, все используют onPush. Так что проблем не было и до 18 версии.