Перейти к содержимому

Angular

Как устроено Angular-приложение, как фреймворк узнаёт об изменениях, как работать с RxJS и что поменялось с Angular 13 до актуальной версии 22. На собеседовании это проверяют и как знание основ, и как готовность поддерживать или обновлять старое приложение.

Перед этой темой: JavaScript и TypeScript.

Junior+
Ответ

Что это

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

  • Компонент — класс с декоратором @Component и HTML-шаблон. Класс хранит данные и обработчики, шаблон их показывает.
  • Сервис — класс с общей логикой: вызовы API, кеш, текущий пользователь.
  • Маршрутизатор — показывает компонент, который соответствует URL.

Как пользоваться

  1. ng generate component orders создаёт компонент.
  2. Дочерние компоненты и директивы, которые нужны шаблону, перечислите в imports декоратора.
  3. Сервис получите полем класса: private orders = inject(OrderService).

Частая ошибка

Забыть добавить дочерний компонент в imports. Сборка упадёт с ошибкой 'app-x' is not a known element.

Как устроено

Синтаксис в шаблоне Что делает
{{ total }} интерполяция: значение из класса в текст
[disabled]="busy" привязка свойства: класс → DOM
(click)="save()" привязка события: DOM → класс
[(value)]="name" двусторонняя: свойство плюс событие valueChange
  • Директива меняет поведение элемента: [class.active], ngModel. Компонент — это директива со своим шаблоном.
  • Структурные директивы *ngIf и *ngFor с Angular 17 заменены встроенными блоками @if и @for.
  • NgModule против standalone. В Angular 13 компонент объявляли в declarations какого-нибудь NgModule. С v19 компоненты standalone по умолчанию и перечисляют зависимости в своих imports.

Когда что выбирать

Способ получить сервис Когда
inject(Service) в поле по умолчанию, так советует style guide Angular
параметр конструктора старый код; работает как раньше

Ловушки

  • inject() работает только в injection context: инициализаторы полей, конструктор, фабрики провайдеров, гарды. Вызов в обработчике события даст ошибку NG0203.
  • Сервис в providers компонента — отдельный экземпляр на каждый экземпляр компонента, а не общий.

Правило

Новые компоненты — standalone, сервисы — providedIn: 'root', зависимости — через inject().

Под капотом

  • AOT-компилятор превращает шаблон в функции создания и обновления DOM. Ошибки типов в привязках видны на сборке.
  • Поиск провайдера идёт снизу вверх: сначала инжекторы элементов от компонента к корню дерева, затем environment-инжекторы — маршрута, корневой, платформенный. Не нашёл — NullInjectorError.

Сравнение с ASP.NET Core

Angular Ближайший аналог в ASP.NET Core
providedIn: 'root', @Service() (v22) AddSingleton
providers маршрута экземпляр на раздел приложения; прямого аналога нет
providers компонента экземпляр на компонент и его поддерево; scopes в .NET не иерархичны
inject(X, { optional: true }) GetService вместо GetRequiredService

Компромиссы

  • Standalone делает зависимости компонента явными и позволяет лениво грузить отдельный компонент. NgModule поддерживается, а для перевода есть миграция ng generate @angular/core:standalone.
  • @Service() из v22 — короткая запись @Injectable({ providedIn: 'root' }). Он работает только с inject(), без параметров конструктора.
  • inject() упрощает наследование: базовому классу не нужен super(...) с длинным списком зависимостей.
  • Иерархия позволяет дать странице своё состояние через providers, но тот же механизм незаметно плодит дубли синглтонов.

Как проверить

Angular DevTools в dev-сборке показывает дерево компонентов и инжекторов и откуда пришёл каждый сервис. Дубли синглтонов видны там же.

Итог: DI в Angular иерархический: где объявлен провайдер, там и граница общего экземпляра.

