Angular для React-разработчика: от основ до Senior-уровня

Практический курс перехода с React на Angular для опытных frontend-разработчиков. Каждая глава построена на сравнении с React, реальном коде и разборе типичных ошибок миграции. По итогу курса вы уверенно работаете с современным Angular (v17+), проходите собеседования и строите enterprise-приложения.

Введение в Angular: история, версии и принципиальные отличия от AngularJS

Введение в Angular: история, версии и принципиальные отличия от AngularJS

Представьте, что вы приходите на новую работу и вам говорят: «Мы используем Angular». Вы киваете, открываете документацию — и видите что-то совершенно непохожее на тот Angular, о котором читали в старых туториалах. Контроллеры, $scope, ng-model — ничего этого нет. Перед вами совершенно другой фреймворк, который лишь унаследовал имя. Понять эту разницу критически важно, прежде чем писать первую строку кода.

Два Angular: почему важно разделять понятия

AngularJS (Angular 1.x) — это JavaScript-фреймворк, выпущенный Google в 2010 году. Он строился вокруг концепции двухстороннего связывания через $scope, контроллеров и директив. Архитектура была революционной для своего времени, но с ростом приложений обнаружились серьёзные проблемы: производительность деградировала из-за механизма dirty checking, тестируемость оставляла желать лучшего, а мобильные устройства задыхались под нагрузкой.

Angular (Angular 2+) — это полностью переписанный фреймворк, выпущенный в 2016 году. Общего с AngularJS у него только название и компания-разработчик. Новый Angular написан на TypeScript, использует компонентную архитектуру, иерархическую систему Dependency Injection и реактивное программирование через RxJS. Это принципиально другой инструмент.

Называть Angular 2+ «AngularJS» — всё равно что называть современный автомобиль «каретой» только потому, что оба перевозят людей.

Когда в вакансии пишут «Angular», имеют в виду Angular 2 и выше. AngularJS официально перешёл в режим долгосрочной поддержки и прекратил активное развитие в декабре 2021 года.

Хронология версий: что менялось и почему

Angular придерживается семантического версионирования и выпускает мажорные версии каждые шесть месяцев. Это не значит, что каждая версия ломает всё — команда Google строго следит за обратной совместимостью внутри мажорных версий и предоставляет инструменты миграции (ng update).

Версия Год Ключевые изменения
Angular 2 2016 Полный переход на TypeScript, компоненты, DI
Angular 4 2017 Улучшенный компилятор, уменьшение размера бандла
Angular 6–7 2018 Angular Elements, CLI Workspaces, CDK
Angular 8–9 2019–2020 Ivy-компилятор (по умолчанию с v9), Differential Loading
Angular 12–13 2021 Удаление View Engine, улучшения CLI
Angular 14 2022 Standalone Components (preview), Typed Forms
Angular 15 2022 Standalone API стабилен, директивные композиции
Angular 16 2023 Signals (developer preview), esbuild по умолчанию
Angular 17 2023 Signals стабильны, новый синтаксис шаблонов (@if, @for)
Angular 18–19 2024 Zoneless режим, улучшенный SSR, Signal-based inputs
Angular 20 2025 Полная интеграция Signals, SignalStore, SSR/hydration

Версии 3 не существует — команда пропустила её, чтобы синхронизировать номера пакетов в монорепозитории. Это единственный «пропуск» в истории Angular.

Революция Ivy: почему это важно для React-разработчика

До Angular 9 фреймворк использовал компилятор View Engine. Ivy — это новый движок рендеринга и компиляции, который изменил всё под капотом. Для вас как для разработчика это означает:

Меньший размер бандла. Ivy генерирует код, который лучше поддаётся tree-shaking — удалению неиспользуемого кода. Приложение на Angular 9+ весит значительно меньше, чем аналогичное на Angular 8.

Инкрементальная компиляция. Пересборка при разработке стала быстрее, потому что Ivy перекомпилирует только изменившиеся компоненты.

Улучшенная отладка. В консоли браузера появились читаемые сообщения об ошибках вместо криптографических стектрейсов.

Если вы видите в коде функции с префиксом ɵɵ (например, ɵɵelementStart), это внутренние инструкции Ivy — именно в них компилируются ваши шаблоны. Как показывает анализ внутренней механики Angular, Angular использует подход Incremental DOM: вместо создания виртуального дерева компилятор генерирует инструкции, которые напрямую создают и обновляют реальные DOM-узлы.

Signals: современная реактивность Angular

Начиная с Angular 16, фреймворк получил Signals — примитив реактивности, аналогичный сигналам в SolidJS или useState в React, но с принципиально другой моделью обновлений. Сигнал — это контейнер значения, который отслеживает, кто его читает, и уведомляет подписчиков при изменении.

import { signal, computed, effect } from '@angular/core';

// Создание сигнала
const count = signal(0);

// Вычисляемое значение (аналог useMemo в React)
const doubled = computed(() => count() * 2);

// Эффект (аналог useEffect, но без массива зависимостей)
effect(() => {
  console.log(`Count: ${count()}, Doubled: ${doubled()}`);
});

// Обновление
count.set(5);       // Установить значение
count.update(v => v + 1); // Обновить на основе предыдущего

Ключевое отличие от React: Angular автоматически отслеживает зависимости во время выполнения. Не нужно вручную указывать массив зависимостей — фреймворк сам знает, какие сигналы читает computed или effect.

Философия Angular: «batteries included»

React — это библиотека для рендеринга UI. Всё остальное (роутинг, управление состоянием, HTTP-клиент, формы) вы выбираете сами из экосистемы. Angular — это платформа: он включает всё необходимое для разработки enterprise-приложений из коробки.

Это принципиальная разница в философии. Как отмечают практики из AngularUX:

At 2 a.m., governance beats cleverness. Angular's defaults turn reliability into muscle memory.

angularux.com

Когда в три часа ночи падает продакшн, вам не нужно вспоминать, какую библиотеку для форм выбрала команда полгода назад. В Angular ответ всегда один: Reactive Forms. Роутинг — Angular Router. HTTP — HttpClient. Это снижает когнитивную нагрузку и ускоряет онбординг новых разработчиков.

Первый проект: Angular CLI

Установка и создание проекта занимают три команды:

# Установка Angular CLI глобально
npm install -g @angular/cli

# Создание нового проекта
ng new my-app --routing --style=scss

# Запуск dev-сервера
cd my-app && ng serve

CLI задаст несколько вопросов: нужна ли маршрутизация (почти всегда yes) и какой препроцессор стилей использовать. После этого вы получите полностью настроенный проект с TypeScript, ESLint, тестами и сборкой через esbuild.

Структура проекта выглядит так:

my-app/
├── src/
│   ├── app/
│   │   ├── app.component.ts      # Корневой компонент
│   │   ├── app.component.html    # Шаблон
│   │   ├── app.component.scss    # Стили
│   │   ├── app.component.spec.ts # Тесты
│   │   └── app.routes.ts         # Маршруты (Angular 17+)
│   ├── main.ts                   # Точка входа
│   └── styles.scss               # Глобальные стили
├── angular.json                  # Конфигурация CLI
├── tsconfig.json                 # Конфигурация TypeScript
└── package.json

Обратите внимание: в современном Angular (17+) нет AppModule по умолчанию — проект создаётся в режиме standalone components, о котором подробно поговорим в статье 11.

Ключевые команды CLI, которые нужно знать сразу

# Генерация компонента
ng generate component features/user-list
# Сокращённо:
ng g c features/user-list

# Генерация сервиса
ng g s services/user

# Генерация директивы, пайпа, guard
ng g d directives/highlight
ng g p pipes/truncate
ng g guard guards/auth

# Сборка для продакшна
ng build --configuration=production

# Запуск тестов
ng test

# Обновление Angular
ng update @angular/core @angular/cli

CLI — это не просто генератор файлов. Он управляет зависимостями, применяет schematics (скрипты миграции) при обновлении версий и интегрируется с системами сборки. Для React-разработчика аналогом является Create React App или Vite, но Angular CLI значительно мощнее и охватывает весь жизненный цикл разработки.

Почему Angular выбирают для enterprise

Согласно данным relevant.software, более 26% профессиональных разработчиков называют Angular своим основным веб-фреймворком. Крупные компании — Upwork, PayPal, Deutsche Bank — строят на нём критически важные системы. Причины:

Строгая типизация. TypeScript не опционален в Angular — он встроен в ДНК фреймворка. Это означает, что ошибки типов ловятся на этапе компиляции, а не в продакшне.

Предсказуемая архитектура. Когда в команде 20 разработчиков, важно, чтобы все писали код одинаково. Angular навязывает структуру — это не ограничение, а защита от хаоса.

Долгосрочная поддержка. Google использует Angular во внутренних продуктах (Google Cloud Console, Google Ads). Это гарантирует, что фреймворк не будет заброшен.

Инструменты миграции. Переход с Angular 12 на Angular 20 — это ng update, а не переписывание приложения с нуля.

Для React-разработчика переход на Angular — это не просто изучение нового синтаксиса. Это смена парадигмы: от «выбери сам» к «лучшая практика уже встроена». В следующей статье разберём, как именно эти парадигмы различаются на уровне архитектуры.

Сравнение архитектуры React и Angular: компоненты, DI, шаблоны и философия фреймворка

Сравнение архитектуры React и Angular: компоненты, DI, шаблоны и философия фреймворка

Вы открываете Angular-проект после нескольких лет работы с React и чувствуете лёгкое головокружение. Файлов в три раза больше, декораторы везде, какой-то NgModule, инжекторы, провайдеры. Первый импульс — «зачем всё это?». Но за каждым из этих элементов стоит конкретное архитектурное решение, которое решает реальные проблемы масштабирования. Разберём их по одному, сравнивая с тем, что вы уже знаете.

Библиотека против платформы: фундаментальная разница

React — это библиотека для построения UI. Она отвечает за один вопрос: как отрисовать данные в DOM и обновить их при изменении. Всё остальное — ваш выбор. Роутинг? React Router или TanStack Router. Состояние? Redux, Zustand, Jotai, Recoil. HTTP? Fetch, Axios, TanStack Query. Формы? React Hook Form, Formik.

Angular — это платформа. Она отвечает на все эти вопросы сразу и встроенными средствами. Это не ограничение — это архитектурное решение, которое устраняет decision fatigue (усталость от выбора) в больших командах.

React-стек (типичный):
React + React Router + Redux Toolkit + RTK Query + React Hook Form + Zod

Angular-стек (всё из коробки):
Angular (Router + HttpClient + Reactive Forms + RxJS + DI)

Когда в команде 30 разработчиков, унификация стека — это не удобство, а необходимость.

Компоненты: функция против класса с декоратором

В React компонент — это функция, которая принимает props и возвращает JSX:

// React
interface UserCardProps {
  name: string;
  email: string;
  onDelete: (id: string) => void;
}

function UserCard({ name, email, onDelete }: UserCardProps) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div className="card">
      <h2>{name}</h2>
      {expanded && <p>{email}</p>}
      <button onClick={() => setExpanded(!expanded)}>Toggle</button>
      <button onClick={() => onDelete(email)}>Delete</button>
    </div>
  );
}

В Angular компонент — это класс с декоратором @Component:

