Привет! Я Незар, фронтенд-разработчик Т-Банка в одном из продуктов Т-Бизнеса. Наша команда использует Angular, и мы следим за всеми изменениями, которые с ним происходят.

В статье разберу, как Angular эволюционировал от Zone.js к Zoneless-приложениям с современным реактивным подходом Signal API и почему это стало ключевым шагом для повышения производительности и упрощения реактивного программирования. 

На примерах и замерах производительности посмотрим, как переход к Zoneless-подходу с Signal API позволяет сократить избыточные перерисовки, ускорить отклик приложения и сделать код значительно чище и предсказуемее. Сделаем Angular-приложения быстрее и проще, добро пожаловать под кат!

Эволюция Angular: от Zone.js к Zoneless

Изначально Angular использовал Zone.js для автоматического отслеживания асинхронных операций: действий пользователя, обработки таймеров, HTTP-запросов или других событий с помощью глобального манкипатчинга. Это позволяло фреймворку автоматически запускать механизм обнаружения изменений и динамически обновлять интерфейс в ответ на эти события без добавления какого-либо кода.

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

Каждый асинхронный вызов (клик, таймер, HTTP-запрос) по умолчанию запускал полную проверку дерева компонентов или проверку dirty-компонентов при стратегии OnPush, даже если фактические данные не изменялись. В сложных приложениях с тысячей компонентов это приводило к лагам, так как Angular тратил ресурсы на ненужные пересчеты.

Для решения проблем команда разработчиков в Angular 16 сделала первый шаг к отказу от Zone.js — представила Signal API, принципиально новый механизм реактивности.

Signal API переводит фокус на точечное отслеживание зависимостей. С ним можно явно указать, какие изменения значений в данных (сигналы) влияют на конкретные части интерфейса. При изменении сигнала Angular обновляет только те компоненты, которые от него зависят, минуя полный проход по дереву. Это не только устраняет лишние перерисовки, но и делает поток данных более видимым и контролируемым.

Важно! В демоприложении используется Angular версии 20.0.2. Хотя Signal API уже достаточно стабилен для практического применения, часть его функциональности все еще находится в статусе developer preview. Полный отказ от Zone.js пока находится в активной разработке и тестировании, но уже сегодня сигналы открывают новые возможности для оптимизации и масштабирования современных фронтенд-приложений.

Signal API ускоряют Angular

Использование Zone.js приводит к:

  • Избыточным проверкам: Zone.js запускает глобальное обнаружение изменений после любого асинхронного события, даже если данные не изменились.

  • Росту размера сборки: Zone.js добавляет ~34 КБ.

  • Сложностям отладки: Патчинг API усложняет стектрейсы и вызывает ошибки вроде ExpressionChangedAfterItHasBeenCheckedError.

  • Проблемам с совместимостью: поскольку Zone.js манкипатчит браузерные API, то новые API могут быть не добавлены и работать некорректно. Некоторые библиотеки, которые используются при веб-разработке, также могут быть несовместимы с Zone.js.

Signal API обеспечивает реактивность на уровне примитивов:

  • Локальные обновления. Сигналы явно указывают зависимости между данными и компонентами. При изменении значения обновляются только связанные части UI.

  • Контроль над реактивностью. Разработчик решает, когда и как обновлять состояние, избегая ненужных циклов Change Detection.

  • Меньший вес. Сигналы встроены в Angular и не требуют дополнительных зависимостей, что уменьшает размер бандла.

Сравнение Zone.js и Signal API

Критерий

Zone.js

Signal API

Обнаружение

Глобальное (все компонентное дерево)

Локальное (только зависимости)

Триггеры

Любое асинхронное событие

Явное изменение сигнала

Производительность

Риск избыточных перерисовок

Точечные обновления

Размер кода

+34 КБ (Zone.js)

Неотъемлемая часть Angular Core, в будущем может стать стандартом JavaScript

Механизмы Change Detection и Local Change Detection

Change Detection — механизм в Angular, позволяющий проверить, изменилось ли что-то в данных, чтобы обновить UI. Изначально Angular использовал библиотеку Zone.js, которая позволяла уведомлять Angular, что надо проверить определенные компоненты на изменение. 