Пример
import { Component, Injectable, inject } from '@angular/core';
@Injectable({ providedIn: 'root' }) // один экземпляр на приложение, как AddSingleton
export class GreetingService {
greet(name: string) {
return `Hello, ${name}!`;
}
}
@Component({
selector: 'app-greeting', // standalone по умолчанию с v19
templateUrl: './greeting.html',
})
export class Greeting {
private readonly greetings = inject(GreetingService); // вместо параметра конструктора
protected message = '';
protected say(name: string) {
this.message = this.greetings.greet(name);
}
}
greeting.html
<input #name value="Angular" />
<button (click)="say(name.value)">Greet</button>
<p [class.empty]="!message">{{ message }}</p> <!-- после клика: Hello, Angular! -->
Middle+

Как работает обнаружение изменений: zone.js, OnPush, signals и zoneless?

Заголовок раздела «Как работает обнаружение изменений: zone.js, OnPush, signals и zoneless?»
Ответ

Что это

Change detection — проверка, совпадает ли то, что на экране, с данными компонентов. Если значение в привязке изменилось, Angular обновляет этот кусок DOM.

Что изменилось с Angular 13

  • Тогда проверку запускал zone.js после каждого клика, таймера или HTTP-ответа — по всему дереву.
  • Сейчас состояние держат в сигналах: signal(0) создаёт значение, count() читает его, count.set(1) меняет. Angular знает, какие шаблоны читают сигнал, и обновляет только их.
  • С v21 новые приложения работают без zone.js, с v22 компоненты по умолчанию OnPush.

Частая ошибка

Менять массив внутри сигнала: items().push(x). Ссылка та же, сигнал не сообщит об изменении. Нужно items.update(list => [...list, x]).

Как устроено с zone.js

  1. zone.js подменяет асинхронные API браузера: setTimeout, Promise, addEventListener, XHR.
  2. Когда задача и её микрозадачи завершились, NgZone сообщает об этом, и Angular вызывает ApplicationRef.tick().
  3. Дерево компонентов проверяется сверху вниз: каждое выражение шаблона сравнивается с прошлым значением.
  4. В dev-режиме второй проход ловит значения, изменившиеся во время проверки, — ExpressionChangedAfterItHasBeenCheckedError.

Стратегии

Стратегия Когда компонент проверяется
Eager (раньше Default) в каждом цикле
OnPush (по умолчанию с v22) новый input по ссылке, событие в поддереве, markForCheck(), изменение прочитанного сигнала

Сигналы

signal хранит значение, computed вычисляет производное — лениво и с кешем, effect выполняет побочное действие: лог, запись в localStorage, вызов сторонней библиотеки.

Ловушки

  • Мутация объекта во входе OnPush-компонента: ссылка та же, проверки не будет.
  • Поле, изменённое в setTimeout или в подписке, в OnPush-компоненте не отрисуется без сигнала или markForCheck().
  • effect, который копирует один сигнал в другой. Для производных значений есть computed и linkedSignal.

Правило

Состояние — в сигналах, входы — неизменяемые, производное — через computed.

Под капотом

  • Сигналы образуют граф: шаблоны, computed и effect — потребители, сигналы — источники. Чтение сигнала в реактивном контексте регистрирует зависимость.
  • set() помечает зависимые представления и планирует проверку. OnPush-предки служат только путём к ним и сами не перепроверяются.
  • Без zone.js проверку запускают только уведомления: markForCheck() (его вызывает async pipe), ComponentRef.setInput, изменение сигнала из шаблона, обработчик события в шаблоне.

Цена zone.js

  • Проверка после любого асинхронного события, даже если данные не менялись.
  • Вес бандла, время старта и длинные стек-трейсы.
  • Нативный async/await zone.js перехватить не может, поэтому сборка переписывает его в генераторы.

Компромиссы при миграции

  1. ng update до v21 добавляет provideZoneChangeDetection(), до v22 — ChangeDetectionStrategy.Eager компонентам без явной стратегии. Поведение сохраняется.
  2. Компоненты переводят на OnPush-совместимый код: сигналы, неизменяемые входы, async pipe.
  3. NgZone.onStable и onMicrotaskEmpty в zoneless не срабатывают — их заменяют afterNextRender.
  4. Последним убирают provideZoneChangeDetection() и zone.js из polyfills.