// Angular
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div class="card">
      <h2>{{ name }}</h2>
      @if (expanded()) {
        <p>{{ email }}</p>
      }
      <button (click)="toggleExpanded()">Toggle</button>
      <button (click)="delete.emit(email)">Delete</button>
    </div>
  `,
  styleUrl: './user-card.component.scss'
})
export class UserCardComponent {
  @Input() name = '';
  @Input() email = '';
  @Output() delete = new EventEmitter<string>();

  expanded = signal(false);

  toggleExpanded() {
    this.expanded.update(v => !v);
  }
}

Разница не только синтаксическая. В Angular компонент — это класс с состоянием и методами, а не замыкание с хуками. Это означает, что логика компонента организована объектно-ориентированно: методы, свойства, наследование (хотя наследование компонентов в Angular используется редко).

Шаблоны: JSX против HTML с директивами

JSX — это JavaScript с синтаксическим сахаром. Вы пишете «HTML» прямо в JS-файле, и это даёт полную мощь языка: любые выражения, деструктуризация, map/filter, тернарные операторы.

Angular-шаблоны — это расширенный HTML. Они компилируются отдельно от TypeScript-кода и имеют собственный синтаксис. Начиная с Angular 17, появился новый синтаксис управляющих конструкций:

<!-- Angular 17+ новый синтаксис -->
@if (user.isAdmin) {
  <admin-panel [user]="user" />
} @else {
  <user-panel [user]="user" />
}

@for (item of items; track item.id) {
  <li>{{ item.name }}</li>
} @empty {
  <li>Список пуст</li>
}

@switch (status) {
  @case ('active') { <span class="green">Активен</span> }
  @case ('inactive') { <span class="red">Неактивен</span> }
  @default { <span>Неизвестно</span> }
}

До Angular 17 использовались структурные директивы *ngIf, *ngFor, *ngSwitch. Вы встретите их в legacy-коде, поэтому важно знать оба синтаксиса.

Концепция React (JSX) Angular (Template)
Условный рендер {condition && <Comp />} @if (condition) { <comp /> }
Список items.map(i => <Item key={i.id} />) @for (i of items; track i.id) { <item /> }
Привязка события onClick={handler} (click)="handler()"
Привязка свойства className={cls} [class]="cls"
Двусторонняя привязка Нет встроенной [(ngModel)]="value"
Передача данных вниз <Comp prop={value} /> <comp [prop]="value" />
Передача событий вверх <Comp onChange={fn} /> <comp (change)="fn($event)" />

Dependency Injection: инверсия зависимостей вместо prop drilling

Это, пожалуй, самое важное архитектурное отличие Angular от React. В React, если компоненту нужны данные или функции, их передают через props. Когда дерево глубокое, возникает prop drilling — данные передаются через промежуточные компоненты, которым они не нужны. Решение в React — Context API или внешний стор.

Angular решает эту проблему через Dependency Injection (DI) — встроенную систему инверсии зависимостей. Компонент не создаёт свои зависимости сам — он объявляет, что ему нужно, а Angular предоставляет нужный экземпляр:

// Сервис
@Injectable({ providedIn: 'root' })
export class UserService {
  private users = signal<User[]>([]);

  getUsers() { return this.users.asReadonly(); }

  addUser(user: User) {
    this.users.update(list => [...list, user]);
  }
}

// Компонент получает сервис через конструктор
@Component({ ... })
export class UserListComponent {
  // Angular сам создаёт и передаёт экземпляр UserService
  constructor(private userService: UserService) {}

  // Или современный способ через inject()
  private userService = inject(UserService);
}

Ключевое преимущество DI над Context: иерархия инжекторов. Сервис может быть предоставлен на уровне всего приложения (providedIn: 'root'), конкретного модуля или даже отдельного компонента. Это даёт точный контроль над областью видимости и временем жизни зависимостей.

Поток данных: однонаправленный, но с нюансами

React строго однонаправлен: данные текут сверху вниз через props, события поднимаются вверх через callback-функции. Angular поддерживает тот же паттерн, но добавляет двустороннее связывание через [(ngModel)]:

<!-- Двустороннее связывание в Angular -->
<input [(ngModel)]="searchQuery" placeholder="Поиск..." />
<p>Вы ищете: {{ searchQuery }}</p>

Это синтаксический сахар над комбинацией [ngModel]="value" (привязка данных вниз) и (ngModelChange)="value = $event" (событие вверх). Под капотом поток данных остаётся однонаправленным — просто Angular автоматизирует обновление в обе стороны.

Важно: двустороннее связывание удобно для форм, но его не стоит использовать для передачи данных между несвязанными компонентами — это ведёт к запутанному коду.

Жизненный цикл компонента

React и Angular имеют схожие концепции жизненного цикла, но реализованные по-разному:

// Angular lifecycle hooks
@Component({ ... })
export class MyComponent implements OnInit, OnChanges, OnDestroy {

  // Аналог useEffect(fn, [prop]) — вызывается при изменении @Input
  ngOnChanges(changes: SimpleChanges) {
    if (changes['userId']) {
      this.loadUser(changes['userId'].currentValue);
    }
  }

  // Аналог useEffect(fn, []) — вызывается после инициализации
  ngOnInit() {
    this.loadInitialData();
  }

  // Аналог useEffect(() => () => cleanup, []) — очистка
  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}
React Hook Angular Hook Когда вызывается
useEffect(fn, []) ngOnInit После первой инициализации
useEffect(fn, [dep]) ngOnChanges При изменении @Input
useEffect(() => cleanup) ngOnDestroy При уничтожении компонента
useLayoutEffect ngAfterViewInit После рендера DOM
ngAfterContentInit После проекции контента

Модульность: NgModules vs Standalone

Исторически Angular организовывал код через NgModules — декларативные блоки, которые группировали компоненты, директивы и пайпы. Это мощная система, но с высоким порогом входа.

// Старый подход: NgModule
@NgModule({
  declarations: [UserListComponent, UserCardComponent],
  imports: [CommonModule, RouterModule],
  exports: [UserListComponent]
})
export class UserModule {}

Начиная с Angular 14, появились Standalone Components — компоненты, которые не требуют NgModule и сами декларируют свои зависимости:

// Современный подход: Standalone
@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [UserCardComponent, AsyncPipe, RouterLink],
  template: `...`
})
export class UserListComponent {}

Для React-разработчика standalone-подход значительно привычнее: каждый компонент явно импортирует то, что использует — как ES-модули в React.

Почему Angular «многословнее» React

Часто слышат: «Angular требует слишком много boilerplate». Это правда, но за каждой строкой стоит причина:

  • @Injectable({ providedIn: 'root' }) — явно объявляет, что класс является сервисом и доступен глобально. Нет магии, нет неявных зависимостей.
  • @Input() и @Output() — явный контракт компонента. Любой разработчик видит публичный API компонента без чтения шаблона.
  • implements OnInit — явная декларация используемых хуков жизненного цикла. TypeScript проверит, что метод ngOnInit реализован правильно.

Как отмечают в Selectel, Angular знает конкретную ноду, которую нужно изменить, и обновляет только её — без перерисовки всего компонента. Эта точность достигается именно благодаря явной архитектуре.

Многословность Angular — это цена за предсказуемость. В React вы можете написать компонент в 10 строк, но через полгода никто не поймёт, откуда берутся данные. В Angular архитектура самодокументируется.

TypeScript в Angular: декораторы, дженерики, утилитарные типы и строгий режим

TypeScript в Angular: декораторы, дженерики, утилитарные типы и строгий режим

Если в React TypeScript — это опция, которую можно включить или проигнорировать, то в Angular он встроен в саму ДНК фреймворка. Декораторы, которые делают Angular Angular-ом (@Component, @Injectable, @Input), — это TypeScript-фича. Без понимания системы типов вы будете писать Angular-код вслепую, полагаясь на any там, где компилятор мог бы поймать ошибку ещё до запуска браузера.

Строгий режим: почему strict: true — это не опция

При создании Angular-проекта CLI предлагает включить строгий режим. Всегда соглашайтесь. Строгий режим включает несколько флагов TypeScript одновременно:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,           // Включает все флаги ниже
    "noImplicitAny": true,    // Запрещает неявный any
    "strictNullChecks": true, // null и undefined — отдельные типы
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true, // Свойства класса должны быть инициализированы
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true, // Проверка параметров DI
    "strictInputAccessModifiers": true,
    "strictTemplates": true   // Проверка типов в шаблонах!
  }
}

Флаг strictTemplates — уникальная Angular-фича. Он включает проверку типов прямо в HTML-шаблонах. Если вы передаёте строку туда, где ожидается число, компилятор сообщит об ошибке при сборке, а не в рантайме:

<!-- Ошибка компиляции: Type 'string' is not assignable to type 'number' -->
<app-progress [value]="'50'" />

Это принципиально отличает Angular от React, где ошибки типов в JSX часто проявляются только в браузере.

Декораторы: метапрограммирование в Angular

Декораторы — это функции, которые добавляют метаданные к классам, методам и свойствам. В TypeScript они обозначаются символом @. Angular использует их повсеместно.

Декоратор @Component — самый важный. Он превращает обычный TypeScript-класс в Angular-компонент:

@Component({
  selector: 'app-user-profile',  // CSS-селектор для использования в шаблонах
  standalone: true,
  imports: [CommonModule, RouterLink],
  templateUrl: './user-profile.component.html',
  styleUrl: './user-profile.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush  // Оптимизация
})
export class UserProfileComponent {
  // Декоратор @Input превращает свойство в входной параметр компонента
  @Input({ required: true }) userId!: string;

  // Декоратор @Output создаёт EventEmitter для передачи событий родителю
  @Output() profileUpdated = new EventEmitter<UserProfile>();

  // Декоратор @ViewChild даёт доступ к DOM-элементу или дочернему компоненту
  @ViewChild('nameInput') nameInputRef!: ElementRef<HTMLInputElement>;
}

Обратите внимание на ! после userId и nameInputRef. Это non-null assertion operator — вы говорите TypeScript: «Я знаю, что это значение будет инициализировано, доверяй мне». Используйте его осторожно — только когда уверены, что Angular гарантирует инициализацию (как в случае с @Input({ required: true })).

Дженерики в Angular: типизированные сервисы и компоненты

Дженерики позволяют создавать переиспользуемые компоненты и сервисы, которые работают с разными типами данных, сохраняя строгую типизацию. В Angular это особенно полезно для сервисов работы с данными:

// Универсальный HTTP-сервис с дженериком
@Injectable({ providedIn: 'root' })
export class CrudService<T extends { id: string }> {
  constructor(private http: HttpClient) {}

  getAll(endpoint: string): Observable<T[]> {
    return this.http.get<T[]>(endpoint);
  }

  getById(endpoint: string, id: string): Observable<T> {
    return this.http.get<T>(`${endpoint}/${id}`);
  }

  create(endpoint: string, item: Omit<T, 'id'>): Observable<T> {
    return this.http.post<T>(endpoint, item);
  }

  update(endpoint: string, id: string, item: Partial<T>): Observable<T> {
    return this.http.put<T>(`${endpoint}/${id}`, item);
  }
}

// Использование с конкретным типом
interface User {
  id: string;
  name: string;
  email: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private crud = inject(CrudService<User>);

  getUsers() {
    return this.crud.getAll('/api/users'); // Возвращает Observable<User[]>
  }
}

Дженерики в Angular также используются в Signal<T>, Observable<T>, EventEmitter<T> и FormControl<T>. Понимание дженериков — это не академическая тонкость, а ежедневный инструмент.

Утилитарные типы TypeScript в Angular-контексте

TypeScript предоставляет набор встроенных утилитарных типов, которые особенно часто применяются в Angular-разработке:

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: Date;
}

// Partial<T> — все поля опциональны. Используется для форм редактирования
type UserUpdateDto = Partial<User>;
// { id?: string; name?: string; email?: string; ... }

// Required<T> — все поля обязательны
type UserRequired = Required<User>;

// Pick<T, K> — выбрать только нужные поля
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }

// Omit<T, K> — исключить поля
type UserCreateDto = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string; role: ... }

// Readonly<T> — запрет на изменение
type ImmutableUser = Readonly<User>;

// Record<K, V> — объект с ключами типа K и значениями типа V
type UsersByRole = Record<User['role'], User[]>;
// { admin: User[]; user: User[]; moderator: User[] }

Реальный пример применения в Angular-компоненте:

@Component({ ... })
export class UserFormComponent {
  // Форма редактирования — все поля опциональны
  @Input() initialValues: Partial<User> = {};

  // Событие создания — без id (генерируется на сервере)
  @Output() userCreated = new EventEmitter<Omit<User, 'id' | 'createdAt'>>();

  // Словарь ошибок валидации
  validationErrors: Partial<Record<keyof User, string>> = {};
}

Типизация форм: Typed Forms в Angular 14+

До Angular 14 формы были практически нетипизированными — FormControl возвращал any. Начиная с Angular 14, формы полностью типизированы:

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

interface LoginForm {
  email: FormControl<string>;
  password: FormControl<string>;
  rememberMe: FormControl<boolean>;
}

@Component({ ... })
export class LoginComponent {
  private fb = inject(FormBuilder);

  // TypeScript знает типы каждого поля
  form: FormGroup<LoginForm> = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    rememberMe: [false]
  });

  onSubmit() {
    // form.value имеет тип { email: string; password: string; rememberMe: boolean }
    const { email, password } = this.form.value;
    // email — это string, не any!
  }
}

Это огромный шаг вперёд по сравнению с React Hook Form, где типизация требует дополнительных дженериков и иногда ведёт к сложным конструкциям.

Интерфейсы vs классы для моделей данных

В Angular-проектах часто встречается вопрос: использовать interface или class для описания моделей данных? Практическое правило:

// Используйте interface для чистых типов данных (DTO, API-ответы)
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// Используйте class, когда нужны методы или DI
class UserModel {
  constructor(
    public id: string,
    public name: string,
    public email: string
  ) {}

  get displayName(): string {
    return `${this.name} <${this.email}>`;
  }

  toDto(): Omit<UserModel, 'displayName' | 'toDto'> {
    return { id: this.id, name: this.name, email: this.email };
  }
}

Интерфейсы существуют только на этапе компиляции и полностью исчезают из JavaScript-бандла. Классы остаются в рантайме и могут использоваться как токены для DI.

Типизация RxJS-потоков

RxJS — неотъемлемая часть Angular. Правильная типизация Observable-потоков предотвращает целый класс ошибок:

import { Observable, Subject, BehaviorSubject } from 'rxjs';
import { map, filter, switchMap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  // BehaviorSubject хранит текущее значение и тип явно указан
  private notifications$ = new BehaviorSubject<Notification[]>([]);

  // Публичный Observable — только для чтения
  readonly notifications: Observable<Notification[]> =
    this.notifications$.asObservable();

  // Типизированный Subject для событий
  private destroy$ = new Subject<void>();

  addNotification(notification: Omit<Notification, 'id'>): void {
    const newNotification: Notification = {
      ...notification,
      id: crypto.randomUUID()
    };
    this.notifications$.next([
      ...this.notifications$.value,
      newNotification
    ]);
  }

  // Типизированный оператор map
  getUnreadCount(): Observable<number> {
    return this.notifications.pipe(
      map(notifications => notifications.filter(n => !n.read).length)
    );
  }
}

Типичные ошибки TypeScript при переходе с React

Ошибка 1: Использование any вместо дженериков

// Плохо
getUser(id: string): Observable<any> { ... }

// Хорошо
getUser(id: string): Observable<User> { ... }

Ошибка 2: Игнорирование strictPropertyInitialization

// Ошибка: Property 'user' has no initializer
@Component({ ... })
export class ProfileComponent {
  user: User; // TypeScript требует инициализации!
}

// Решение 1: Инициализация значением
user: User = { id: '', name: '', email: '', role: 'user', createdAt: new Date() };

// Решение 2: Опциональный тип
user: User | null = null;

// Решение 3: Definite assignment assertion (используйте осторожно)
user!: User; // Говорим TypeScript: "я гарантирую инициализацию"

Ошибка 3: Неправильная типизация EventEmitter

// Плохо — EventEmitter без типа
@Output() selected = new EventEmitter();

// Хорошо — TypeScript знает, что будет передано
@Output() selected = new EventEmitter<User>();

Ошибка 4: Потеря типа в async pipe

// В шаблоне async pipe возвращает T | null
// Нужно обрабатывать null-случай
@Component({
  template: `
    @if (user$ | async; as user) {
      <p>{{ user.name }}</p>  <!-- user здесь точно не null -->
    }
  `
})

Строгая типизация в Angular — это не бюрократия, а страховочная сетка. Когда проект вырастает до 100+ компонентов и 50+ сервисов, именно TypeScript не даёт изменениям в одном месте незаметно сломать другое.

Компоненты, шаблоны и привязка данных: от JSX к Angular-синтаксису

Компоненты, шаблоны и привязка данных: от JSX к Angular-синтаксису

Первое, с чем сталкивается React-разработчик в Angular — это шаблоны. Вместо JSX, где вся мощь JavaScript доступна прямо в разметке, Angular использует расширенный HTML с собственным синтаксисом привязки данных. Поначалу это кажется ограничением, но на практике оказывается, что Angular-шаблоны дают компилятору достаточно информации для статической проверки типов и оптимизации рендеринга — то, что JSX обеспечить не может.

Анатомия Angular-компонента

Каждый компонент в Angular состоит из трёх частей: класс (логика), шаблон (представление) и стили. Они могут быть в одном файле или разделены:

// user-card.component.ts
@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [DatePipe, RouterLink],
  // Инлайн-шаблон для простых компонентов
  template: `
    <article class="card">
      <h2>{{ user.name }}</h2>
      <time>{{ user.createdAt | date:'dd.MM.yyyy' }}</time>
      <a [routerLink]="['/users', user.id]">Профиль</a>
    </article>
  `,
  styles: [`
    .card { padding: 16px; border-radius: 8px; }
  `]
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;
}

Для сложных компонентов шаблон и стили выносятся в отдельные файлы (templateUrl, styleUrl). Angular CLI генерирует такую структуру по умолчанию.

Четыре типа привязки данных

Angular строит всю систему шаблонов вокруг четырёх типов привязки. Понять их — значит понять 80% синтаксиса шаблонов.

1. Интерполяция {{ expression }} — вывод значения в текст:

<h1>Привет, {{ user.name }}!</h1>
<p>Сумма: {{ price * quantity | currency:'RUB' }}</p>
<span>{{ isActive ? 'Активен' : 'Неактивен' }}</span>

Внутри {{ }} можно использовать любые выражения TypeScript, но не операторы (if, for, присваивание). Это намеренное ограничение — шаблон должен только отображать данные, не управлять ими.

2. Привязка свойства [property]="expression" — передача данных в DOM или компонент:

<!-- Привязка к DOM-свойству -->
<img [src]="user.avatarUrl" [alt]="user.name" />
<button [disabled]="isLoading">Сохранить</button>
<div [class.active]="isSelected">Элемент</div>
<div [style.color]="textColor">Текст</div>

<!-- Привязка к @Input компонента -->
<app-avatar [size]="64" [imageUrl]="user.avatarUrl" />

<!-- Привязка к атрибуту (когда нет DOM-свойства) -->
<td [attr.colspan]="columnSpan">Ячейка</td>

Разница между [property] и attr.property: привязка к свойству работает с DOM-объектом (быстро), привязка к атрибуту работает с HTML-атрибутом (нужна для SVG, ARIA и нестандартных атрибутов).

3. Привязка события (event)="handler($event)" — реакция на действия пользователя:

<button (click)="onSave()">Сохранить</button>
<input (input)="onSearch($event)" (keyup.enter)="onSubmit()" />
<form (ngSubmit)="onFormSubmit()">...</form>

<!-- Доступ к объекту события -->
<div (mousemove)="onMouseMove($event)">Область</div>

Angular поддерживает псевдособытия для клавиш: (keyup.enter), (keydown.escape), (keyup.ctrl.s). Это избавляет от проверок event.key === 'Enter' в обработчиках.

4. Двусторонняя привязка [(ngModel)]="property" — синхронизация данных в обе стороны:

<!-- Требует импорта FormsModule -->
<input [(ngModel)]="searchQuery" />

<!-- Эквивалентная запись без синтаксического сахара -->
<input [ngModel]="searchQuery" (ngModelChange)="searchQuery = $event" />

Новый синтаксис управляющих конструкций (Angular 17+)

До Angular 17 условный рендер и циклы реализовывались через структурные директивы *ngIf и *ngFor. Новый синтаксис значительно читабельнее и поддерживает дополнительные возможности:

<!-- Условный рендер -->
@if (user.isAdmin) {
  <admin-dashboard />
} @else if (user.isModerator) {
  <moderator-panel />
} @else {
  <user-home />
}

<!-- Цикл с отслеживанием и пустым состоянием -->
@for (product of products; track product.id) {
  <app-product-card [product]="product" />
} @empty {
  <p class="empty-state">Товары не найдены</p>
}

<!-- Switch -->
@switch (order.status) {
  @case ('pending') { <span class="badge yellow">Ожидает</span> }
  @case ('shipped') { <span class="badge blue">Отправлен</span> }
  @case ('delivered') { <span class="badge green">Доставлен</span> }
  @default { <span class="badge gray">Неизвестно</span> }
}

Директива track в @for — аналог key в React. Она говорит Angular, как идентифицировать элементы списка при перерендере. Всегда используйте уникальный идентификатор (track item.id), а не индекс (track $index) — это предотвращает проблемы с анимациями и состоянием вложенных компонентов.

Переменные шаблона и @let

Angular 17+ добавил директиву @let для создания локальных переменных в шаблоне:

<!-- @let для промежуточных вычислений -->
@let fullName = user.firstName + ' ' + user.lastName;
@let isEligible = user.age >= 18 && user.verified;

<h2>{{ fullName }}</h2>
@if (isEligible) {
  <app-premium-content />
}

<!-- Переменная шаблона через # — ссылка на элемент или компонент -->
<input #searchInput type="text" />
<button (click)="search(searchInput.value)">Найти</button>

<!-- Ссылка на дочерний компонент -->
<app-modal #modal />
<button (click)="modal.open()">Открыть</button>

Переменная шаблона #name — это прямая ссылка на DOM-элемент или экземпляр компонента. Аналог useRef в React, но без .current.

Проекция контента: ng-content

Проекция контента — это Angular-аналог children в React. Она позволяет компоненту принимать произвольный HTML-контент от родителя:

// Компонент-обёртка
@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <div class="card-header">
        <ng-content select="[card-title]" />
      </div>
      <div class="card-body">
        <ng-content />
      </div>
      <div class="card-footer">
        <ng-content select="[card-actions]" />
      </div>
    </div>
  `
})
export class CardComponent {}
<!-- Использование с именованными слотами -->
<app-card>
  <h2 card-title>Заголовок карточки</h2>

  <p>Основное содержимое карточки</p>
  <p>Любой HTML здесь</p>

  <div card-actions>
    <button (click)="save()">Сохранить</button>
    <button (click)="cancel()">Отмена</button>
  </div>
</app-card>

Атрибут select в ng-content работает как CSS-селектор: select="[card-title]" выбирает элементы с атрибутом card-title, select=".header" — с классом header, select="app-button" — компоненты с селектором app-button.