Рассмотрим принцип работы Change Detection при использовании Zone.js на примере приложения для блога с шапкой (компонент Header), контентом (компонент Content), сайдбаром (компонент Sidebar) и футером (компонент Footer). 

Компонент контента содержит список публикаций (компонент ArticleList), который использует компонент публикации (компонент ArticleItem). Компонент публикации содержит поле ввода комментария (компонент CommentList). Во всех компонентах включена OnPush-стратегия проверки изменений.

Схема компонентов приложения для блога (Zone.js)
Схема компонентов приложения для блога (Zone.js)

Предположим, что под публикацией пишут комментарий и передают его в компоненте ArticleItem. В таком случае Angular запустит проверку изменений для всего дерева компонентов при событии в глубоко вложенном компоненте (компонент CommentList) — от целевого элемента до корня приложения, хотя по сути изменились лишь два компонента CommentList и ArticleItem.

В такой концепции слишком много дополнительных проверок, поскольку все компоненты сверху вниз вплоть до нужного помечались для проверки. Поэтому разработчики Angular ввели Signal API — Local Change Detection. Теперь сам компонент уведомляет, что в нем что-то изменилось, и не делает дополнительных проверок по всему дереву компонентов. Мы просто ищем нужный компонент, который имеет определенный флаг.

Для примера оставим структуру приложения как и с Change Detection, но теперь компонент ввода комментария содержит в себе сигнал, который считывается в родительском компоненте публикации. После ввода и подтверждения комментария событие вновь возникает в самом глубоком компоненте, но теперь проверка изменений не идет вверх до рутового компонента. 

В игру вступают новые флаги: RefreshView и HAS_CHILD_VIEWS_TO_REFRESH. Компонент, в котором изменился сигнал, помечается флагом RefreshView, а все его предки — флагом HAS_CHILD_VIEWS_TO_REFRESH. В таком случае, пока мы не дойдем до RefreshView-компонента, все остальные будут пропущены и лишь он будет проверен и обновлен.

Проверка изменений запускается лишь в зависимых от сигнала компонентах (CommentList и ArticleItem), что является более эффективным решением, чем манкипатчинг Zone.js.

Схема компонентов приложения для блога (Signal API)
Схема компонентов приложения для блога (Signal API)

Zone.js против Signal API на примерах

Проанализируем циклы проверки изменений демоприложения и то, как они отличаются по скорости обработки. Анализировать производительность будем с помощью Angular DevTools, в среднем вызов действий будет выполнен от 50 до 100 раз в несколько итераций для более корректной статистики.

Для демонстрации я подготовил демо-проект на GitHub.

Рассмотрим пример с длинной таблицей с данными. Возьмем простую модель: id, name и surname. И замокируем, например, 5000 разных объектов.

В качестве UI kit’а в некоторых частях приложения будем использовать наше решение Taiga UI, которое активно развивается большим комьюнити с помощью open source. Эта мощная и гибкая библиотека компонентов для Angular позволяет быстро создавать современные и красивые веб-приложения любого уровня сложности. Недавно мой коллега рассказывал о библиотеке подробнее:

Хост директивы: ключ к декомпозиции
В Angular 15 появилась новая фича, которой не уделяют должного внимания, — Directive Composition API...
habr.com

Построим примитивную таблицу с несколькими полями и кнопками. Пример кода компонента с использованием Zone.js:

@Component({
 selector: 'app-table',
 imports: [TuiButton, AsyncPipe],
 template: `
  <button tuiButton (click)="getUsers()">Запросить данные</button>
   @if (users$ | async; as users) {
     <table>
       <thead>
         <tr>
           <th>Id</th>
           <th>Name</th>
           <th>Surname</th>
         </tr>
       </thead>
       <tbody>
         @for (user of users; track user.id) {
           <tr>
             <td>
               {{ user.id }}
             </td>
             <td>
               {{ user.name }}
             </td>
             <td>
               {{ user.surname }}
             </td>
           </tr>
         }
       </tbody>
     </table>
   }
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
 providers: [UsersService],
})
export class TableComponent {
 private readonly usersService = inject(UsersService);


 protected users$: Observable<User[]> = of([]);


 protected getUsers(): void {
   this.users$ = this.usersService.users$;
 }
}

Есть кнопка для запроса данных и сама таблица. Код сервиса получения данных достаточно примитивен, мок 5000 элементов:

@Injectable()
export class UsersService {
 private _users: User[] = [];


 public readonly users$ = new BehaviorSubject<User[]>([]);


 constructor() {
   this.generateUsers();
 }


 private generateUsers(): void {
   for (let i = 1; i <= 5000; i++) {
     this._users.push({
       id: i,
       name: `User  ${i}`,
       surname: `Surname ${i}`,
     });
   }


   this.users$.next(this._users);
 }
}

Кликнем на кнопку и анализируем производительность — на отрисовку в среднем ушло 56,04 мс.

Рассмотрим аналогичный пример, но с использованием Signal API. Сразу отключим Zone.js. Для этого добавим provider provideZonelessChangeDetection() в app.config (или в main.ts), а также удалим опцию "polyfills": ["zone.js"] в angular.json.

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

@Component({
 selector: 'app-table',
 imports: [TuiButton],
 template: `
   <button #getUsersButton tuiButton>Запросить данные</button>
   @if (users) {
     <table>
       <thead>
         <tr>
           <th>Id</th>
           <th>Name</th>
           <th>Surname</th>
         </tr>
       </thead>
       <tbody>
         @for (user of users; track user.id) {
           <tr>
             <td>
               {{ user.id }}
             </td>
             <td>
               {{ user.name }}
             </td>
             <td>
               {{ user.surname }}
             </td>
           </tr>
         }
       </tbody>
     </table>
   }
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
 providers: [UsersService],
})
export class TableComponent implements AfterViewInit {
 private readonly usersService = inject(UsersService);
 private readonly destroyRef = inject(DestroyRef);


 private getUsersButton = viewChild('getUsersButton', { read: ElementRef });


 public get users(): User[] {
   return this.usersService.users();
 }


 ngAfterViewInit(): void {
   fromEvent(this.getUsersButton()?.nativeElement, 'click')
     .pipe(
       tap(() => this.usersService.generateUsers()),
       takeUntilDestroyed(this.destroyRef),
     )
     .subscribe();
 }
}

Чтобы клик на кнопку не помечал компоненты как dirty, а применялся Local Change Detection, подпишемся на клик через fromEvent. Для получения данных используется сервис, схожий с сервисом примера Zone.js:

@Injectable()
export class UsersService {
 public readonly users = signal<User[]>([]);


 public generateUsers(): void {
   const users = [];


   for (let i = 1; i <= 5000; i++) {
     users.push({
       id: i,
       name: `User  ${i}`,
       surname: `Surname ${i}`,
     });
   }


   this.users.set(users);
 }


 public addNewUser(): void {
   const index = this.users().length;


   this.users.update(users => [
     ...users,
     {
       id: index,
       name: `User  ${index}`,
       surname: `Surname ${index}`,
     },
   ]);
 }


 public removeLastUser(): void {
   this.users.update(users => users.slice(0, users.length - 1));
 }
}

Кликнем на кнопку и проанализируем производительность: на отрисовку в среднем ушло 54,05 мс. Особых изменений в производительности замечено не было.

Реализуем дополнительно две кнопки — для добавления нового объекта и для удаления последнего. Обработчики в компоненте:

protected addNewUser(): void {
   this.usersService.addNewUser();
 }


 protected removeLastUser(): void {
   this.usersService.removeLastUser();
 }

Код сервиса по добавлению и удалению элемента:

public addNewUser(): void {
  const index = this._users.length;


  this._users.push({
    id: index,
    name: `User  ${index}`,
    surname: `Surname ${index}`,
  });


  this.users$.next(this._users);
}


public removeLastUser(): void {
  this._users.pop();


  this.users$.next(this._users);
}

Проанализируем обработку их клика. С использованием Zone.js в среднем вышло 6,37 мс

С использованием Signal API без Zone.js в среднем вышло 5,75 мс

Прирост в скорости выполнения цикла проверки изменений получился 10%.

Перейдем ко вложенным спискам. Выберем глубину списка, например 500. Пример кода компонентов с использованием Zone.js:

@Component({
 selector: 'app-tree',
 standalone: true,
 imports: [TreeItemComponent],
 template: `
   <div class="tree">
     <app-tree-item [depth]="0" [maxDepth]="500" />
   </div>
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeComponent {}


@Component({
 selector: 'app-tree-item',
 standalone: true,
 imports: [TuiTitle, TuiButton, TuiAppearance, TuiCardMedium, TuiTitle, AsyncPipe],
 template: `
   <div
     tuiAppearance="neutral"
     tuiCardMedium
     [style.margin-left.px]="depth * 10"
   >
     <span tuiTitle="m">{{ itemText$ | async }}</span>


    <button tuiButton (click)="updateItemText()">Изменить</button>
   </div>


   @if (!isDeepest) {
     <app-tree-item [depth]="depth + 1" [maxDepth]="maxDepth" />
   }
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeItemComponent implements OnInit {
 @Input({ required: true }) depth!: number;
 @Input({ required: true }) maxDepth!: number;


 protected readonly itemText$ = new BehaviorSubject<number>(0);


 protected get isDeepest(): boolean {
   return this.depth === this.maxDepth;
 }


 ngOnInit(): void {
   this.itemText$.next(this.depth);
 }


 protected updateItemText(): void {
   this.itemText$.next(Math.round(Math.random() * this.maxDepth));
 }
}

После нажатия на кнопку «Изменить» у последнего элемента проанализируем производительность:

Среднее время ожидания обновления значения в самом глубоком компоненте заняло 1,37 мс.

Перейдем к сигналам. Код компонента дерева остается таким же, а вот код элемента дерева следующий:

@Component({
 selector: 'app-tree-item',
 standalone: true,
 imports: [TuiTitle, TuiButton, TuiAppearance, TuiCardMedium, TuiTitle],
 template: `
   <div
     tuiAppearance="neutral"
     tuiCardMedium
     [style.margin-left.px]="depth() * 10"
   >
     <span tuiTitle="m">{{ itemText() }}</span>


     <button #editItemButton tuiButton>Изменить</button>
   </div>


   @if (!isDeepest()) {
     <app-tree-item [depth]="depth() + 1" [maxDepth]="maxDepth()" />
   }
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeItemComponent implements AfterViewInit {
 private readonly destroyRef = inject(DestroyRef);
 private readonly editItemButton = viewChild('editItemButton', { read: ElementRef });


 public readonly depth = input.required<number>();
 public readonly maxDepth = input.required<number>();


 protected readonly itemText = linkedSignal(() => this.depth());


 protected readonly isDeepest = computed(() => this.depth() === this.maxDepth());


 ngAfterViewInit(): void {
   fromEvent(this.editItemButton()?.nativeElement, 'click')
     .pipe(
       tap(() => this.updateItemText()),
       takeUntilDestroyed(this.destroyRef),
     )
     .subscribe();
 }


 private updateItemText(): void {
   this.itemText.set(Math.round(Math.random() * this.maxDepth()));
 }
}

После нажатия на кнопку «Изменить» у самого глубокого элемента проанализируем производительность:

В среднем на перерисовку уходит 0,2 мс. Прирост в скорости вышел 85%!

По примерам видно, что наибольший прирост в скорости замечен на примере с глубокой вложенностью компонентов. Изменялся лишь текст, но суть замеров была в том, чтобы проверить, как уменьшение количества проходов Change Detection влияет на перерисовку состояния. Абсолютные значения прохода Change Detection невелики, но если делать какую-то затратную операцию, то результат оптимизации будет еще очевиднее.

Структура современных веб-приложений состоит из большого числа вложенных друг в друга компонентов, поэтому эти цифры и наблюдения недвусмысленно подтверждают: переход к Zoneless в связке с сигналами — не просто синтаксическое обновление, а фундаментальный шаг к созданию более быстрых и предсказуемых Angular-приложений.

Заключение

Отказ от Zone.js и переход на Signal API стал ключевым прорывом для Angular, устранив избыточные проверки и повысив производительность, особенно в сложных компонентах. Сигналы обеспечили точечный контроль над реактивностью и сделали код чище за счет явных зависимостей. Этот переход кардинально улучшил отзывчивость приложений и уменьшил размер бандла.

Signal API — будущее реактивности в Angular, его интеграция будет только углубляться. Разработчикам необходимо активно осваивать сигналы и пересматривать подходы к управлению состоянием. 

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

И на прощание — полезная ссылка для желающих подробнее почитать про Change Detection.

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