Как проверить

  • В dev-сборке provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) находит привязки, изменённые без уведомления.
  • Профайлер Angular DevTools показывает длительность каждого цикла и проверенные компоненты.

Итог: zoneless — переход от «проверять всё после любого события» к «обновлять то, о чём сообщили», и готовность к нему определяет код компонентов, а не флаг.

Пример
import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-cart',
templateUrl: './cart.html',
// С v22 OnPush — по умолчанию; в Angular 13 его указывали явно
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Cart {
protected readonly items = signal<{ name: string; price: number }[]>([]);
// Пересчитается только при изменении items
protected readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
constructor() {
effect(() => console.log('Items:', this.items().length)); // побочное действие, не состояние
}
protected add() {
// Новая ссылка: сигнал сообщит об изменении.
// this.items().push(...) не сработает — ссылка та же
this.items.update(list => [...list, { name: 'Book', price: 10 }]);
}
}
cart.html
<button (click)="add()">Add</button>
<p>Items: {{ items().length }}, total: {{ total() }}</p> <!-- после клика: Items: 1, total: 10 -->
Режим при запуске
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZoneChangeDetection, provideZonelessChangeDetection } from '@angular/core';
import { App } from './app/app';
// Angular 20: zoneless включают явно; с v21 это поведение по умолчанию
bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] });
// Старое приложение после ng update до v21+ остаётся на zone.js так:
// bootstrapApplication(App, { providers: [provideZoneChangeDetection()] });
Middle+
Ответ

Что это

Observable — источник, который после подписки выдаёт значения: ноль, одно или много, а затем завершается или падает с ошибкой.

Признак Promise Observable
Когда начинается работа сразу при создании при подписке
Сколько значений одно сколько угодно
Отмена нет отписка (unsubscribe)

Как пользоваться

  • http.get<User[]>('/api/users') возвращает Observable, запрос уйдёт при подписке.
  • В шаблоне: users$ | async — подписка и отписка автоматически.
  • В коде: .subscribe(users => ...).

Частая ошибка

Подписаться на поток, который сам не завершается (interval, router.events, Subject в сервисе), и не отписаться. Компонент уничтожен, а подписка живёт и держит его в памяти.

Как устроено

  • HttpClient выдаёт одно значение и завершается. Каждая подписка — новый запрос, отписка до ответа отменяет его.
  • Операторы в pipe() строят новый поток: map, filter, debounceTime, catchError.

Когда что выбирать

Оператор Новое значение, пока прошлое не завершено Пример
switchMap отменяет прошлое поиск по мере ввода
mergeMap выполняет параллельно загрузка независимых файлов
concatMap ставит в очередь сохранения, где важен порядок
exhaustMap игнорирует новое кнопка «Отправить», вход

Как отписываться

  • async pipe — в шаблоне.
  • toSignal(obs$) превращает поток в сигнал и отписывается при уничтожении компонента; toObservable(sig) делает обратное.
  • takeUntilDestroyed() (стабилен с v19) — для ручного subscribe. Вне injection context ему передают DestroyRef.

Ловушки

  • Два async pipe на один HTTP-поток — два запроса. Общий результат — через shareReplay или toSignal.
  • takeUntil(destroy$) не последним в pipe(): операторы после него могут оставить подписку живой.
  • Вложенный subscribe внутри subscribe вместо switchMap: подписки не отменяются, ошибки теряются.

Правило

Подписку создаёт шаблон или toSignal; ручной subscribe — только с takeUntilDestroyed().

Под капотом

  • Observable — функция подписки. pipe() ничего не запускает, а subscribe() строит цепочку подписчиков до источника.
  • Отписка идёт по цепочке вверх. Для HttpClient это отмена запроса: AbortController у fetch-бэкенда (по умолчанию с v22) или abort() у XHR.
  • toObservable отдаёт значения через effect, поэтому несколько set() подряд дадут одно значение — последнее.