Как отмечают в habr.com, ng-content позволяет передавать статичный контент без биндинга, что работает быстрее, чем передача данных через @Input — Angular не создаёт точки отслеживания изменений для статичного контента.

Пайпы в шаблонах: трансформация данных

Пайпы (pipes) — это функции трансформации данных, применяемые прямо в шаблоне через оператор |. Аналогов в React нет — там трансформации выполняются в JavaScript-коде компонента.

<!-- Встроенные пайпы -->
{{ price | currency:'RUB':'symbol':'1.2-2' }}
<!-- Результат: ₽1 234,56 -->

{{ date | date:'dd MMMM yyyy, HH:mm' }}
<!-- Результат: 15 апреля 2025, 14:30 -->

{{ longText | slice:0:100 }}{{ longText.length > 100 ? '...' : '' }}

{{ user | json }}
<!-- Для отладки: выводит объект как JSON -->

{{ items | async }}
<!-- Подписывается на Observable и автоматически отписывается -->

<!-- Цепочка пайпов -->
{{ name | uppercase | slice:0:10 }}

Пайп async заслуживает отдельного внимания. Он подписывается на Observable или Promise, отображает последнее значение и автоматически отписывается при уничтожении компонента. Это предотвращает утечки памяти:

@Component({
  template: `
    @if (users$ | async; as users) {
      @for (user of users; track user.id) {
        <app-user-card [user]="user" />
      }
    } @else {
      <app-loading-spinner />
    }
  `
})
export class UserListComponent {
  users$ = inject(UserService).getUsers(); // Observable<User[]>
}

Переписываем React-компонент в Angular

Практический пример: переносим типичный React-компонент поиска с фильтрацией:

// React
function SearchableList({ items }: { items: Product[] }) {
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState<'name' | 'price'>('name');

  const filtered = useMemo(() =>
    items
      .filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
      .sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1),
    [items, query, sortBy]
  );

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <select value={sortBy} onChange={e => setSortBy(e.target.value as any)}>
        <option value="name">По названию</option>
        <option value="price">По цене</option>
      </select>
      <ul>
        {filtered.map(p => <li key={p.id}>{p.name} — {p.price}₽</li>)}
      </ul>
    </div>
  );
}
// Angular
@Component({
  selector: 'app-searchable-list',
  standalone: true,
  imports: [FormsModule, CurrencyPipe],
  template: `
    <div>
      <input [(ngModel)]="query" placeholder="Поиск..." />
      <select [(ngModel)]="sortBy">
        <option value="name">По названию</option>
        <option value="price">По цене</option>
      </select>
      <ul>
        @for (product of filteredProducts(); track product.id) {
          <li>{{ product.name }} — {{ product.price | currency:'RUB' }}</li>
        }
      </ul>
    </div>
  `
})
export class SearchableListComponent {
  @Input({ required: true }) items: Product[] = [];

  query = '';
  sortBy: 'name' | 'price' = 'name';

  // computed() — аналог useMemo, автоматически пересчитывается при изменении зависимостей
  filteredProducts = computed(() =>
    this.items
      .filter(p => p.name.toLowerCase().includes(this.query.toLowerCase()))
      .sort((a, b) => a[this.sortBy] > b[this.sortBy] ? 1 : -1)
  );
}

Ключевые наблюдения при переносе:

  • useState → свойство класса (или signal() для реактивности)
  • useMemocomputed()
  • onChange[(ngModel)] или (input)="handler()"
  • keytrack
  • Форматирование в JSX → пайпы в шаблоне

Dependency Injection и сервисы: инверсия зависимостей вместо prop drilling и Context

Dependency Injection и сервисы: инверсия зависимостей вместо prop drilling и Context

Представьте компонент глубоко в дереве, которому нужен доступ к данным пользователя. В React вы либо пробрасываете props через пять промежуточных компонентов, либо создаёте Context и оборачиваете дерево провайдером. Оба подхода работают, но оба имеют цену: prop drilling засоряет интерфейсы промежуточных компонентов, а Context не даёт контроля над областью видимости и временем жизни данных. Angular решает эту проблему через встроенную систему Dependency Injection — и решает её элегантнее.

Что такое DI и зачем он нужен

Dependency Injection (инверсия зависимостей) — это паттерн, при котором объект не создаёт свои зависимости сам, а получает их извне. Вместо new UserService() внутри компонента Angular сам создаёт нужный экземпляр и передаёт его.

Почему это важно? Представьте, что UserService зависит от HttpClient, который зависит от HttpBackend, который зависит от конфигурации. Без DI вам пришлось бы вручную создавать всю цепочку зависимостей. С DI вы просто объявляете: «мне нужен UserService» — и Angular разбирается с остальным.

Для тестирования это бесценно: вместо реального UserService можно подставить мок, не меняя код компонента.

Иерархия инжекторов: главное отличие от Context

В React Context — это глобальный провайдер. Вы либо предоставляете значение для всего дерева, либо нет. Angular устроен иначе: инжекторы образуют иерархию, и каждый уровень может переопределять зависимости для своего поддерева.

Root Injector (providedIn: 'root')
  └── Platform Injector
        └── Environment Injector (NgModule или Route)
              └── Element Injector (Component)
                    └── Element Injector (Child Component)

Когда компонент запрашивает зависимость, Angular ищет её снизу вверх по иерархии. Первый инжектор, который может предоставить зависимость, выигрывает.

// Глобальный синглтон — один экземпляр на всё приложение
@Injectable({ providedIn: 'root' })
export class AuthService { ... }

// Синглтон на уровне маршрута — новый экземпляр для каждого lazy-loaded модуля
@Injectable({ providedIn: 'any' })
export class CartService { ... }

// Новый экземпляр для каждого компонента и его потомков
@Component({
  providers: [LocalStateService]  // Изолированный экземпляр
})
export class ProductPageComponent { ... }

Практический пример: компонент модального окна может иметь свой изолированный FormService, который уничтожается вместе с модалкой. Другие компоненты не видят этот сервис и не могут случайно его изменить.

Создание и использование сервисов

Сервис в Angular — это обычный TypeScript-класс с декоратором @Injectable:

// user.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';

export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);

  // Внутреннее состояние через Signals
  private _users = signal<User[]>([]);
  private _loading = signal(false);
  private _error = signal<string | null>(null);

  // Публичные readonly-сигналы
  readonly users = this._users.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();

  // Вычисляемые значения
  readonly adminCount = computed(() =>
    this._users().filter(u => u.role === 'admin').length
  );

  loadUsers(): Observable<User[]> {
    this._loading.set(true);
    this._error.set(null);

    return this.http.get<User[]>('/api/users').pipe(
      tap({
        next: users => {
          this._users.set(users);
          this._loading.set(false);
        },
        error: err => {
          this._error.set(err.message);
          this._loading.set(false);
        }
      })
    );
  }

  getUserById(id: string): User | undefined {
    return this._users().find(u => u.id === id);
  }

  updateUser(id: string, updates: Partial<User>): void {
    this._users.update(users =>
      users.map(u => u.id === id ? { ...u, ...updates } : u)
    );
  }
}

Использование в компоненте через функцию inject() — современный способ, рекомендованный в Angular 14+:

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @if (userService.loading()) {
      <app-spinner />
    } @else if (userService.error()) {
      <app-error [message]="userService.error()!" />
    } @else {
      <p>Администраторов: {{ userService.adminCount() }}</p>
      @for (user of userService.users(); track user.id) {
        <app-user-card [user]="user" />
      }
    }
  `
})
export class UserListComponent implements OnInit {
  protected userService = inject(UserService);

  ngOnInit() {
    this.userService.loadUsers().subscribe();
  }
}

inject() vs конструктор: два способа получить зависимость

Angular поддерживает два синтаксиса для получения зависимостей:

// Способ 1: Через конструктор (классический, работает везде)
@Component({ ... })
export class MyComponent {
  constructor(
    private userService: UserService,
    private router: Router,
    private fb: FormBuilder
  ) {}
}

// Способ 2: Через inject() (современный, Angular 14+)
@Component({ ... })
export class MyComponent {
  private userService = inject(UserService);
  private router = inject(Router);
  private fb = inject(FormBuilder);
}

inject() предпочтительнее по нескольким причинам: код чище, зависимости можно использовать при инициализации свойств класса, и функцию можно вызывать в вспомогательных функциях (не только в конструкторе).

Важное ограничение: inject() работает только в injection context — во время конструирования класса или в функциях, вызываемых из конструктора. Нельзя вызвать inject() в методе ngOnInit или в обработчике события.

Токены и интерфейсы: продвинутый DI

Иногда нужно предоставить не класс, а примитивное значение или объект, реализующий интерфейс. Для этого используются InjectionToken:

// Создание токена для конфигурации
import { InjectionToken } from '@angular/core';

export interface AppConfig {
  apiUrl: string;
  maxRetries: number;
  debugMode: boolean;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config', {
  providedIn: 'root',
  factory: () => ({
    apiUrl: 'https://api.example.com',
    maxRetries: 3,
    debugMode: false
  })
});

// Использование в компоненте или сервисе
@Injectable({ providedIn: 'root' })
export class ApiService {
  private config = inject(APP_CONFIG);

  getApiUrl(): string {
    return this.config.apiUrl;
  }
}

// Переопределение в тестах
TestBed.configureTestingModule({
  providers: [
    {
      provide: APP_CONFIG,
      useValue: { apiUrl: 'http://localhost:3000', maxRetries: 1, debugMode: true }
    }
  ]
});

Паттерн: сервис как единый источник истины

Лучшая практика Angular — держать состояние в сервисах, а компоненты делать «тупыми» (presentational). Это прямой аналог паттерна из Redux, но без boilerplate:

// Сервис управляет состоянием корзины
@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);

  readonly cartItems = this.items.asReadonly();
  readonly totalPrice = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  readonly itemCount = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );

  addItem(product: Product): void {
    this.items.update(items => {
      const existing = items.find(i => i.productId === product.id);
      if (existing) {
        return items.map(i =>
          i.productId === product.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...items, { productId: product.id, name: product.name, price: product.price, quantity: 1 }];
    });
  }

  removeItem(productId: string): void {
    this.items.update(items => items.filter(i => i.productId !== productId));
  }

  clearCart(): void {
    this.items.set([]);
  }
}

Любой компонент в приложении может инжектировать CartService и получить актуальное состояние корзины — без prop drilling, без Context, без глобального стора.

Сравнение с React-подходами

Задача React Angular
Глобальное состояние Context + useReducer / Zustand Сервис с providedIn: 'root'
Локальное состояние компонента useState / useReducer Сигналы в компоненте
Изолированное состояние поддерева Context с ограниченным провайдером Сервис в providers компонента
Зависимость от конфигурации Переменные окружения / Context InjectionToken
Мокирование в тестах Jest mock / MSW TestBed.overrideProvider()

Типичные ошибки при работе с DI

Ошибка 1: Создание сервиса через new

// Плохо — обходит DI, нельзя замокать в тестах
export class MyComponent {
  private service = new UserService(); // ❌
}

// Хорошо
export class MyComponent {
  private service = inject(UserService); // ✅
}

Ошибка 2: Хранение состояния в компоненте вместо сервиса

Если несколько компонентов нуждаются в одних данных — данные должны жить в сервисе, а не дублироваться в каждом компоненте.

Ошибка 3: Подписка на Observable в сервисе без отписки

// Плохо — утечка памяти
@Injectable({ providedIn: 'root' })
export class BadService {
  constructor() {
    interval(1000).subscribe(v => console.log(v)); // Никогда не отпишется!
  }
}

// Хорошо — используйте takeUntilDestroyed
@Injectable({ providedIn: 'root' })
export class GoodService {
  private destroyRef = inject(DestroyRef);

  constructor() {
    interval(1000).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(v => console.log(v));
  }
}

DI в Angular — это не просто способ передать зависимости. Это архитектурный инструмент, который определяет, как организован весь код приложения. Правильное использование DI делает код тестируемым, переиспользуемым и понятным — независимо от размера команды и проекта.

RxJS и реактивное программирование: Observable, операторы и паттерны вместо Promise

RxJS и реактивное программирование: Observable, операторы и паттерны вместо Promise

Если Dependency Injection — это архитектурный хребет Angular, то RxJS — его нервная система. Каждый HTTP-запрос, каждое событие формы, каждое изменение маршрута в Angular возвращает Observable. Игнорировать RxJS в Angular — всё равно что игнорировать Promise в JavaScript. Но для React-разработчика, привыкшего к async/await и useEffect, реактивное программирование поначалу кажется избыточным усложнением. Разберём, почему это не так.

Observable vs Promise: принципиальная разница

Promise — это контейнер для одного будущего значения. Он либо выполняется, либо отклоняется. После завершения он неизменен.

Observable — это поток нуля или более значений во времени. Он может:

  • Вернуть одно значение (как Promise)
  • Вернуть много значений (WebSocket, события мыши)
  • Не вернуть ничего (пустой поток)
  • Быть отменён (в отличие от Promise!)
  • Быть синхронным или асинхронным
import { Observable, of, from, interval, fromEvent } from 'rxjs';

// Observable с одним значением (аналог Promise)
const single$ = of(42);

// Observable из массива (синхронный!)
const array$ = of(1, 2, 3, 4, 5);

// Observable из Promise
const fromPromise$ = from(fetch('/api/users'));

// Бесконечный поток — каждую секунду
const timer$ = interval(1000);

// События DOM
const clicks$ = fromEvent(document, 'click');

Символ $ в конце имени переменной — соглашение об именовании Observable-потоков в Angular-сообществе. Это не обязательно, но широко принято.

Операторы: трансформация потоков

Мощь RxJS — в операторах. Они трансформируют, фильтруют, комбинируют потоки. Операторы применяются через метод pipe():

import { of, interval } from 'rxjs';
import { map, filter, take, debounceTime, switchMap, catchError } from 'rxjs/operators';

// map — трансформация каждого значения (аналог Array.map)
const doubled$ = of(1, 2, 3).pipe(
  map(n => n * 2)
); // Эмитирует: 2, 4, 6

// filter — фильтрация (аналог Array.filter)
const evens$ = of(1, 2, 3, 4, 5).pipe(
  filter(n => n % 2 === 0)
); // Эмитирует: 2, 4

// take — взять первые N значений и завершить поток
const firstThree$ = interval(1000).pipe(
  take(3)
); // Эмитирует: 0, 1, 2 — затем завершается

// debounceTime — ждать паузы перед эмитом (для поиска)
const search$ = searchInput$.pipe(
  debounceTime(300),
  filter(query => query.length >= 2)
);

Ключевые операторы для Angular-разработки

Три оператора, которые вы будете использовать ежедневно: switchMap, mergeMap и concatMap. Все три принимают значение из потока и возвращают новый Observable, но по-разному обрабатывают параллельные запросы.

// switchMap — отменяет предыдущий запрос при новом значении
// Идеально для поиска: пользователь печатает быстро, нужен только последний запрос
const searchResults$ = searchQuery$.pipe(
  debounceTime(300),
  switchMap(query => this.http.get<Product[]>(`/api/search?q=${query}`))
);

// mergeMap — выполняет все запросы параллельно
// Для независимых операций, где порядок не важен
const allData$ = userIds$.pipe(
  mergeMap(id => this.http.get<User>(`/api/users/${id}`))
);

// concatMap — выполняет запросы последовательно
// Для операций, где важен порядок (например, загрузка файлов)
const sequential$ = fileQueue$.pipe(
  concatMap(file => this.uploadService.upload(file))
);

Типичная ошибка React-разработчика — использовать mergeMap везде. Для поиска это катастрофа: если пользователь быстро вводит «ang», «angu», «angul», придут три ответа в непредсказуемом порядке. switchMap отменяет предыдущий запрос, оставляя только актуальный.

Реальный пример: поиск с автодополнением

@Component({
  selector: 'app-search',
  standalone: true,
  imports: [ReactiveFormsModule, AsyncPipe],
  template: `
    <input [formControl]="searchControl" placeholder="Поиск..." />

    @if (loading()) {
      <app-spinner />
    }

    @for (result of results$ | async; track result.id) {
      <div class="result" (click)="select(result)">
        {{ result.name }}
      </div>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);

  searchControl = new FormControl('');
  loading = signal(false);

  results$ = this.searchControl.valueChanges.pipe(
    debounceTime(300),           // Ждём паузу в печати
    distinctUntilChanged(),      // Игнорируем одинаковые значения
    filter(query => (query?.length ?? 0) >= 2), // Минимум 2 символа
    tap(() => this.loading.set(true)),
    switchMap(query =>           // Отменяем предыдущий запрос
      this.http.get<SearchResult[]>(`/api/search?q=${query}`).pipe(
        catchError(() => of([]))  // При ошибке — пустой массив
      )
    ),
    tap(() => this.loading.set(false))
  );

  select(result: SearchResult) {
    console.log('Выбрано:', result);
  }
}

Обратите внимание: весь поток данных описан декларативно в results$. Нет useEffect, нет ручного управления подписками, нет AbortController для отмены запросов — switchMap делает это автоматически.

Управление подписками: предотвращение утечек памяти

В React useEffect возвращает функцию очистки. В Angular нужно явно управлять подписками. Есть несколько подходов:

// Подход 1: takeUntilDestroyed (рекомендуется в Angular 16+)
@Component({ ... })
export class MyComponent {
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    interval(1000).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(v => console.log(v));
    // Автоматически отписывается при уничтожении компонента
  }
}

// Подход 2: async pipe в шаблоне (самый простой)
@Component({
  template: `{{ data$ | async }}`
  // async pipe сам управляет подпиской
})

// Подход 3: Subscription + ngOnDestroy (классический)
@Component({ ... })
export class MyComponent implements OnDestroy {
  private subscription = new Subscription();

  ngOnInit() {
    this.subscription.add(
      interval(1000).subscribe(v => console.log(v))
    );
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

takeUntilDestroyed — предпочтительный современный подход. Он работает как с компонентами, так и с сервисами, и не требует реализации OnDestroy.

Subject: мост между императивным и реактивным кодом

Subject — это Observable, в который можно вручную отправлять значения. Это мост между обычным JavaScript-кодом и реактивными потоками:

import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';

// Subject — только новые значения
const events$ = new Subject<string>();
events$.next('click');  // Отправить значение
events$.subscribe(e => console.log(e));

// BehaviorSubject — хранит последнее значение, новые подписчики получают его сразу
const currentUser$ = new BehaviorSubject<User | null>(null);
currentUser$.next(user);  // Обновить
currentUser$.value;       // Получить текущее значение синхронно

// ReplaySubject — буферизует N последних значений
const history$ = new ReplaySubject<Action>(10);  // Хранит последние 10 действий

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

Комбинирование потоков

import { combineLatest, forkJoin, merge, zip } from 'rxjs';

// combineLatest — эмитирует при изменении любого потока
// Используется для зависимых фильтров
const filteredData$ = combineLatest([
  this.searchQuery$,
  this.selectedCategory$,
  this.sortOrder$
]).pipe(
  switchMap(([query, category, sort]) =>
    this.api.getProducts({ query, category, sort })
  )
);

// forkJoin — ждёт завершения всех потоков (аналог Promise.all)
// Для параллельной загрузки независимых данных
const pageData$ = forkJoin({
  user: this.userService.getCurrentUser(),
  settings: this.settingsService.getSettings(),
  notifications: this.notificationService.getUnread()
});

// merge — объединяет несколько потоков в один
const allEvents$ = merge(
  fromEvent(document, 'click'),
  fromEvent(document, 'keydown')
);

RxJS vs async/await: когда что использовать

Не всё должно быть Observable. Вот практическое правило:

Сценарий Рекомендация
Одиночный HTTP-запрос без отмены async/await или Observable — оба OK
Поиск с debounce и отменой Observable + switchMap
WebSocket / Server-Sent Events Observable
Комбинирование нескольких потоков Observable + combineLatest
Простая загрузка данных в ngOnInit async/await через firstValueFrom()
Реактивные формы Observable (встроено в Angular)
// Конвертация Observable в Promise для async/await
import { firstValueFrom, lastValueFrom } from 'rxjs';

async ngOnInit() {
  // firstValueFrom — берёт первое значение и завершает подписку
  const user = await firstValueFrom(this.userService.getUser('123'));
  console.log(user.name);
}

RxJS — это инвестиция. Первые две недели он кажется сложным. Потом вы начинаете видеть, как декларативные потоки делают код чище и предсказуемее, чем цепочки useEffect с флагами isMounted и AbortController.

Формы в Angular: Template-driven и Reactive Forms против React Hook Form

Формы в Angular: Template-driven и Reactive Forms против React Hook Form

Формы — это то место, где Angular действительно блистает по сравнению с React. Пока в React-экосистеме идут споры между Formik, React Hook Form и нативными решениями, Angular предлагает два встроенных подхода с чёткими сценариями применения. Понять разницу между ними — значит сделать правильный архитектурный выбор с первого раза.

Два подхода: когда какой выбирать

Template-driven Forms — логика формы живёт в шаблоне. Подход декларативный, похож на Vue.js или AngularJS. Подходит для простых форм: логин, подписка на рассылку, простой поиск.

Reactive Forms — логика формы живёт в TypeScript-классе. Подход императивный, полностью типизированный, тестируемый. Подходит для сложных форм: многошаговые визарды, динамические поля, сложная валидация.

В React Hook Form вся логика тоже живёт в JavaScript, но без встроенной типизации состояния формы и без иерархии форм. Angular Reactive Forms предоставляют это из коробки.

Template-driven Forms: быстрый старт

// Требует импорта FormsModule
@Component({
  selector: 'app-login',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
      <div>
        <label for="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          [(ngModel)]="formData.email"
          required
          email
          #emailField="ngModel"
        />
        @if (emailField.invalid && emailField.touched) {
          @if (emailField.errors?.['required']) {
            <span class="error">Email обязателен</span>
          }
          @if (emailField.errors?.['email']) {
            <span class="error">Некорректный email</span>
          }
        }
      </div>

      <div>
        <label for="password">Пароль</label>
        <input
          id="password"
          name="password"
          type="password"
          [(ngModel)]="formData.password"
          required
          minlength="8"
          #passwordField="ngModel"
        />
        @if (passwordField.invalid && passwordField.touched) {
          <span class="error">Минимум 8 символов</span>
        }
      </div>

      <button type="submit" [disabled]="loginForm.invalid">Войти</button>
    </form>
  `
})
export class LoginComponent {
  formData = { email: '', password: '' };

  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log(form.value);
    }
  }
}

Template-driven Forms используют директивы (required, email, minlength) прямо в HTML — это те же HTML5-атрибуты валидации, но Angular перехватывает их и добавляет реактивное поведение.

Reactive Forms: полный контроль

Reactive Forms — это то, что вы будете использовать в 90% реальных проектов. Они дают полный контроль над состоянием формы, валидацией и динамическими полями:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';

// Кастомный валидатор
function passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
  const password = control.get('password');
  const confirm = control.get('confirmPassword');

  if (password && confirm && password.value !== confirm.value) {
    return { passwordMismatch: true };
  }
  return null;
}

@Component({
  selector: 'app-register',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <div formGroupName="personal">
        <input formControlName="firstName" placeholder="Имя" />
        @if (getError('personal.firstName', 'required')) {
          <span class="error">Имя обязательно</span>
        }

        <input formControlName="lastName" placeholder="Фамилия" />
      </div>

      <input formControlName="email" type="email" placeholder="Email" />
      @if (getError('email', 'email')) {
        <span class="error">Некорректный email</span>
      }

      <div formGroupName="passwords">
        <input formControlName="password" type="password" placeholder="Пароль" />
        <input formControlName="confirmPassword" type="password" placeholder="Подтвердите пароль" />
        @if (form.get('passwords')?.errors?.['passwordMismatch']) {
          <span class="error">Пароли не совпадают</span>
        }
      </div>

      <button type="submit" [disabled]="form.invalid || isSubmitting">
        {{ isSubmitting ? 'Регистрация...' : 'Зарегистрироваться' }}
      </button>
    </form>
  `
})
export class RegisterComponent implements OnInit {
  private fb = inject(FormBuilder);

  isSubmitting = false;

  form: FormGroup = this.fb.group({
    personal: this.fb.group({
      firstName: ['', [Validators.required, Validators.minLength(2)]],
      lastName: ['', Validators.required]
    }),
    email: ['', [Validators.required, Validators.email]],
    passwords: this.fb.group({
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required]
    }, { validators: passwordMatchValidator })
  });

  // Удобный метод для получения ошибок
  getError(path: string, errorKey: string): boolean {
    const control = this.form.get(path);
    return !!(control?.errors?.[errorKey] && control.touched);
  }

  onSubmit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched(); // Показать все ошибки
      return;
    }

    this.isSubmitting = true;
    // Отправка данных...
  }
}

Динамические формы: FormArray

Одна из сильнейших возможностей Reactive Forms — FormArray для динамических списков полей. В React Hook Form это требует useFieldArray и дополнительной настройки. В Angular это встроено:

@Component({
  selector: 'app-skills-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <div formArrayName="skills">
        @for (skill of skillsArray.controls; track $index; let i = $index) {
          <div [formGroupName]="i" class="skill-row">
            <input formControlName="name" placeholder="Навык" />
            <select formControlName="level">
              <option value="beginner">Начинающий</option>
              <option value="intermediate">Средний</option>
              <option value="expert">Эксперт</option>
            </select>
            <button type="button" (click)="removeSkill(i)">Удалить</button>
          </div>
        }
      </div>
      <button type="button" (click)="addSkill()">Добавить навык</button>
    </form>
  `
})
export class SkillsFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    skills: this.fb.array([this.createSkill()])
  });

  get skillsArray() {
    return this.form.get('skills') as FormArray;
  }

  createSkill() {
    return this.fb.group({
      name: ['', Validators.required],
      level: ['beginner', Validators.required]
    });
  }

  addSkill() {
    this.skillsArray.push(this.createSkill());
  }

  removeSkill(index: number) {
    this.skillsArray.removeAt(index);
  }
}

Реактивное отслеживание изменений формы

Reactive Forms предоставляют Observable-потоки для отслеживания изменений — это то, чего нет в React Hook Form без дополнительных хуков:

ngOnInit() {
  // Отслеживание изменений конкретного поля
  this.form.get('email')!.valueChanges.pipe(
    debounceTime(500),
    distinctUntilChanged(),
    switchMap(email => this.authService.checkEmailAvailability(email)),
    takeUntilDestroyed(this.destroyRef)
  ).subscribe(isAvailable => {
    if (!isAvailable) {
      this.form.get('email')!.setErrors({ emailTaken: true });
    }
  });

  // Отслеживание статуса всей формы
  this.form.statusChanges.pipe(
    takeUntilDestroyed(this.destroyRef)
  ).subscribe(status => {
    console.log('Статус формы:', status); // 'VALID', 'INVALID', 'PENDING'
  });

  // Отслеживание всех изменений формы
  this.form.valueChanges.pipe(
    debounceTime(1000),
    takeUntilDestroyed(this.destroyRef)
  ).subscribe(value => {
    localStorage.setItem('form-draft', JSON.stringify(value));
  });
}

Кастомные валидаторы и асинхронная валидация

// Синхронный кастомный валидатор
function russianPhoneValidator(control: AbstractControl): ValidationErrors | null {
  const phone = control.value as string;
  const pattern = /^\+7\d{10}$/;
  return pattern.test(phone) ? null : { invalidPhone: true };
}

// Асинхронный валидатор (проверка на сервере)
@Injectable({ providedIn: 'root' })
class UsernameValidator {
  private http = inject(HttpClient);

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    return this.http.get<{ available: boolean }>(
      `/api/check-username?username=${control.value}`
    ).pipe(
      map(res => res.available ? null : { usernameTaken: true }),
      catchError(() => of(null)),
      debounceTime(300)
    );
  }
}

// Использование в форме
form = this.fb.group({
  phone: ['', [Validators.required, russianPhoneValidator]],
  username: ['', Validators.required, this.usernameValidator.validate.bind(this.usernameValidator)]
  //                                   ↑ Третий аргумент — асинхронные валидаторы
});

Сравнение с React Hook Form

Возможность Angular Reactive Forms React Hook Form
Типизация Полная (Angular 14+) Через дженерики
Динамические поля FormArray встроен useFieldArray
Асинхронная валидация Встроена Встроена
Отслеживание изменений valueChanges Observable watch()
Вложенные формы FormGroup в FormGroup Вложенные объекты
Производительность Хорошая с OnPush Отличная (uncontrolled)
Зависимости Нет (встроено) Внешняя библиотека
Интеграция с RxJS Нативная Через адаптеры

Ключевое преимущество Angular Reactive Forms — нативная интеграция с RxJS. valueChanges и statusChanges — это настоящие Observable, которые можно комбинировать с любыми другими потоками. В React Hook Form watch() возвращает значение, а не поток — для реактивного поведения нужны дополнительные усилия.

Типичные ошибки при работе с формами

Ошибка 1: Доступ к значению формы без проверки валидности

// Плохо
onSubmit() {
  const data = this.form.value; // Может содержать null для disabled полей!
}

// Хорошо
onSubmit() {
  if (this.form.invalid) return;
  const data = this.form.getRawValue(); // Включает disabled поля
}

Ошибка 2: Мутация значения FormControl напрямую

// Плохо — Angular не узнает об изменении
this.form.get('name')!.value = 'Новое имя'; // ❌

// Хорошо
this.form.get('name')!.setValue('Новое имя'); // ✅
this.form.patchValue({ name: 'Новое имя' });  // ✅ (обновляет только указанные поля)

Ошибка 3: Забыть markAllAsTouched() при сабмите

Ошибки валидации показываются только для touched полей. Если пользователь нажал «Отправить», не трогая поля, ошибки не появятся. Вызов this.form.markAllAsTouched() перед проверкой валидности решает это.

Маршрутизация, lazy loading и route guards: Angular Router vs React Router

Маршрутизация, lazy loading и route guards: Angular Router vs React Router

Маршрутизация в Angular — это не просто «какой компонент показать по какому URL». Angular Router — это полноценная подсистема с иерархическими маршрутами, ленивой загрузкой модулей, защитой маршрутов, резолверами данных и стратегиями предзагрузки. Для React-разработчика, привыкшего к React Router v6, здесь много знакомых концепций — но реализованных значительно мощнее.

Базовая конфигурация маршрутов

В современном Angular (17+) маршруты конфигурируются в файле app.routes.ts:

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    redirectTo: '/dashboard',
    pathMatch: 'full'
  },
  {
    path: 'dashboard',
    component: DashboardComponent,
    title: 'Дашборд'  // Автоматически устанавливает <title> страницы
  },
  {
    path: 'users',
    children: [
      {
        path: '',
        component: UserListComponent
      },
      {
        path: ':id',
        component: UserDetailComponent
      },
      {
        path: ':id/edit',
        component: UserEditComponent
      }
    ]
  },
  {
    path: '**',  // Wildcard — 404
    component: NotFoundComponent
  }
];

// main.ts — подключение маршрутов
bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes)]
});

Сравнение с React Router v6:

// React Router v6
const router = createBrowserRouter([
  { path: '/', element: <Navigate to="/dashboard" /> },
  { path: '/dashboard', element: <Dashboard /> },
  {
    path: '/users',
    children: [
      { index: true, element: <UserList /> },
      { path: ':id', element: <UserDetail /> }
    ]
  }
]);

Структура похожа, но Angular Router добавляет title, data, resolve, canActivate и другие поля прямо в конфигурацию маршрута.

Навигация в шаблонах и коде

<!-- Директива routerLink — аналог <Link> в React Router -->
<a routerLink="/dashboard">Дашборд</a>
<a [routerLink]="['/users', userId]">Профиль</a>
<a [routerLink]="['/users', userId, 'edit']" [queryParams]="{ tab: 'settings' }">
  Настройки
</a>

<!-- routerLinkActive добавляет CSS-класс для активного маршрута -->
<nav>
  <a routerLink="/dashboard" routerLinkActive="active">Дашборд</a>
  <a routerLink="/users" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
    Пользователи
  </a>
</nav>
// Программная навигация
@Component({ ... })
export class MyComponent {
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  navigateToUser(id: string) {
    this.router.navigate(['/users', id]);
  }

  navigateWithQuery() {
    this.router.navigate(['/search'], {
      queryParams: { q: 'angular', page: 1 }
    });
  }

  // Относительная навигация
  goBack() {
    this.router.navigate(['..'], { relativeTo: this.route });
  }
}

Получение параметров маршрута

@Component({ ... })
export class UserDetailComponent {
  private route = inject(ActivatedRoute);
  private userService = inject(UserService);

  // Способ 1: Через Observable (реактивный — обновляется при изменении параметра)
  user$ = this.route.paramMap.pipe(
    map(params => params.get('id')!),
    switchMap(id => this.userService.getUser(id))
  );

  // Способ 2: Через snapshot (только текущее значение, не обновляется)
  userId = this.route.snapshot.paramMap.get('id');

  // Query params
  searchQuery$ = this.route.queryParamMap.pipe(
    map(params => params.get('q') ?? '')
  );
}

Важное отличие от React Router: Angular Router по умолчанию переиспользует компонент при изменении параметров маршрута (например, переход с /users/1 на /users/2). Поэтому нужно использовать Observable-подход, а не читать параметры только в ngOnInit.

Lazy Loading: загрузка по требованию

Lazy loading — загрузка кода маршрута только тогда, когда пользователь переходит на него. Это критически важно для производительности больших приложений. В Angular это реализуется через динамический импорт:

// app.routes.ts — lazy loading с standalone компонентами
export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () =>
      import('./features/admin/admin.component').then(m => m.AdminComponent)
  },
  {
    path: 'shop',
    loadChildren: () =>
      import('./features/shop/shop.routes').then(m => m.shopRoutes)
  }
];

// shop.routes.ts — дочерние маршруты
export const shopRoutes: Routes = [
  { path: '', component: ShopHomeComponent },
  { path: 'products', component: ProductListComponent },
  {
    path: 'cart',
    loadComponent: () =>
      import('./cart/cart.component').then(m => m.CartComponent)
  }
];

Angular CLI автоматически создаёт отдельные чанки для lazy-loaded маршрутов. В angular.json можно настроить бюджеты размера бандла:

{
  "budgets": [
    { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" },
    { "type": "anyComponentStyle", "maximumWarning": "2kb" }
  ]
}

Route Guards: защита маршрутов

Guards — это функции или классы, которые решают, можно ли перейти на маршрут. Аналог в React Router — loader с редиректом или компонент-обёртка <PrivateRoute>.

// Функциональный guard (Angular 15+, рекомендуется)
export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  // Сохраняем URL для редиректа после логина
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url }
  });
};

// Guard с проверкой роли
export const adminGuard: CanActivateFn = () => {
  const authService = inject(AuthService);
  const router = inject(Router);

  return authService.currentUser$.pipe(
    map(user => {
      if (user?.role === 'admin') return true;
      return router.createUrlTree(['/forbidden']);
    })
  );
};

// Подключение в маршрутах
export const routes: Routes = [
  {
    path: 'profile',
    component: ProfileComponent,
    canActivate: [authGuard]
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
    canActivate: [authGuard, adminGuard]
  }
];

Angular поддерживает несколько типов guards:

Guard Назначение Аналог в React
canActivate Разрешить/запретить вход на маршрут <PrivateRoute>
canActivateChild Защита дочерних маршрутов
canDeactivate Предотвратить уход со страницы useBlocker
canMatch Выбор маршрута по условию
resolve Загрузка данных перед рендером loader в React Router v6

Resolve: данные до рендера компонента

Resolve guard загружает данные до того, как компонент отрендерится. Это устраняет мерцание загрузчика:

// user.resolver.ts
export const userResolver: ResolveFn<User> = (route) => {
  const userService = inject(UserService);
  const router = inject(Router);
  const id = route.paramMap.get('id')!;

  return userService.getUser(id).pipe(
    catchError(() => {
      router.navigate(['/not-found']);
      return EMPTY;
    })
  );
};

// Подключение в маршруте
{
  path: 'users/:id',
  component: UserDetailComponent,
  resolve: { user: userResolver }
}

// Получение данных в компоненте
@Component({ ... })
export class UserDetailComponent {
  private route = inject(ActivatedRoute);

  // Данные уже загружены, компонент рендерится сразу
  user = this.route.snapshot.data['user'] as User;
}

Стратегии предзагрузки

После первоначальной загрузки Angular может предзагружать lazy-loaded маршруты в фоне:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withPreloading(PreloadAllModules)  // Предзагрузить все lazy маршруты
      // или:
      // withPreloading(NoPreloading)    // Не предзагружать (по умолчанию)
    )
  ]
});

Для продакшна лучше использовать кастомную стратегию, которая предзагружает только маршруты с флагом data: { preload: true }:

@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return route.data?.['preload'] ? load() : of(null);
  }
}

Вложенные маршруты и router-outlet

<!-- app.component.html — корневой outlet -->
<nav>
  <a routerLink="/dashboard">Дашборд</a>
  <a routerLink="/users">Пользователи</a>
</nav>

<router-outlet />

<!-- users.component.html — вложенный outlet для дочерних маршрутов -->
<div class="users-layout">
  <aside>
    <app-user-sidebar />
  </aside>
  <main>
    <router-outlet />  <!-- Здесь рендерятся /users/:id и /users/:id/edit -->
  </main>
</div>

<router-outlet> — это точка монтирования компонента маршрута. Аналог <Outlet /> в React Router v6. Можно иметь несколько именованных outlets для сложных макетов (например, боковая панель и основной контент рендерятся независимо).

Управление состоянием: NgRx, Signals, NgXS и сравнение с Redux и Zustand

Управление состоянием: NgRx, Signals, NgXS и сравнение с Redux и Zustand

Вопрос управления состоянием в Angular — это не «нужен ли нам стор», а «какой стор выбрать». В React-экосистеме этот выбор давно решён в пользу Zustand или Redux Toolkit для большинства проектов. В Angular картина сложнее: встроенные сервисы с Signals уже покрывают большинство задач, NgRx добавляет предсказуемость для сложных сценариев, а NgXS предлагает компромисс. Разберём каждый подход честно.

Когда встроенных сервисов достаточно

Прежде чем тянуться к NgRx, ответьте на вопрос: действительно ли вам нужен Redux-подобный стор? Для большинства приложений ответ — нет. Сервисы с Signals покрывают 80% задач:

// Полноценный стор на сервисах + Signals — без NgRx
@Injectable({ providedIn: 'root' })
export class ProductStore {
  // Приватное состояние
  private _products = signal<Product[]>([]);
  private _loading = signal(false);
  private _selectedId = signal<string | null>(null);
  private _filters = signal<ProductFilters>({ category: null, minPrice: 0 });

  // Публичные селекторы (computed)
  readonly products = this._products.asReadonly();
  readonly loading = this._loading.asReadonly();

  readonly filteredProducts = computed(() => {
    const { category, minPrice } = this._filters();
    return this._products().filter(p =>
      (!category || p.category === category) &&
      p.price >= minPrice
    );
  });

  readonly selectedProduct = computed(() =>
    this._products().find(p => p.id === this._selectedId())
  );

  // Действия (методы)
  loadProducts = rxMethod<void>(
    pipe(
      tap(() => this._loading.set(true)),
      switchMap(() => inject(ProductService).getAll()),
      tap(products => {
        this._products.set(products);
        this._loading.set(false);
      })
    )
  );

  selectProduct(id: string) {
    this._selectedId.set(id);
  }

  setFilter(filter: Partial<ProductFilters>) {
    this._filters.update(f => ({ ...f, ...filter }));
  }
}

Это полноценный реактивный стор: состояние инкапсулировано, мутации только через методы, вычисляемые значения автоматически обновляются. Для большинства приложений этого достаточно.

NgRx: Redux для Angular

NgRx — это Angular-реализация паттерна Redux. Если вы работали с Redux Toolkit, концепции знакомы: actions, reducers, selectors, effects. NgRx добавляет строгую типизацию и интеграцию с Angular DI.

// 1. Определяем состояние
export interface ProductState {
  products: Product[];
  loading: boolean;
  error: string | null;
  selectedId: string | null;
}

const initialState: ProductState = {
  products: [],
  loading: false,
  error: null,
  selectedId: null
};

// 2. Actions — события, которые могут произойти
export const ProductActions = createActionGroup({
  source: 'Products',
  events: {
    'Load Products': emptyProps(),
    'Load Products Success': props<{ products: Product[] }>(),
    'Load Products Failure': props<{ error: string }>(),
    'Select Product': props<{ id: string }>(),
  }
});

// 3. Reducer — чистая функция обновления состояния
export const productReducer = createReducer(
  initialState,
  on(ProductActions.loadProducts, state => ({ ...state, loading: true, error: null })),
  on(ProductActions.loadProductsSuccess, (state, { products }) => ({
    ...state, products, loading: false
  })),
  on(ProductActions.loadProductsFailure, (state, { error }) => ({
    ...state, error, loading: false
  })),
  on(ProductActions.selectProduct, (state, { id }) => ({
    ...state, selectedId: id
  }))
);

// 4. Selectors — получение данных из стора
export const selectProductState = createFeatureSelector<ProductState>('products');

export const selectAllProducts = createSelector(
  selectProductState,
  state => state.products
);

export const selectLoading = createSelector(
  selectProductState,
  state => state.loading
);

export const selectFilteredProducts = createSelector(
  selectAllProducts,
  (products) => products.filter(p => p.inStock)
);

// 5. Effects — побочные эффекты (HTTP-запросы)
@Injectable()
export class ProductEffects {
  private actions$ = inject(Actions);
  private productService = inject(ProductService);

  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.loadProducts),
      switchMap(() =>
        this.productService.getAll().pipe(
          map(products => ProductActions.loadProductsSuccess({ products })),
          catchError(error =>
            of(ProductActions.loadProductsFailure({ error: error.message }))
          )
        )
      )
    )
  );
}

// 6. Использование в компоненте
@Component({ ... })
export class ProductListComponent {
  private store = inject(Store);

  products = this.store.selectSignal(selectFilteredProducts);
  loading = this.store.selectSignal(selectLoading);

  ngOnInit() {
    this.store.dispatch(ProductActions.loadProducts());
  }

  selectProduct(id: string) {
    this.store.dispatch(ProductActions.selectProduct({ id }));
  }
}

NgRx SignalStore: современный NgRx без boilerplate

NgRx 17+ представил SignalStore — новый API, который объединяет Signals и NgRx без Redux-boilerplate:

import { signalStore, withState, withComputed, withMethods, withHooks } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

export const ProductStore = signalStore(
  { providedIn: 'root' },

  withState<ProductState>({
    products: [],
    loading: false,
    error: null,
    selectedId: null
  }),

  withComputed(({ products, selectedId }) => ({
    selectedProduct: computed(() =>
      products().find(p => p.id === selectedId())
    ),
    inStockCount: computed(() =>
      products().filter(p => p.inStock).length
    )
  })),

  withMethods((store, productService = inject(ProductService)) => ({
    loadProducts: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() =>
          productService.getAll().pipe(
            tapResponse({
              next: products => patchState(store, { products, loading: false }),
              error: (error: Error) => patchState(store, { error: error.message, loading: false })
            })
          )
        )
      )
    ),

    selectProduct(id: string) {
      patchState(store, { selectedId: id });
    }
  })),

  withHooks({
    onInit(store) {
      store.loadProducts();
    }
  })
);

// Использование в компоненте — чище, чем классический NgRx
@Component({ ... })
export class ProductListComponent {
  protected store = inject(ProductStore);
  // store.products(), store.loading(), store.selectedProduct() — всё доступно
}

SignalStore — это будущее NgRx. Он сохраняет предсказуемость и тестируемость NgRx, но убирает 70% boilerplate.

NgXS: альтернатива с меньшим порогом входа

NgXS использует декораторы и классы вместо функций, что ближе к Angular-стилю:

// NgXS State
@State<ProductStateModel>({
  name: 'products',
  defaults: { products: [], loading: false }
})
@Injectable()
export class ProductState {
  @Selector()
  static products(state: ProductStateModel) { return state.products; }

  @Action(LoadProducts)
  loadProducts(ctx: StateContext<ProductStateModel>) {
    ctx.patchState({ loading: true });
    return inject(ProductService).getAll().pipe(
      tap(products => ctx.patchState({ products, loading: false }))
    );
  }
}

// Использование
@Component({ ... })
export class ProductListComponent {
  @Select(ProductState.products) products$!: Observable<Product[]>;

  constructor(private store: Store) {}

  load() {
    this.store.dispatch(new LoadProducts());
  }
}

Сравнение подходов

Критерий Сервисы + Signals NgRx Classic NgRx SignalStore NgXS
Boilerplate Минимальный Высокий Средний Средний
Типизация Отличная Отличная Отличная Хорошая
DevTools Нет Redux DevTools Redux DevTools NgXS DevTools
Time-travel debugging Нет Да Да Да
Тестируемость Хорошая Отличная Отличная Хорошая
Порог входа Низкий Высокий Средний Средний
Подходит для Малые/средние Крупные Любые Средние

Сравнение с Redux и Zustand

Если вы пришли из Redux Toolkit:

  • NgRx Classic — прямой аналог. Те же концепции, та же предсказуемость, больше Angular-специфики.
  • NgRx SignalStore — ближе к Zustand: меньше boilerplate, но с сохранением предсказуемости.

Если вы пришли из Zustand:

  • Сервисы с Signals — ближайший аналог. Простота Zustand + Angular DI.
  • NgRx SignalStore — Zustand с DevTools и строгой типизацией.
// Zustand (React)
const useProductStore = create<ProductStore>((set) => ({
  products: [],
  loading: false,
  loadProducts: async () => {
    set({ loading: true });
    const products = await fetchProducts();
    set({ products, loading: false });
  }
}));

// Аналог на Angular Signals + Service
@Injectable({ providedIn: 'root' })
export class ProductStore {
  products = signal<Product[]>([]);
  loading = signal(false);

  async loadProducts() {
    this.loading.set(true);
    const products = await firstValueFrom(inject(ProductService).getAll());
    this.products.set(products);
    this.loading.set(false);
  }
}

Практическая рекомендация: начинайте с сервисов + Signals. Если появляется необходимость в time-travel debugging, сложных эффектах или команда уже знает NgRx — переходите на NgRx SignalStore. Классический NgRx оправдан только в очень крупных командах с устоявшимися процессами.

Change Detection, Zone.js и оптимизация производительности Angular-приложений

Change Detection, Zone.js и оптимизация производительности Angular-приложений

Почему Angular-приложение иногда «тормозит» без видимой причины? Ответ почти всегда один: Change Detection запускается слишком часто или обрабатывает слишком много компонентов. Понимание механизма обнаружения изменений — это разница между разработчиком, который «добавляет OnPush и надеется на лучшее», и разработчиком, который точно знает, почему компонент перерендерился и как это предотвратить.

Как работает Change Detection

Angular должен знать, когда данные изменились, чтобы обновить DOM. В React это происходит явно: вы вызываете setState или dispatch, и React знает, что нужно перерендерить. Angular исторически использовал другой подход — Zone.js.

Zone.js — это библиотека, которая патчит все асинхронные API браузера: setTimeout, setInterval, Promise, fetch, addEventListener и другие. Когда любая из этих операций завершается, Zone.js уведомляет Angular: «что-то произошло, возможно, данные изменились». Angular запускает цикл обнаружения изменений.

// Zone.js перехватывает это:
setTimeout(() => {
  this.counter++;  // Angular узнает об изменении через Zone.js
}, 1000);

// И это:
fetch('/api/data').then(data => {
  this.items = data;  // Zone.js уведомит Angular
});

// И даже это:
button.addEventListener('click', () => {
  this.clicked = true;  // Zone.js перехватит событие
});

Проблема: Zone.js не знает, что именно изменилось. Он только знает, что что-то произошло. Поэтому Angular по умолчанию проверяет все компоненты в дереве при каждом асинхронном событии.

Default vs OnPush: два режима Change Detection

По умолчанию Angular использует стратегию Default — проверяет всё дерево компонентов сверху вниз при каждом событии. Для небольших приложений это нормально. Для больших — катастрофа производительности.

Стратегия OnPush говорит Angular: «Проверяй этот компонент только если:

  1. Изменился один из @Input (по ссылке)
  2. Компонент или его потомок сгенерировал событие
  3. Вручную запущена проверка через ChangeDetectorRef
  4. Использован async pipe (он сам триггерит проверку)
  5. Изменился Signal, используемый в шаблоне»
@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,  // Включаем OnPush
  template: `
    <div>{{ user.name }}</div>
    <div>{{ computedValue() }}</div>
  `
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;

  // Signals автоматически работают с OnPush
  private count = signal(0);
  computedValue = computed(() => this.count() * 2);
}

Критически важно: при OnPush мутация объекта не вызовет перерендер:

// Плохо — Angular не увидит изменения при OnPush
this.user.name = 'Новое имя';  // Ссылка на объект не изменилась!

// Хорошо — создаём новый объект
this.user = { ...this.user, name: 'Новое имя' };  // Новая ссылка!

Это то же правило иммутабельности, что и в Redux/React. При OnPush Angular сравнивает @Input по ссылке, а не по значению.

ChangeDetectorRef: ручное управление

Иногда нужно вручную контролировать цикл обнаружения изменений:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RealTimeComponent {
  private cdr = inject(ChangeDetectorRef);
  data: number[] = [];

  ngOnInit() {
    // Данные приходят вне Zone.js (например, через WebWorker)
    this.webSocket.onMessage(data => {
      this.data = data;
      this.cdr.markForCheck();  // Сказать Angular: "проверь этот компонент"
    });
  }

  // Полностью отключить проверку (для очень тяжёлых компонентов)
  pauseDetection() {
    this.cdr.detach();
  }

  // Включить обратно
  resumeDetection() {
    this.cdr.reattach();
  }
}

markForCheck() — самый важный метод. Он помечает компонент и всех его предков как «требующие проверки» при следующем цикле. Используйте его, когда данные изменяются вне Angular-контекста.

Signals и Zoneless: будущее Angular

Angular 16+ представил Zoneless режим — возможность полностью отказаться от Zone.js. В этом режиме Angular обновляет только те компоненты, которые читают изменившийся Signal:

// main.ts — включение zoneless режима (Angular 18+)
bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
});
// С Signals Angular точно знает, что изменилось
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- Angular обновит только этот текст, не весь компонент -->
    <p>{{ userName() }}</p>
    <p>{{ userEmail() }}</p>  <!-- Не обновится, если изменился только userName -->
  `
})
export class ProfileComponent {
  private userService = inject(UserService);

  userName = this.userService.name;    // Signal<string>
  userEmail = this.userService.email;  // Signal<string>
}

Это фундаментальный сдвиг: вместо «проверить всё дерево» Angular обновляет только конкретные DOM-узлы, которые читают изменившийся Signal. Производительность сопоставима с SolidJS.

Практические техники оптимизации

1. TrackBy в @for — всегда

<!-- Без track — Angular пересоздаёт все DOM-элементы при любом изменении массива -->
@for (item of items; track item.id) {
  <app-item [item]="item" />
}

2. Pure Pipes вместо методов в шаблоне

// Плохо — метод вызывается при каждом цикле CD
@Component({
  template: `<p>{{ formatDate(user.createdAt) }}</p>`
})
export class BadComponent {
  formatDate(date: Date): string {
    return date.toLocaleDateString('ru-RU');  // Вызывается постоянно!
  }
}