Цена

  • switchMap отменяет ответ на клиенте, но не работу на сервере. Для POST и PUT это потерянные или задвоенные изменения, поэтому запись — через concatMap или exhaustMap.
  • mergeMap без лимита конкурентности (mergeMap(fn, 4)) способен отправить сотни запросов разом.
  • shareReplay(1) без refCount: true держит подписку на источник и после ухода всех подписчиков.

Компромиссы

Задача Инструмент
состояние для шаблона сигнал
загрузка данных по параметрам httpResource, rxResource (стабильны с v22)
события во времени, WebSocket, debounce, retry RxJS

Как проверить

  • Утечку видно в Chrome DevTools: снимок кучи после нескольких переходов туда и обратно показывает отсоединённые (detached) элементы и живые экземпляры компонента.
  • Во вкладке Network отменённые запросы помечены (canceled), а дубли запросов выдают лишнюю подписку.

Итог: RxJS — инструмент для потоков событий и конкурентности, а не для хранения состояния; состояние — в сигналах.

Пример
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, interval, switchMap } from 'rxjs';
@Component({ selector: 'app-product-search', imports: [ReactiveFormsModule], templateUrl: './product-search.html' })
export class ProductSearch {
private readonly http = inject(HttpClient);
protected readonly query = new FormControl('', { nonNullable: true });
// switchMap отменяет прошлый запрос, если пользователь продолжил печатать
protected readonly products = toSignal(
this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.http.get<{ id: number; name: string }[]>('/api/products', { params: { q } })),
),
{ initialValue: [] }, // отписка — автоматически при уничтожении компонента
);
constructor() {
// Бесконечный поток: без takeUntilDestroyed подписка переживёт компонент
interval(60_000).pipe(takeUntilDestroyed()).subscribe(n => console.log('tick', n));
}
}
product-search.html
<input [formControl]="query" placeholder="Search" />
@for (p of products(); track p.id) {
<p>{{ p.name }}</p>
}
Middle+

Что изменилось в Angular с версии 13 до актуальной?

Заголовок раздела «Что изменилось в Angular с версии 13 до актуальной?»
Ответ

Что это

Angular 13 вышел в ноябре 2021, текущая версия — 22 (июнь 2026). За это время сменился сам стиль кода.

Главное

  • Компоненты без NgModule — standalone.
  • Новый синтаксис шаблонов: @if, @for, @switch вместо *ngIf и *ngFor.
  • @defer — ленивая загрузка части шаблона.
  • Сигналы: состояние, входы input() и выходы output().
  • inject() вместо параметров конструктора.
  • Сборка на esbuild и Vite — заметно быстрее Webpack.
  • SSR с гидратацией — пакет @angular/ssr вместо Angular Universal.
  • Работа без zone.js и OnPush по умолчанию.

Частая ошибка

Думать, что старый код перестал работать. NgModule, @Input() и *ngIf (deprecated с v20) поддерживаются, а ng update сам правит то, что ломается.

Когда что появилось

Версия Что изменилось
14–15 (2022) standalone-компоненты, типизированные формы, inject(), функциональные гарды и интерцепторы
16 (май 2023) сигналы (preview), takeUntilDestroyed, гидратация (preview), сборка на esbuild (preview)
17 (ноябрь 2023) @if, @for, @defer; стабильные сигналы; application builder и Vite для новых проектов
17.1–19 input(), output(), model(), сигнальные запросы; с v19 стабильны, standalone по умолчанию
20 (май 2025) стабильны effect, linkedSignal, toSignal; *ngIf и *ngFor deprecated; zoneless стабилен с 20.2
21 (ноябрь 2025) zoneless и Vitest по умолчанию в новых проектах; экспериментальные Signal Forms
22 (июнь 2026) OnPush по умолчанию; стабильны Signal Forms, resource и httpResource; @Service(); HttpClient на fetch