// Хорошо — Pure Pipe кэширует результат
@Pipe({ name: 'formatDate', pure: true, standalone: true })
export class FormatDatePipe implements PipeTransform {
  transform(date: Date): string {
    return date.toLocaleDateString('ru-RU');  // Вызывается только при изменении date
  }
}

3. Виртуализация длинных списков

// Angular CDK Virtual Scroll для списков из 1000+ элементов
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="60" style="height: 400px">
      @for (item of items; track item.id) {
        <div *cdkVirtualFor="let item of items" class="item">
          {{ item.name }}
        </div>
      }
    </cdk-virtual-scroll-viewport>
  `
})

4. Запуск тяжёлых операций вне Zone.js

@Component({ ... })
export class HeavyComponent {
  private ngZone = inject(NgZone);

  startHeavyAnimation() {
    // Анимация не должна триггерить CD на каждый кадр
    this.ngZone.runOutsideAngular(() => {
      const animate = () => {
        this.updateCanvasDirectly();  // Прямая работа с DOM
        requestAnimationFrame(animate);
      };
      requestAnimationFrame(animate);
    });
  }

  // Когда нужно обновить Angular-данные — возвращаемся в Zone
  updateAngularData(data: unknown) {
    this.ngZone.run(() => {
      this.result = data;  // Теперь Angular увидит изменение
    });
  }
}

Профилирование с Angular DevTools

Angular DevTools (расширение для Chrome) показывает, какие компоненты перерендерились и сколько времени заняла каждая проверка:

  1. Откройте DevTools → вкладка Angular
  2. Включите «Record» и выполните действие
  3. Посмотрите flame chart: красные компоненты — долгие проверки
  4. Компоненты с OnPush показывают значительно меньше проверок

Как отмечают в AngularUX, использование Angular DevTools flame charts для доказательства снижения рендеров — стандартная практика в enterprise-разработке. Цифры, а не ощущения.

Чеклист оптимизации Angular-приложения

  • Все компоненты используют ChangeDetectionStrategy.OnPush
  • Все списки используют track с уникальным идентификатором
  • Трансформации данных в шаблоне — через Pure Pipes, не методы
  • Длинные списки (n>100n > 100) используют виртуализацию через CDK
  • Тяжёлые вычисления вынесены в computed() или Web Workers
  • HTTP-запросы используют switchMap для отмены устаревших запросов
  • Подписки управляются через takeUntilDestroyed или async pipe
  • Lazy loading включён для всех feature-маршрутов

Директивы, пайпы, standalone components и современные API Angular

Директивы, пайпы, standalone components и современные API Angular

Три инструмента, которые отличают Angular от других фреймворков: директивы позволяют расширять HTML произвольным поведением, пайпы трансформируют данные прямо в шаблоне, а standalone components упрощают архитектуру до уровня, понятного React-разработчику. Вместе они формируют уникальный инструментарий, которого нет ни в одной другой экосистеме.

Директивы: три вида и их назначение

В Angular есть три типа директив. Компоненты — это директивы с шаблоном (мы уже разбирали их). Структурные директивы изменяют структуру DOM. Атрибутные директивы изменяют поведение или внешний вид существующих элементов.

Атрибутные директивы

Атрибутная директива — это класс с декоратором @Directive, который получает доступ к элементу через ElementRef и Renderer2:

// Директива подсветки при наведении
@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective {
  private el = inject(ElementRef);
  private renderer = inject(Renderer2);

  // @Input с тем же именем, что и селектор
  @Input('appHighlight') highlightColor = 'yellow';
  @Input() defaultColor = 'transparent';

  @HostListener('mouseenter')
  onMouseEnter() {
    this.setBackground(this.highlightColor);
  }

  @HostListener('mouseleave')
  onMouseLeave() {
    this.setBackground(this.defaultColor);
  }

  private setBackground(color: string) {
    this.renderer.setStyle(this.el.nativeElement, 'background-color', color);
  }
}

// Использование
// <p appHighlight="lightblue" defaultColor="white">Наведи на меня</p>

Почему Renderer2, а не прямой доступ к element.style? Renderer2 абстрагирует работу с DOM, что позволяет Angular работать в средах без браузера (SSR, Web Workers). Прямая мутация DOM через nativeElement — антипаттерн в Angular.

Директива с хост-привязками

// Директива для автоматического фокуса
@Directive({
  selector: '[appAutoFocus]',
  standalone: true
})
export class AutoFocusDirective implements AfterViewInit {
  private el = inject(ElementRef);

  // @HostBinding привязывает свойство к хост-элементу
  @HostBinding('attr.tabindex') tabIndex = 0;
  @HostBinding('class.focused') isFocused = false;

  @HostListener('focus') onFocus() { this.isFocused = true; }
  @HostListener('blur') onBlur() { this.isFocused = false; }

  ngAfterViewInit() {
    this.el.nativeElement.focus();
  }
}

Структурные директивы

Структурные директивы изменяют DOM — добавляют или удаляют элементы. Они используют * в синтаксисе (который является синтаксическим сахаром над ng-template):

// Кастомная структурная директива — показывает контент только для определённых ролей
@Directive({
  selector: '[appHasRole]',
  standalone: true
})
export class HasRoleDirective implements OnInit {
  private templateRef = inject(TemplateRef);
  private viewContainer = inject(ViewContainerRef);
  private authService = inject(AuthService);

  @Input('appHasRole') requiredRole!: string | string[];

  ngOnInit() {
    const roles = Array.isArray(this.requiredRole)
      ? this.requiredRole
      : [this.requiredRole];

    if (this.authService.hasAnyRole(roles)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

// Использование
// <button *appHasRole="'admin'">Удалить пользователя</button>
// <div *appHasRole="['admin', 'moderator']">Панель управления</div>

Пайпы: трансформация данных в шаблоне

Пайп — это класс с декоратором @Pipe и методом transform. Pure pipe (по умолчанию) вызывается только при изменении входных данных. Impure pipe вызывается при каждом цикле Change Detection — используйте осторожно.

// Пайп для форматирования размера файла
@Pipe({
  name: 'fileSize',
  standalone: true,
  pure: true  // По умолчанию true
})
export class FileSizePipe implements PipeTransform {
  transform(bytes: number, decimals = 2): string {
    if (bytes === 0) return '0 Байт';

    const k = 1024;
    const sizes = ['Байт', 'КБ', 'МБ', 'ГБ', 'ТБ'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
  }
}

// Пайп с несколькими аргументами
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 100, trail = '...'): string {
    return value.length > limit
      ? value.substring(0, limit) + trail
      : value;
  }
}

// Использование в шаблоне
// {{ file.size | fileSize }}              → "1.5 МБ"
// {{ file.size | fileSize:0 }}           → "2 МБ"
// {{ description | truncate:50:'…' }}    → "Длинный текст…"
// {{ description | truncate | uppercase }} → Цепочка пайпов

Встроенные пайпы Angular, которые нужно знать:

Пайп Пример Результат
date {{ date | date:'dd.MM.yyyy' }} 15.04.2025
currency {{ 1234.5 | currency:'RUB':'symbol' }} ₽1 234,50
decimal {{ 3.14159 | number:'1.2-2' }} 3.14
percent {{ 0.75 | percent }} 75%
uppercase {{ 'hello' | uppercase }} HELLO
slice {{ [1,2,3,4,5] | slice:1:3 }} [2, 3]
async {{ data$ | async }} Значение Observable
json {{ obj | json }} JSON-строка
keyvalue @for (kv of obj | keyvalue; ...) Итерация по объекту

Standalone Components: архитектура без NgModules

До Angular 14 каждый компонент должен был быть объявлен в NgModule. Standalone components изменили это: компонент сам декларирует свои зависимости через imports.

// Standalone компонент — полностью самодостаточный
@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [
    // Другие standalone компоненты
    PriceTagComponent,
    RatingComponent,
    // Директивы
    HighlightDirective,
    // Пайпы
    CurrencyPipe,
    TruncatePipe,
    // Angular-модули (для обратной совместимости)
    RouterLink,
    AsyncPipe
  ],
  template: `
    <div appHighlight="lightyellow">
      <h3>{{ product.name | truncate:50 }}</h3>
      <app-price-tag [price]="product.price" />
      <app-rating [value]="product.rating" />
      <a [routerLink]="['/products', product.id]">Подробнее</a>
    </div>
  `
})
export class ProductCardComponent {
  @Input({ required: true }) product!: Product;
}

Для React-разработчика это привычная модель: каждый файл явно импортирует то, что использует. Нет магических «глобальных» компонентов — всё явно.

Директивная композиция (Angular 15+)

Directive Composition API позволяет применять директивы к компоненту декларативно, без изменения шаблона:

// Применяем несколько директив к компоненту через hostDirectives
@Component({
  selector: 'app-button',
  standalone: true,
  hostDirectives: [
    {
      directive: HighlightDirective,
      inputs: ['appHighlight: color']  // Переименовываем input
    },
    AutoFocusDirective,
    {
      directive: HasRoleDirective,
      inputs: ['appHasRole: requiredRole']
    }
  ],
  template: `<ng-content />`
})
export class ButtonComponent {}

// Теперь ButtonComponent автоматически имеет поведение всех трёх директив
// <app-button color="blue" requiredRole="admin">Удалить</app-button>

Новые Signal-based API (Angular 17+)

Angular 17+ добавил Signal-based версии @Input, @Output и @ViewChild:

import { input, output, viewChild, contentChild, model } from '@angular/core';

@Component({ ... })
export class ModernComponent {
  // Signal Input — значение доступно как сигнал
  userId = input.required<string>();
  pageSize = input(10);  // С дефолтным значением

  // Computed на основе input
  userUrl = computed(() => `/api/users/${this.userId()}`);

  // Output через output()
  selected = output<User>();

  // Two-way binding через model()
  value = model('');  // Аналог [(value)]

  // ViewChild как сигнал
  inputRef = viewChild<ElementRef>('inputEl');

  // ContentChild как сигнал
  icon = contentChild(IconComponent);

  handleClick() {
    this.selected.emit(this.currentUser);
    // Доступ к ViewChild без проверки на undefined
    this.inputRef()?.nativeElement.focus();
  }
}

Signal-based inputs имеют важное преимущество: они работают с computed() и effect() напрямую, без ngOnChanges. Это делает реактивный код значительно чище.

Паттерн: умные и тупые компоненты

Лучшая практика Angular — разделять компоненты на умные (smart/container) и тупые (dumb/presentational):

// Умный компонент — знает о сервисах, управляет данными
@Component({
  selector: 'app-user-list-page',
  standalone: true,
  imports: [UserCardComponent, AsyncPipe],
  template: `
    @for (user of userService.users(); track user.id) {
      <app-user-card
        [user]="user"
        (delete)="onDelete($event)"
      />
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListPageComponent {
  protected userService = inject(UserService);

  onDelete(userId: string) {
    this.userService.deleteUser(userId);
  }
}

// Тупой компонент — только отображение, никаких сервисов
@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [DatePipe],
  template: `
    <div class="card">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
      <time>{{ user.createdAt | date }}</time>
      <button (click)="delete.emit(user.id)">Удалить</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
  user = input.required<User>();
  delete = output<string>();
}

Тупые компоненты с OnPush и Signal inputs — это максимально оптимизированные компоненты. Angular обновляет их только при изменении входных данных, и они полностью переиспользуемы.

HTTP Client, Interceptors, работа с API и обработка ошибок в Angular

HTTP Client, Interceptors, работа с API и обработка ошибок в Angular

В React вы выбираете между fetch, axios и TanStack Query. В Angular выбора нет — есть HttpClient, и это хорошая новость. Встроенный HTTP-клиент Angular глубоко интегрирован с RxJS, поддерживает типизацию из коробки, имеет мощную систему перехватчиков и тестируется без моков. Разберём его от базового использования до продвинутых паттернов.

Настройка и базовые запросы

// main.ts — подключение HttpClient (Angular 15+)
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([authInterceptor, errorInterceptor]),
      withFetch()  // Использовать Fetch API вместо XMLHttpRequest
    )
  ]
});
// Базовые HTTP-запросы
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private baseUrl = '/api/users';

  // GET — получить список
  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.baseUrl);
  }

  // GET с параметрами
  searchUsers(query: string, page = 1, limit = 20): Observable<PaginatedResponse<User>> {
    const params = new HttpParams()
      .set('q', query)
      .set('page', page.toString())
      .set('limit', limit.toString());

    return this.http.get<PaginatedResponse<User>>(this.baseUrl, { params });
  }

  // GET одного объекта
  getUser(id: string): Observable<User> {
    return this.http.get<User>(`${this.baseUrl}/${id}`);
  }

  // POST — создать
  createUser(data: CreateUserDto): Observable<User> {
    return this.http.post<User>(this.baseUrl, data);
  }

  // PUT — полное обновление
  updateUser(id: string, data: UpdateUserDto): Observable<User> {
    return this.http.put<User>(`${this.baseUrl}/${id}`, data);
  }

  // PATCH — частичное обновление
  patchUser(id: string, data: Partial<UpdateUserDto>): Observable<User> {
    return this.http.patch<User>(`${this.baseUrl}/${id}`, data);
  }

  // DELETE
  deleteUser(id: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`);
  }
}

Обратите внимание: HttpClient возвращает Observable, а не Promise. Запрос не выполняется до тех пор, пока кто-то не подпишется на Observable. Это ленивое выполнение — важное отличие от fetch.

Interceptors: перехватчики запросов

Interceptors — это middleware для HTTP-запросов. Они перехватывают каждый запрос или ответ и могут их модифицировать. Аналог axios.interceptors или middleware в Redux.

// Функциональный interceptor (Angular 15+, рекомендуется)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (!token) {
    return next(req);
  }

  // Клонируем запрос с добавлением заголовка
  const authReq = req.clone({
    headers: req.headers.set('Authorization', `Bearer ${token}`)
  });

  return next(authReq);
};

// Interceptor для обработки ошибок
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const notificationService = inject(NotificationService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      switch (error.status) {
        case 401:
          router.navigate(['/login']);
          break;
        case 403:
          notificationService.error('Доступ запрещён');
          break;
        case 404:
          notificationService.error('Ресурс не найден');
          break;
        case 500:
          notificationService.error('Ошибка сервера. Попробуйте позже.');
          break;
      }
      return throwError(() => error);
    })
  );
};

// Interceptor для логирования
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = Date.now();
  console.log(`[HTTP] ${req.method} ${req.url}`);

  return next(req).pipe(
    tap({
      next: response => {
        if (response instanceof HttpResponse) {
          console.log(`[HTTP] ${req.method} ${req.url} — ${Date.now() - start}ms`);
        }
      },
      error: error => {
        console.error(`[HTTP] ${req.method} ${req.url} — Error:`, error);
      }
    })
  );
};

Retry и обработка сетевых ошибок

import { retry, retryWhen, delay, take, catchError } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class ResilientApiService {
  private http = inject(HttpClient);

  // Простой retry с фиксированной задержкой
  getData<T>(url: string): Observable<T> {
    return this.http.get<T>(url).pipe(
      retry({
        count: 3,
        delay: 1000  // Ждать 1 секунду между попытками
      }),
      catchError(this.handleError)
    );
  }

  // Экспоненциальный backoff
  getDataWithBackoff<T>(url: string): Observable<T> {
    return this.http.get<T>(url).pipe(
      retryWhen(errors =>
        errors.pipe(
          scan((retryCount, error) => {
            if (retryCount >= 3) throw error;
            return retryCount + 1;
          }, 0),
          delayWhen(retryCount => timer(Math.pow(2, retryCount) * 1000))
        )
      ),
      catchError(this.handleError)
    );
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    let message = 'Произошла ошибка';

    if (error.error instanceof ErrorEvent) {
      // Клиентская ошибка (нет сети)
      message = `Ошибка сети: ${error.error.message}`;
    } else {
      // Серверная ошибка
      message = `Ошибка ${error.status}: ${error.error?.message ?? error.statusText}`;
    }

    return throwError(() => new Error(message));
  }
}

Загрузка файлов с прогрессом

@Injectable({ providedIn: 'root' })
export class FileUploadService {
  private http = inject(HttpClient);

  uploadFile(file: File): Observable<UploadProgress> {
    const formData = new FormData();
    formData.append('file', file, file.name);

    return this.http.post('/api/upload', formData, {
      reportProgress: true,
      observe: 'events'  // Получать все события, не только финальный ответ
    }).pipe(
      map(event => {
        switch (event.type) {
          case HttpEventType.UploadProgress:
            const progress = event.total
              ? Math.round(100 * event.loaded / event.total)
              : 0;
            return { status: 'progress', progress } as UploadProgress;

          case HttpEventType.Response:
            return { status: 'complete', data: event.body } as UploadProgress;

          default:
            return { status: 'pending', progress: 0 } as UploadProgress;
        }
      }),
      filter(event => event.status !== 'pending')
    );
  }
}

// Использование в компоненте
@Component({ ... })
export class UploadComponent {
  private uploadService = inject(FileUploadService);

  uploadProgress = signal(0);
  uploadStatus = signal<'idle' | 'uploading' | 'complete' | 'error'>('idle');