Ловушки миграции

  • ng update переходит только на одну мажорную версию за раз. С 13 до 22 — девять шагов, у каждого свои требования к Node.js и TypeScript.
  • Сторонние библиотеки обновляют вместе с Angular: Kendo UI for Angular, angular-oauth2-oidc и другие должны поддерживать целевую версию.
  • Собственные настройки Webpack в application builder не переносятся.

Правило

Сначала довести версии до актуальной с сохранением поведения, потом модернизировать код миграциями ng generate @angular/core:... — по одной за раз.

Почему так

  • Сигналы и zoneless убирают главную цену старого Angular — проверку всего дерева по любому событию.
  • Standalone и @defer делают единицей загрузки компонент, а не модуль.
  • esbuild и Vite ускоряют сборку; Webpack-сборщики в v22 объявлены deprecated.
  • С v22 мажорный релиз выходит раз в 12 месяцев, а не раз в полгода. v23 ожидается около июня 2027.

Стратегия миграции

  1. Обновлять мажор за мажором по update guide, после каждого шага — сборка и тесты.
  2. Автоматические миграции сохраняют поведение: в v21 приложению добавляется provideZoneChangeDetection(), в v22 компонентам без стратегии — ChangeDetectionStrategy.Eager.
  3. Сборщик переводят командой ng update @angular/cli --name use-application-builder.
  4. Затем по одной: standalone, control flow, inject(), input() и output(), ленивые маршруты.
  5. Последними — OnPush и zoneless: они меняют логику, а не синтаксис.

Цена

  • Каждая мажорная версия поднимает минимум TypeScript и Node.js: v22 требует TypeScript 6.0 и Node.js 22.22+ или 24.13+.
  • Удаляются старые API: в v22 — ComponentFactoryResolver. HttpClient перешёл на fetch, и для прогресса загрузки файлов нужен withXhr().
  • На октябрь 2026 Kendo UI for Angular 25.x поддерживает Angular 20–22, а angular-oauth2-oidc 22.x требует Angular 22.

Компромиссы

Переписывать всё сразу незачем: старый и новый стиль сосуществуют в одном приложении. Новое пишут в новом стиле, старое переводят при изменении.

Итог: миграция с 13 — сначала безопасное обновление версий с сохранением поведения, потом постепенный переход на сигналы и zoneless.

Пример
import { Component, EventEmitter, Input, Output, inject, input, output } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface User { id: number; name: string; }
// Angular 13: декораторы, конструктор, объявление в declarations какого-нибудь NgModule
@Component({ selector: 'app-user-old', templateUrl: './user-old.component.html' })
export class UserOldComponent {
@Input() user!: User;
@Output() selected = new EventEmitter<User>();
constructor(private http: HttpClient) {}
}
// Angular 22: standalone, сигнальные входы и выходы, inject()
@Component({ selector: 'app-user', templateUrl: './user.html' })
export class UserCard {
readonly user = input.required<User>(); // читается как user()
readonly selected = output<User>();
private readonly http = inject(HttpClient);
}
<!-- Angular 13 -->
<li *ngFor="let o of orders; trackBy: trackById">{{ o.title }}</li>
<p *ngIf="!orders.length">No orders</p>
<!-- Angular 22: track обязателен, @empty вместо отдельного *ngIf -->
@for (o of orders(); track o.id) {
<li>{{ o.title }}</li>
} @empty {
<p>No orders</p>
}
<!-- Код графика загрузится, когда блок появится в области видимости -->
@defer (on viewport) {
<app-sales-chart />
} @placeholder {
<p>Chart</p>
}
Senior+

Как строить большое Angular-приложение и управлять состоянием?

Заголовок раздела «Как строить большое Angular-приложение и управлять состоянием?»
Ответ

Что это

Большое приложение делят по фичам — бизнес-разделам: заказы, клиенты, отчёты.

  • core/ — то, что нужно один раз: авторизация, интерцепторы, каркас страницы.
  • shared/ — общие UI-компоненты и пайпы без бизнес-логики.
  • features/orders/ — маршруты, страницы и состояние одной фичи.