  onFileSelected(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    this.uploadStatus.set('uploading');

    this.uploadService.uploadFile(file).subscribe({
      next: progress => {
        if (progress.status === 'progress') {
          this.uploadProgress.set(progress.progress);
        } else if (progress.status === 'complete') {
          this.uploadStatus.set('complete');
        }
      },
      error: () => this.uploadStatus.set('error')
    });
  }
}

Кэширование HTTP-запросов

// Interceptor для кэширования GET-запросов
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
  private cache = new Map<string, HttpResponse<unknown>>();

  intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Кэшируем только GET-запросы
    if (req.method !== 'GET') {
      return next.handle(req);
    }

    const cacheKey = req.urlWithParams;
    const cached = this.cache.get(cacheKey);

    if (cached) {
      return of(cached);  // Возвращаем кэшированный ответ
    }

    return next.handle(req).pipe(
      tap(event => {
        if (event instanceof HttpResponse) {
          this.cache.set(cacheKey, event);
        }
      })
    );
  }
}

Типизированные API-клиенты: паттерн Resource

Angular 19 представил экспериментальный API resource() — декларативный способ работы с асинхронными данными, похожий на TanStack Query:

import { resource, signal } from '@angular/core';

@Component({ ... })
export class UserDetailComponent {
  private http = inject(HttpClient);

  userId = signal('123');

  // resource автоматически перезагружает данные при изменении userId
  userResource = resource({
    request: () => ({ id: this.userId() }),
    loader: ({ request }) =>
      firstValueFrom(this.http.get<User>(`/api/users/${request.id}`))
  });

  // Доступ к состоянию
  // userResource.value()   — текущие данные
  // userResource.isLoading() — загрузка
  // userResource.error()  — ошибка
  // userResource.reload() — принудительная перезагрузка
}

Тестирование HTTP-запросов

Angular предоставляет HttpClientTestingModule для тестирования без реальных запросов:

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        UserService,
        provideHttpClientTesting()
      ]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // Проверить, что нет незавершённых запросов
  });

  it('должен загрузить пользователей', () => {
    const mockUsers: User[] = [
      { id: '1', name: 'Иван', email: 'ivan@example.com', role: 'user', createdAt: new Date() }
    ];

    service.getUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);  // Вернуть мок-данные
  });

  it('должен обработать ошибку 404', () => {
    service.getUser('999').subscribe({
      error: (error: HttpErrorResponse) => {
        expect(error.status).toBe(404);
      }
    });

    const req = httpMock.expectOne('/api/users/999');
    req.flush('Not Found', { status: 404, statusText: 'Not Found' });
  });
});

Это принципиальное преимущество Angular перед React: тестирование HTTP встроено в фреймворк. Не нужны MSW или jest.mockHttpTestingController перехватывает запросы на уровне Angular и позволяет проверить URL, метод, заголовки и тело запроса.

Модульная архитектура и лучшие практики построения enterprise-приложений на Angular

Модульная архитектура и лучшие практики построения enterprise-приложений на Angular

Когда приложение вырастает до 50+ компонентов и 20+ разработчиков, вопрос «как организовать код» становится важнее вопроса «как написать код». Плохая архитектура в Angular не сломает приложение сразу — она убьёт его медленно: через конфликты при мёрже, через компоненты с тысячами строк, через сервисы, которые знают обо всём. Разберём архитектурные паттерны, которые масштабируются.

Feature-based структура: основа enterprise-архитектуры

Первое решение, которое определяет всё остальное — как организовать папки. Есть два подхода: по типу файлов (все компоненты в одной папке, все сервисы в другой) и по функциональности (всё, что относится к пользователям — в одной папке).

# Плохо — организация по типу файлов
src/
├── components/
│   ├── user-list.component.ts
│   ├── product-card.component.ts
│   └── order-form.component.ts
├── services/
│   ├── user.service.ts
│   ├── product.service.ts
│   └── order.service.ts
└── models/
    ├── user.model.ts
    └── product.model.ts

# Хорошо — организация по функциональности (Feature-based)
src/
├── app/
│   ├── core/                    # Синглтоны, глобальные сервисы
│   │   ├── auth/
│   │   ├── http/
│   │   └── guards/
│   ├── shared/                  # Переиспользуемые компоненты без бизнес-логики
│   │   ├── components/
│   │   │   ├── button/
│   │   │   └── modal/
│   │   ├── directives/
│   │   └── pipes/
│   └── features/                # Бизнес-функциональность
│       ├── users/
│       │   ├── components/
│       │   ├── services/
│       │   ├── models/
│       │   ├── store/
│       │   └── users.routes.ts
│       ├── products/
│       └── orders/

Правило: если удалить папку features/users, всё остальное должно продолжать работать. Это признак правильной инкапсуляции.

Три слоя: Core, Shared, Features

Core содержит синглтоны, которые существуют в единственном экземпляре на всё приложение: сервис аутентификации, HTTP-перехватчики, глобальные guards, конфигурация. Core-сервисы используют providedIn: 'root'.

Shared содержит переиспользуемые «тупые» компоненты, директивы и пайпы без бизнес-логики. Компонент кнопки, модального окна, спиннера — это Shared. Shared не зависит от Features и не зависит от Core (кроме базовых утилит).

Features содержит всю бизнес-логику, разбитую по доменам. Каждая feature — это изолированный вертикальный срез: компоненты, сервисы, стор, маршруты. Features могут зависеть от Core и Shared, но не должны зависеть друг от друга.

// Правило зависимостей (стрелки показывают допустимые зависимости):
// Features → Shared → Core
// Features → Core
// Features НЕ → другие Features (только через Core-сервисы)

Barrel exports: управление публичным API

Каждая feature должна экспортировать только то, что нужно снаружи. Остальное — детали реализации:

// features/users/index.ts — публичный API feature
export { UserListComponent } from './components/user-list/user-list.component';
export { UserCardComponent } from './components/user-card/user-card.component';
export { UserService } from './services/user.service';
export type { User, CreateUserDto } from './models/user.model';
// UserDetailFormComponent — внутренний, не экспортируем

// Использование в другом месте
import { UserCardComponent, UserService } from '@app/features/users';
// Не: import { UserCardComponent } from '@app/features/users/components/user-card/user-card.component'

Настройка path aliases в tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@app/core/*": ["src/app/core/*"],
      "@app/shared/*": ["src/app/shared/*"],
      "@app/features/*": ["src/app/features/*"]
    }
  }
}

Паттерн Facade: упрощение интерфейса к сложной подсистеме

Facade — это сервис, который скрывает сложность взаимодействия нескольких сервисов за простым интерфейсом. Компоненты работают только с Facade, не зная о внутренней структуре:

// Без Facade — компонент знает о трёх сервисах
@Component({ ... })
export class OrderPageComponent {
  private orderService = inject(OrderService);
  private inventoryService = inject(InventoryService);
  private notificationService = inject(NotificationService);
  private analyticsService = inject(AnalyticsService);

  async placeOrder(order: Order) {
    await this.inventoryService.reserve(order.items);
    const result = await this.orderService.create(order);
    this.notificationService.success('Заказ оформлен');
    this.analyticsService.track('order_placed', { orderId: result.id });
  }
}

// С Facade — компонент знает только об одном сервисе
@Injectable({ providedIn: 'root' })
export class OrderFacade {
  private orderService = inject(OrderService);
  private inventoryService = inject(InventoryService);
  private notificationService = inject(NotificationService);
  private analyticsService = inject(AnalyticsService);

  // Публичное состояние
  readonly orders = this.orderService.orders;
  readonly loading = this.orderService.loading;

  async placeOrder(order: CreateOrderDto): Promise<void> {
    await this.inventoryService.reserve(order.items);
    const result = await firstValueFrom(this.orderService.create(order));
    this.notificationService.success('Заказ оформлен');
    this.analyticsService.track('order_placed', { orderId: result.id });
  }
}

@Component({ ... })
export class OrderPageComponent {
  protected facade = inject(OrderFacade);  // Только один сервис!
}

Nx Monorepo: масштабирование до нескольких приложений

Для крупных enterprise-проектов с несколькими приложениями (основное приложение, admin-панель, мобильное приложение) используют Nx — инструмент для монорепозиториев:

# Создание Nx workspace
npx create-nx-workspace@latest my-company --preset=angular

# Структура Nx monorepo
apps/
├── main-app/          # Основное приложение
├── admin-app/         # Административная панель
└── mobile-app/        # PWA для мобильных
libs/
├── shared/
│   ├── ui/            # Общие UI-компоненты
│   ├── utils/         # Утилиты
│   └── models/        # Общие типы
├── users/
│   ├── data-access/   # Сервисы и стор
│   └── feature/       # Компоненты
└── products/
    ├── data-access/
    └── feature/

Nx предоставляет dependency graph — визуализацию зависимостей между библиотеками, и affected commands — запуск тестов только для изменённых библиотек:

nx affected:test    # Тестировать только изменённые библиотеки
nx affected:build   # Собирать только изменённые приложения
nx graph            # Визуализировать граф зависимостей

Как отмечают в AngularUX, Nx monorepos с типизированными event-схемами и CI-гейтами снижают общую стоимость владения для долгоживущих enterprise-приложений.

Правила архитектуры: что запрещено

Хорошая архитектура — это не только то, что разрешено, но и то, что запрещено. Nx позволяет настроить boundary rules:

// .eslintrc.json — запрет нарушения архитектурных границ
{
  "rules": {
    "@nx/enforce-module-boundaries": ["error", {
      "depConstraints": [
        {
          "sourceTag": "scope:features",
          "onlyDependOnLibsWithTags": ["scope:shared", "scope:core"]
        },
        {
          "sourceTag": "scope:shared",
          "onlyDependOnLibsWithTags": ["scope:core"]
        },
        {
          "sourceTag": "type:ui",
          "onlyDependOnLibsWithTags": ["type:ui", "type:utils"]
        }
      ]
    }]
  }
}

Теперь если разработчик попытается импортировать из другой feature напрямую, ESLint выдаст ошибку. Архитектурные правила автоматически соблюдаются.

Паттерн: умные маршруты с Resolve

В enterprise-приложениях страницы часто требуют нескольких источников данных. Паттерн «умного маршрута» загружает все данные до рендера:

// Resolve загружает данные для страницы
export const orderDetailResolver: ResolveFn<OrderPageData> = (route) => {
  const orderId = route.paramMap.get('id')!;
  const orderService = inject(OrderService);
  const productService = inject(ProductService);

  return forkJoin({
    order: orderService.getOrder(orderId),
    relatedProducts: productService.getRelated(orderId),
    history: orderService.getHistory(orderId)
  }).pipe(
    catchError(() => {
      inject(Router).navigate(['/orders']);
      return EMPTY;
    })
  );
};

// Маршрут
{
  path: 'orders/:id',
  component: OrderDetailComponent,
  resolve: { pageData: orderDetailResolver }
}

// Компонент получает готовые данные
@Component({ ... })
export class OrderDetailComponent {
  private route = inject(ActivatedRoute);
  pageData = this.route.snapshot.data['pageData'] as OrderPageData;
  // Компонент рендерится только когда все данные готовы
}

Чеклист enterprise-архитектуры

  • Структура проекта: Core / Shared / Features
  • Каждая feature имеет публичный API через index.ts
  • Path aliases настроены в tsconfig.json
  • Все компоненты используют ChangeDetectionStrategy.OnPush
  • Все компоненты standalone
  • Lazy loading для всех feature-маршрутов
  • Facade-сервисы для сложных взаимодействий
  • ESLint с правилами архитектурных границ
  • Nx для монорепозитория (при нескольких приложениях)
  • CI/CD с проверкой бюджетов бандла

Интеграция внешних библиотек, микрофронтенды и масштабирование Angular-проектов

Интеграция внешних библиотек, микрофронтенды и масштабирование Angular-проектов

Реальный Angular-проект никогда не существует в вакууме. Он интегрируется с UI-библиотеками, аналитикой, картами, редакторами, платёжными системами. Он может быть частью микрофронтенд-архитектуры, где несколько команд разрабатывают независимые части одного приложения. Разберём, как Angular справляется с этими задачами — и где возникают нетривиальные проблемы.

Интеграция UI-библиотек

Три наиболее популярных UI-библиотеки для Angular:

Angular Material — официальная библиотека от Google, реализует Material Design. Полная интеграция с Angular DI, темизация через CSS-переменные, доступность из коробки.

PrimeNG — богатая библиотека компонентов для enterprise. Таблицы с виртуализацией, графики, сложные формы. Популярна в корпоративных проектах.

Ng-Zorro — Angular-реализация Ant Design. Популярна в проектах с китайскими корнями и в enterprise.

// Установка Angular Material
ng add @angular/material

// Использование компонентов
@Component({
  standalone: true,
  imports: [
    MatButtonModule,
    MatTableModule,
    MatPaginatorModule,
    MatSortModule,
    MatDialogModule
  ],
  template: `
    <table mat-table [dataSource]="dataSource" matSort>
      <ng-container matColumnDef="name">
        <th mat-header-cell *matHeaderCellDef mat-sort-header>Имя</th>
        <td mat-cell *matCellDef="let user">{{ user.name }}</td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
      <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
    </table>

    <mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons />
  `
})
export class UserTableComponent implements AfterViewInit {
  @ViewChild(MatPaginator) paginator!: MatPaginator;
  @ViewChild(MatSort) sort!: MatSort;

  displayedColumns = ['name', 'email', 'role'];
  dataSource = new MatTableDataSource<User>([]);

  ngAfterViewInit() {
    this.dataSource.paginator = this.paginator;
    this.dataSource.sort = this.sort;
  }
}

Интеграция JavaScript-библиотек без Angular-обёртки

Иногда нужно интегрировать библиотеку, у которой нет Angular-обёртки: Chart.js, Leaflet, Monaco Editor, Quill. Паттерн всегда одинаков: инициализация в ngAfterViewInit, очистка в ngOnDestroy, работа с DOM через ElementRef.

import { Chart, ChartConfiguration } from 'chart.js/auto';

@Component({
  selector: 'app-chart',
  standalone: true,
  template: `<canvas #chartCanvas></canvas>`
})
export class ChartComponent implements AfterViewInit, OnDestroy, OnChanges {
  @ViewChild('chartCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
  @Input({ required: true }) data!: ChartConfiguration['data'];
  @Input() type: ChartConfiguration['type'] = 'bar';

  private chart: Chart | null = null;
  private destroyRef = inject(DestroyRef);

  ngAfterViewInit() {
    this.initChart();
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['data'] && this.chart) {
      this.chart.data = this.data;
      this.chart.update();
    }
  }

  private initChart() {
    this.chart = new Chart(this.canvasRef.nativeElement, {
      type: this.type,
      data: this.data,
      options: {
        responsive: true,
        animation: { duration: 300 }
      }
    });
  }

  ngOnDestroy() {
    this.chart?.destroy();
  }
}

Ключевые правила интеграции:

  • Никогда не инициализируйте DOM-зависимые библиотеки в ngOnInit — DOM ещё не готов
  • Всегда уничтожайте экземпляры библиотек в ngOnDestroy — иначе утечки памяти
  • Используйте NgZone.runOutsideAngular() для библиотек с частыми обновлениями (анимации, карты)

Angular Elements: компоненты как Web Components

Angular Elements позволяет упаковать Angular-компонент как стандартный Web Component (Custom Element). Это позволяет использовать Angular-компоненты в React, Vue или обычном HTML:

// Создание Web Component из Angular-компонента
import { createCustomElement } from '@angular/elements';

@Component({
  selector: 'app-rating-widget',
  standalone: true,
  template: `
    <div class="rating">
      @for (star of stars; track star) {
        <span
          [class.filled]="star <= value()"
          (click)="setValue(star)"
        >★</span>
      }
    </div>
  `
})
export class RatingWidgetComponent {
  value = model(0);
  stars = [1, 2, 3, 4, 5];

  setValue(star: number) {
    this.value.set(star);
  }
}

// Регистрация как Custom Element
bootstrapApplication(AppComponent, appConfig).then(appRef => {
  const injector = appRef.injector;
  const RatingElement = createCustomElement(RatingWidgetComponent, { injector });
  customElements.define('rating-widget', RatingElement);
});

// Использование в любом HTML (даже в React-приложении!)
// <rating-widget value="3"></rating-widget>

Микрофронтенды: Module Federation

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

// webpack.config.js — Host Application (Shell)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // Подключаем удалённые приложения
        'users-app': 'usersApp@http://localhost:4201/remoteEntry.js',
        'products-app': 'productsApp@http://localhost:4202/remoteEntry.js'
      },
      shared: {
        '@angular/core': { singleton: true, strictVersion: true },
        '@angular/router': { singleton: true, strictVersion: true }
      }
    })
  ]
};

// webpack.config.js — Remote Application (Users)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'usersApp',
      filename: 'remoteEntry.js',
      exposes: {
        './UsersModule': './src/app/features/users/users.routes.ts'
      },
      shared: {
        '@angular/core': { singleton: true, strictVersion: true }
      }
    })
  ]
};
// Динамическая загрузка микрофронтенда в маршрутах Shell
export const routes: Routes = [
  {
    path: 'users',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:4201/remoteEntry.js',
        exposedModule: './UsersModule'
      }).then(m => m.usersRoutes)
  }
];

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

Nx для масштабирования: практические команды

# Создание новой библиотеки в Nx workspace
nx g @nx/angular:library shared/ui --standalone --style=scss

# Создание нового приложения
nx g @nx/angular:application admin-app

# Проверка архитектурных границ
nx lint

# Визуализация зависимостей
nx graph

# Запуск только затронутых тестов (CI-оптимизация)
nx affected:test --base=main

# Параллельная сборка всех приложений
nx run-many -t build --parallel=3

Производительность при масштабировании: бюджеты и мониторинг

// angular.json — бюджеты размера бандла
{
  "configurations": {
    "production": {
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "500kb",
          "maximumError": "1mb"
        },
        {
          "type": "lazy",
          "maximumWarning": "200kb",
          "maximumError": "400kb"
        },
        {
          "type": "anyComponentStyle",
          "maximumWarning": "2kb",
          "maximumError": "4kb"
        }
      ]
    }
  }
}

Анализ бандла для поиска тяжёлых зависимостей:

# Установка анализатора
npm install --save-dev webpack-bundle-analyzer

# Генерация статистики
ng build --stats-json

# Визуализация
npx webpack-bundle-analyzer dist/my-app/stats.json

SSR и Hydration: Angular Universal

Server-Side Rendering (SSR) критически важен для SEO и производительности первой загрузки. Angular поддерживает SSR через Angular Universal:

# Добавление SSR в существующий проект
ng add @angular/ssr
// Компонент, совместимый с SSR
@Component({ ... })
export class ProductListComponent {
  private platformId = inject(PLATFORM_ID);

  ngOnInit() {
    // Код, который должен выполняться только в браузере
    if (isPlatformBrowser(this.platformId)) {
      this.initBrowserOnlyFeature();
    }
  }

  private initBrowserOnlyFeature() {
    // localStorage, window, document — только в браузере!
    const saved = localStorage.getItem('preferences');
  }
}

Angular 17+ добавил Incremental Hydration — возможность гидрировать компоненты по мере их появления во viewport, а не все сразу. Это значительно улучшает Time to Interactive:

<!-- Гидрировать только когда компонент появится во viewport -->
@defer (on viewport) {
  <app-heavy-component />
} @placeholder {
  <div class="skeleton" />
}

Типичные проблемы масштабирования и их решения

Проблема: Circular dependencies

# Обнаружение циклических зависимостей
npx madge --circular --extensions ts src/

Решение: переместить общие типы в shared/models, использовать интерфейсы вместо конкретных классов для разрыва циклов.

Проблема: Медленная сборка

# Включение esbuild (Angular 17+ по умолчанию)
# angular.json
"builder": "@angular-devkit/build-angular:application"
# Вместо старого:
# "builder": "@angular-devkit/build-angular:browser"

Проблема: Конфликты версий в монорепозитории

Используйте peerDependencies в библиотеках и resolutions в package.json для принудительного использования одной версии.

Масштабирование Angular-приложений — это прежде всего организационная задача. Технические инструменты (Nx, Module Federation, SSR) решают конкретные проблемы, но без чётких архитектурных правил и командных соглашений они не помогут.

Финальный проект: разработка полноценного enterprise-приложения на Angular с нуля

Финальный проект: разработка полноценного enterprise-приложения на Angular с нуля

Теория без практики — это просто красивые слова. В этой статье мы построим полноценное enterprise-приложение TaskFlow — систему управления задачами с аутентификацией, ролевым доступом, реактивным состоянием, HTTP-интеграцией и оптимизированной архитектурой. Каждое решение будет обосновано, каждый паттерн — из реальной практики.

Что мы строим: требования TaskFlow

TaskFlow — корпоративная система управления задачами:

  • Аутентификация (JWT, refresh tokens)
  • Роли: Admin, Manager, Employee
  • Проекты с задачами, статусами и исполнителями
  • Реалтайм-обновления через WebSocket
  • Дашборд с аналитикой
  • Полная типизация, OnPush везде, lazy loading

Шаг 1: Инициализация проекта

# Создание проекта с оптимальными настройками
ng new taskflow \
  --routing \
  --style=scss \
  --strict \
  --standalone

cd taskflow

# Добавление Angular Material
ng add @angular/material

# Установка дополнительных зависимостей
npm install @ngrx/signals @ngrx/operators
npm install date-fns  # Работа с датами

Шаг 2: Структура проекта

src/app/
├── core/
│   ├── auth/
│   │   ├── auth.service.ts
│   │   ├── auth.guard.ts
│   │   └── auth.interceptor.ts
│   ├── http/
│   │   └── error.interceptor.ts
│   └── models/
│       └── api-response.model.ts
├── shared/
│   ├── components/
│   │   ├── loading-spinner/
│   │   ├── confirm-dialog/
│   │   └── empty-state/
│   ├── directives/
│   │   └── has-role.directive.ts
│   └── pipes/
│       └── time-ago.pipe.ts
├── features/
│   ├── auth/
│   │   ├── login/
│   │   └── auth.routes.ts
│   ├── dashboard/
│   │   ├── dashboard.component.ts
│   │   └── dashboard.routes.ts
│   ├── projects/
│   │   ├── components/
│   │   ├── services/
│   │   ├── store/
│   │   └── projects.routes.ts
│   └── tasks/
│       ├── components/
│       ├── services/
│       ├── store/
│       └── tasks.routes.ts
└── app.routes.ts

Шаг 3: Модели данных

// core/models/user.model.ts
export type UserRole = 'admin' | 'manager' | 'employee';

export interface User {
  id: string;
  name: string;
  email: string;
  role: UserRole;
  avatarUrl?: string;
}

// core/models/project.model.ts
export type ProjectStatus = 'active' | 'completed' | 'archived';

export interface Project {
  id: string;
  name: string;
  description: string;
  status: ProjectStatus;
  ownerId: string;
  memberIds: string[];
  createdAt: string;
  deadline?: string;
}

// core/models/task.model.ts
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';

export interface Task {
  id: string;
  title: string;
  description: string;
  status: TaskStatus;
  priority: TaskPriority;
  projectId: string;
  assigneeId: string | null;
  createdById: string;
  createdAt: string;
  dueDate?: string;
  tags: string[];
}

Шаг 4: Сервис аутентификации

// core/auth/auth.service.ts
export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private router = inject(Router);

  private _currentUser = signal<User | null>(null);
  private _isAuthenticated = computed(() => this._currentUser() !== null);

  readonly currentUser = this._currentUser.asReadonly();
  readonly isAuthenticated = this._isAuthenticated;

  hasRole(role: UserRole): boolean {
    return this._currentUser()?.role === role;
  }

  hasAnyRole(roles: UserRole[]): boolean {
    const userRole = this._currentUser()?.role;
    return userRole ? roles.includes(userRole) : false;
  }

  login(email: string, password: string): Observable<void> {
    return this.http.post<AuthTokens>('/api/auth/login', { email, password }).pipe(
      tap(tokens => this.saveTokens(tokens)),
      switchMap(() => this.loadCurrentUser()),
      map(() => void 0)
    );
  }

  logout(): void {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    this._currentUser.set(null);
    this.router.navigate(['/auth/login']);
  }

  refreshToken(): Observable<AuthTokens> {
    const refreshToken = localStorage.getItem('refreshToken');
    return this.http.post<AuthTokens>('/api/auth/refresh', { refreshToken }).pipe(
      tap(tokens => this.saveTokens(tokens))
    );
  }

  loadCurrentUser(): Observable<User> {
    return this.http.get<User>('/api/auth/me').pipe(
      tap(user => this._currentUser.set(user))
    );
  }

  getAccessToken(): string | null {
    return localStorage.getItem('accessToken');
  }

  private saveTokens(tokens: AuthTokens): void {
    localStorage.setItem('accessToken', tokens.accessToken);
    localStorage.setItem('refreshToken', tokens.refreshToken);
  }
}

Шаг 5: Interceptors

// core/auth/auth.interceptor.ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getAccessToken();

  const authReq = token
    ? req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) })
    : req;

  return next(authReq).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        return authService.refreshToken().pipe(
          switchMap(tokens => {
            const retryReq = req.clone({
              headers: req.headers.set('Authorization', `Bearer ${tokens.accessToken}`)
            });
            return next(retryReq);
          }),
          catchError(() => {
            authService.logout();
            return throwError(() => error);
          })
        );
      }
      return throwError(() => error);
    })
  );
};

Шаг 6: SignalStore для задач

// features/tasks/store/task.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

interface TaskState {
  tasks: Task[];
  loading: boolean;
  error: string | null;
  selectedTaskId: string | null;
  filters: {
    status: TaskStatus | null;
    priority: TaskPriority | null;
    assigneeId: string | null;
    search: string;
  };
}

export const TaskStore = signalStore(
  { providedIn: 'root' },

  withState<TaskState>({
    tasks: [],
    loading: false,
    error: null,
    selectedTaskId: null,
    filters: { status: null, priority: null, assigneeId: null, search: '' }
  }),

  withComputed(({ tasks, filters, selectedTaskId }) => ({
    filteredTasks: computed(() => {
      const f = filters();
      return tasks().filter(task => {
        if (f.status && task.status !== f.status) return false;
        if (f.priority && task.priority !== f.priority) return false;
        if (f.assigneeId && task.assigneeId !== f.assigneeId) return false;
        if (f.search && !task.title.toLowerCase().includes(f.search.toLowerCase())) return false;
        return true;
      });
    }),

    selectedTask: computed(() =>
      tasks().find(t => t.id === selectedTaskId())
    ),

    tasksByStatus: computed(() => {
      const statuses: TaskStatus[] = ['todo', 'in_progress', 'review', 'done'];
      return statuses.reduce((acc, status) => {
        acc[status] = tasks().filter(t => t.status === status);
        return acc;
      }, {} as Record<TaskStatus, Task[]>);
    }),

    stats: computed(() => ({
      total: tasks().length,
      completed: tasks().filter(t => t.status === 'done').length,
      overdue: tasks().filter(t =>
        t.dueDate && new Date(t.dueDate) < new Date() && t.status !== 'done'
      ).length
    }))
  })),

  withMethods((store, taskService = inject(TaskService)) => ({
    loadTasks: rxMethod<string>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(projectId =>
          taskService.getByProject(projectId).pipe(
            tapResponse({
              next: tasks => patchState(store, { tasks, loading: false }),
              error: (err: Error) => patchState(store, { error: err.message, loading: false })
            })
          )
        )
      )
    ),

    updateTaskStatus: rxMethod<{ taskId: string; status: TaskStatus }>(
      pipe(
        switchMap(({ taskId, status }) =>
          taskService.updateStatus(taskId, status).pipe(
            tapResponse({
              next: updatedTask => patchState(store, {
                tasks: store.tasks().map(t => t.id === taskId ? updatedTask : t)
              }),
              error: () => {}
            })
          )
        )
      )
    ),

    setFilter(filter: Partial<TaskState['filters']>) {
      patchState(store, { filters: { ...store.filters(), ...filter } });
    },

    selectTask(id: string | null) {
      patchState(store, { selectedTaskId: id });
    }
  }))
);

Шаг 7: Kanban-доска

// features/tasks/components/kanban-board/kanban-board.component.ts
@Component({
  selector: 'app-kanban-board',
  standalone: true,
  imports: [TaskCardComponent, HasRoleDirective, MatButtonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="kanban-board">
      @for (column of columns; track column.status) {
        <div class="kanban-column">
          <div class="column-header">
            <h3>{{ column.label }}</h3>
            <span class="badge">{{ store.tasksByStatus()[column.status].length }}</span>
          </div>

          <div
            class="column-body"
            (dragover)="onDragOver($event)"
            (drop)="onDrop($event, column.status)"
          >
            @for (task of store.tasksByStatus()[column.status]; track task.id) {
              <app-task-card
                [task]="task"
                [draggable]="true"
                (dragstart)="onDragStart($event, task)"
                (click)="store.selectTask(task.id)"
              />
            } @empty {
              <div class="empty-column">Нет задач</div>
            }
          </div>

          <button
            *appHasRole="['admin', 'manager']"
            mat-stroked-button
            (click)="createTask(column.status)"
          >
            + Добавить задачу
          </button>
        </div>
      }
    </div>
  `
})
export class KanbanBoardComponent {
  protected store = inject(TaskStore);

  columns: Array<{ status: TaskStatus; label: string }> = [
    { status: 'todo', label: 'К выполнению' },
    { status: 'in_progress', label: 'В работе' },
    { status: 'review', label: 'На проверке' },
    { status: 'done', label: 'Готово' }
  ];

  private draggedTask: Task | null = null;

  onDragStart(event: DragEvent, task: Task) {
    this.draggedTask = task;
    event.dataTransfer?.setData('taskId', task.id);
  }

  onDragOver(event: DragEvent) {
    event.preventDefault();
  }

  onDrop(event: DragEvent, newStatus: TaskStatus) {
    event.preventDefault();
    if (this.draggedTask && this.draggedTask.status !== newStatus) {
      this.store.updateTaskStatus({
        taskId: this.draggedTask.id,
        status: newStatus
      });
    }
    this.draggedTask = null;
  }

  createTask(status: TaskStatus) {
    // Открыть диалог создания задачи
  }
}

Шаг 8: Маршруты с guards и resolve

// app.routes.ts
export const routes: Routes = [
  {
    path: 'auth',
    loadChildren: () => import('./features/auth/auth.routes').then(m => m.authRoutes)
  },
  {
    path: '',
    canActivate: [authGuard],
    children: [
      {
        path: 'dashboard',
        loadComponent: () =>
          import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
        title: 'Дашборд — TaskFlow'
      },
      {
        path: 'projects',
        loadChildren: () =>
          import('./features/projects/projects.routes').then(m => m.projectsRoutes)
      },
      {
        path: 'projects/:projectId/tasks',
        loadComponent: () =>
          import('./features/tasks/components/kanban-board/kanban-board.component')
            .then(m => m.KanbanBoardComponent),
        resolve: { project: projectResolver },
        canActivate: [projectMemberGuard]
      }
    ]
  },
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: '**', loadComponent: () => import('./shared/components/not-found/not-found.component').then(m => m.NotFoundComponent) }
];

Шаг 9: Тестирование

// features/tasks/store/task.store.spec.ts
describe('TaskStore', () => {
  let store: InstanceType<typeof TaskStore>;
  let taskServiceMock: jasmine.SpyObj<TaskService>;

  beforeEach(() => {
    taskServiceMock = jasmine.createSpyObj('TaskService', ['getByProject', 'updateStatus']);

    TestBed.configureTestingModule({
      providers: [
        TaskStore,
        { provide: TaskService, useValue: taskServiceMock }
      ]
    });

    store = TestBed.inject(TaskStore);
  });

  it('должен загрузить задачи проекта', fakeAsync(() => {
    const mockTasks: Task[] = [
      { id: '1', title: 'Задача 1', status: 'todo', priority: 'high', projectId: 'p1',
        assigneeId: null, createdById: 'u1', createdAt: new Date().toISOString(),
        description: '', tags: [] }
    ];

    taskServiceMock.getByProject.and.returnValue(of(mockTasks));

    store.loadTasks('p1');
    tick();

    expect(store.tasks()).toEqual(mockTasks);
    expect(store.loading()).toBeFalse();
  }));

  it('должен фильтровать задачи по статусу', () => {
    // Устанавливаем начальное состояние
    const tasks: Task[] = [
      { id: '1', status: 'todo', priority: 'high', title: 'Todo task',
        projectId: 'p1', assigneeId: null, createdById: 'u1',
        createdAt: '', description: '', tags: [] },
      { id: '2', status: 'done', priority: 'low', title: 'Done task',
        projectId: 'p1', assigneeId: null, createdById: 'u1',
        createdAt: '', description: '', tags: [] }
    ];

    // Патчим состояние напрямую для теста
    TestBed.runInInjectionContext(() => {
      patchState(store, { tasks });
    });

    store.setFilter({ status: 'todo' });

    expect(store.filteredTasks().length).toBe(1);
    expect(store.filteredTasks()[0].id).toBe('1');
  });
});

Шаг 10: CI/CD конфигурация

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Lint
        run: ng lint

      - name: Type Check
        run: npx tsc --noEmit

      - name: Unit Tests
        run: ng test --watch=false --browsers=ChromeHeadless --code-coverage

      - name: Build
        run: ng build --configuration=production

      - name: Check Bundle Budgets
        run: |
          BUNDLE_SIZE=$(du -sk dist/taskflow/browser | cut -f1)
          echo "Bundle size: ${BUNDLE_SIZE}KB"
          if [ $BUNDLE_SIZE -gt 1024 ]; then
            echo "Bundle too large!"
            exit 1
          fi

Итоговая архитектура: что мы применили

Концепция Реализация в TaskFlow
Архитектура Core / Shared / Features
Состояние NgRx SignalStore
HTTP HttpClient + Interceptors (auth, error)
Маршрутизация Lazy loading + Guards + Resolve
Формы Reactive Forms с типизацией
Change Detection OnPush везде
Типизация Strict TypeScript + strictTemplates
Тестирование Jasmine + TestBed + HttpTestingController
CI/CD GitHub Actions с бюджетами бандла

TaskFlow — это не учебный пример, а шаблон реального enterprise-приложения. Каждое решение здесь продиктовано практическими требованиями: масштабируемостью, тестируемостью и производительностью. Именно с такими знаниями и таким кодом вы готовы к собеседованию на позицию Senior Angular Developer.