Как пользоваться

Фичу подключают ленивым маршрутом: loadChildren: () => import('./orders/orders.routes'). Её код попадает в отдельный файл (чанк) и загружается при первом переходе.

Частая ошибка

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

Когда что выбирать

Подход к состоянию Когда
сервис с сигналами состояние одной фичи, простые операции
NgRx SignalStore много сторов, нужна единая структура и плагины: withEntities, события
NgRx Store общее состояние многих фич, сложные потоки событий, Redux DevTools

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

  • OnPush и сигналы — меньше проверок.
  • track по id в @for — DOM переиспользуется, а не пересоздаётся.
  • Длинные списки — виртуальная прокрутка: cdk-virtual-scroll-viewport из Angular CDK или scrollable="virtual" в Kendo Grid.
  • Тяжёлые виджеты — графики, планировщик — в @defer.

Ловушки

  • providedIn: 'root' для состояния страницы: оно переживает уход со страницы, и при возврате видны старые данные. Такой стор объявляют в providers маршрута или компонента.
  • Глобальный стор для всего подряд, включая состояние одной формы.
  • Kendo Grid на тысячи строк без пейджинга или виртуальной прокрутки.

Правило

Начинать с сервиса на сигналах. SignalStore — когда сторов много и нужна единая форма, NgRx Store — когда состояние действительно общее и сложное.

Компромиссы в состоянии

  • Большая часть «состояния» — кеш серверных данных. Его закрывают httpResource или сервис; глобальный стор нужен для общего: пользователь, права, настройки.
  • NgRx Store даёт трассировку и time travel в Redux DevTools ценой шаблонного кода: action, reducer, selector и effect на операцию.
  • providers маршрута дают стору время жизни фичи — как scope в ASP.NET Core, только на раздел приложения.

Цена размера бандла

  • budgets в angular.json валят сборку, если начальный бандл превысил лимит.
  • Состав бандла показывают ng build --stats-json и анализатор esbuild.
  • Kendo UI и Angular Material добавляют вес: импортируют только нужные компоненты, а Chart и Scheduler прячут за @defer.

Библиотеки компонентов

  • Обновление Angular ограничено их peerDependencies — проверяют до ng update.
  • Своя обёртка вроде app-data-grid поверх Kendo Grid локализует настройки и будущую замену библиотеки.

Авторизация в SPA

Функциональный интерцептор добавляет access-токен, гард canMatch скрывает недоступные маршруты. Гард — удобство для пользователя, права проверяет API. Где хранить токены и когда нужен BFF — в теме OAuth 2.0 и OpenID Connect.

Как проверить в проде

Core Web Vitals реальных пользователей и Lighthouse показывают итог, вкладка Coverage в Chrome — сколько загруженного JavaScript не выполнилось.

Итог: масштаб держат границы фич, ленивая загрузка и осознанный выбор, где живёт состояние.

Пример
app.routes.ts
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './core/auth.service';
import { OrdersStore } from './features/orders/orders.store';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./features/home/home').then(m => m.Home) },
{
path: 'orders',
canMatch: [() => inject(AuthService).isLoggedIn()], // удобство; права проверяет API
providers: [OrdersStore], // один стор на фичу, живёт, пока открыт раздел
// Отдельный чанк: загрузится при первом переходе на /orders
loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES),
},
];
NgRx SignalStore
import { computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import { Order, OrdersApi } from './orders.api';
export const OrdersStore = signalStore(
withState({ orders: [] as Order[], filter: '', loading: false }),
withComputed(({ orders, filter }) => ({
// Производное состояние — обычный computed
visible: computed(() => orders().filter(o => o.customer.includes(filter()))),
})),
withMethods((store, api = inject(OrdersApi)) => ({
setFilter(filter: string) {
patchState(store, { filter });
},
async load() {
patchState(store, { loading: true });
patchState(store, { orders: await api.loadAll(), loading: false });
},
})),
);