Angular - это платформа и фреймворк для создания одностраничных клиентских приложений с использованием HTML и TypeScript. Разрабатывается командой Google.


Введение и философия

Что такое Angular

Angular — это полноценная платформа для разработки, а не просто библиотека. Он включает:

  • Компонентную архитектуру
  • Систему модулей
  • Dependency Injection
  • Роутинг
  • HTTP-клиент
  • Формы (реактивные и шаблонные)
  • Анимации
  • Тестирование

Отличия от React/Vue

ХарактеристикаAngularReactVue
ТипФреймворкБиблиотекаФреймворк
ЯзыкTypeScriptJavaScript/TSXJavaScript/TS
АрхитектураMVC/MVVMКомпонентнаяКомпонентная
BindingTwo-wayOne-wayTwo-way
DIВстроенныйНетНет
CLIAngular CLICreate React AppVue CLI

Установка и создание проекта

# Установка Angular CLI глобально
npm install -g @angular/cli
 
# Создание нового проекта
ng new my-app
 
# Запуск dev-сервера
cd my-app
ng serve
 
# Генерация компонента
ng generate component components/header
# или сокращённо
ng g c components/header
 
# Генерация сервиса
ng g s services/api
 
# Генерация модуля
ng g m features/auth
 
# Сборка для продакшена
ng build --configuration=production

Архитектура приложения

Структура проекта

src/
├── app/
│   ├── components/          # Переиспользуемые компоненты
│   ├── pages/               # Страницы (роутинг)
│   ├── services/            # Сервисы (логика, API)
│   ├── models/              # Интерфейсы и типы
│   ├── guards/              # Route guards
│   ├── interceptors/        # HTTP interceptors
│   ├── pipes/               # Custom pipes
│   ├── directives/          # Custom directives
│   ├── store/               # NgRx store (state management)
│   ├── app.component.ts     # Корневой компонент
│   ├── app.module.ts        # Корневой модуль
│   └── app-routing.module.ts # Роутинг
├── assets/                  # Статические файлы
├── environments/            # Конфигурации окружений
├── index.html               # Точка входа HTML
├── main.ts                  # Точка входа приложения
└── styles.scss              # Глобальные стили

Модули (NgModules)

Модули — это контейнеры для группировки связанных компонентов, директив, пайпов и сервисов.

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HeaderComponent } from './components/header/header.component';
 
@NgModule({
  // Компоненты, директивы, пайпы этого модуля
  declarations: [
    AppComponent,
    HeaderComponent
  ],
  // Импортируемые модули
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    FormsModule,
    ReactiveFormsModule
  ],
  // Сервисы доступные во всём приложении
  providers: [],
  // Корневой компонент для запуска
  bootstrap: [AppComponent]
})
export class AppModule { }

Standalone Components (Angular 14+)

Начиная с Angular 14, можно создавать компоненты без модулей:

// header.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
 
@Component({
  selector: 'app-header',
  standalone: true, // Standalone компонент
  imports: [CommonModule, RouterModule], // Импорты прямо в компоненте
  template: `
    <header>
      <nav>
        <a routerLink="/">Home</a>
        <a routerLink="/about">About</a>
      </nav>
    </header>
  `,
  styles: [`
    header {
      background: #333;
      padding: 1rem;
    }
    a {
      color: white;
      margin-right: 1rem;
    }
  `]
})
export class HeaderComponent {}

Запуск приложения со standalone компонентом:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
 
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
 
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient()
  ]
});

Компоненты

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

// user-card.component.ts
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
 
// Интерфейс для типизации
interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}
 
@Component({
  // CSS-селектор для использования в шаблонах
  selector: 'app-user-card',
 
  // Путь к HTML-шаблону (или template для inline)
  templateUrl: './user-card.component.html',
 
  // Пути к файлам стилей (или styles для inline)
  styleUrls: ['./user-card.component.scss'],
 
  // Инкапсуляция стилей
  // Emulated (default) - эмуляция Shadow DOM
  // None - глобальные стили
  // ShadowDom - нативный Shadow DOM
  encapsulation: ViewEncapsulation.Emulated
})
export class UserCardComponent implements OnInit, OnDestroy {
 
  // Входные параметры от родителя
  @Input() user!: User;
  @Input() showActions: boolean = true;
 
  // События для родителя
  @Output() onEdit = new EventEmitter<User>();
  @Output() onDelete = new EventEmitter<number>();
 
  // Внутреннее состояние
  isExpanded: boolean = false;
 
  // Lifecycle hook - после инициализации
  ngOnInit(): void {
    console.log('Component initialized with user:', this.user);
  }
 
  // Lifecycle hook - перед уничтожением
  ngOnDestroy(): void {
    console.log('Component destroyed');
  }
 
  // Методы компонента
  toggleExpand(): void {
    this.isExpanded = !this.isExpanded;
  }
 
  editUser(): void {
    this.onEdit.emit(this.user);
  }
 
  deleteUser(): void {
    this.onDelete.emit(this.user.id);
  }
}
<!-- user-card.component.html -->
<div class="user-card" [class.expanded]="isExpanded">
  <div class="avatar">
    <img [src]="user.avatar || 'assets/default-avatar.png'" [alt]="user.name">
  </div>
 
  <div class="info">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
  </div>
 
  <button (click)="toggleExpand()">
    {{ isExpanded ? 'Collapse' : 'Expand' }}
  </button>
 
  <div *ngIf="showActions" class="actions">
    <button (click)="editUser()">Edit</button>
    <button (click)="deleteUser()">Delete</button>
  </div>
</div>

Использование компонента

<!-- parent.component.html -->
<app-user-card
  [user]="currentUser"
  [showActions]="canEdit"
  (onEdit)="handleEdit($event)"
  (onDelete)="handleDelete($event)">
</app-user-card>
 
<!-- Итерация по списку -->
<app-user-card
  *ngFor="let user of users; trackBy: trackByUserId"
  [user]="user"
  (onDelete)="removeUser($event)">
</app-user-card>
// parent.component.ts
export class ParentComponent {
  users: User[] = [];
  currentUser: User = { id: 1, name: 'John', email: 'john@example.com' };
  canEdit: boolean = true;
 
  handleEdit(user: User): void {
    console.log('Editing user:', user);
  }
 
  handleDelete(userId: number): void {
    this.users = this.users.filter(u => u.id !== userId);
  }
 
  // trackBy для оптимизации ngFor
  trackByUserId(index: number, user: User): number {
    return user.id;
  }
}

Template Syntax (Синтаксис шаблонов)

Интерполяция

<!-- Простая интерполяция -->
<h1>{{ title }}</h1>
 
<!-- Выражения -->
<p>{{ 1 + 2 }}</p>
<p>{{ user.name.toUpperCase() }}</p>
<p>{{ isActive ? 'Active' : 'Inactive' }}</p>
 
<!-- Вызов методов -->
<p>{{ getFullName() }}</p>
 
<!-- Пайпы для форматирования -->
<p>{{ price | currency:'USD' }}</p>
<p>{{ birthday | date:'dd.MM.yyyy' }}</p>
<p>{{ text | uppercase }}</p>
<p>{{ data | json }}</p>

Property Binding (Привязка свойств)

<!-- Привязка атрибутов -->
<img [src]="imageUrl" [alt]="imageAlt">
<button [disabled]="isLoading">Submit</button>
<input [value]="name" [placeholder]="placeholder">
 
<!-- Привязка классов -->
<div [class.active]="isActive"></div>
<div [class]="classExpression"></div>
<div [ngClass]="{ 'active': isActive, 'disabled': isDisabled }"></div>
 
<!-- Привязка стилей -->
<div [style.color]="textColor"></div>
<div [style.font-size.px]="fontSize"></div>
<div [ngStyle]="{ 'color': textColor, 'font-size': fontSize + 'px' }"></div>

Event Binding (Привязка событий)

<!-- Клик -->
<button (click)="onClick()">Click me</button>
<button (click)="onClick($event)">With event</button>
 
<!-- События ввода -->
<input (input)="onInput($event)">
<input (change)="onChange($event)">
<input (focus)="onFocus()" (blur)="onBlur()">
 
<!-- Клавиатура -->
<input (keyup)="onKeyUp($event)">
<input (keyup.enter)="onEnter()">
<input (keydown.escape)="onEscape()">
<input (keydown.ctrl.s)="onSave()">
 
<!-- Форма -->
<form (submit)="onSubmit($event)">
  <button type="submit">Submit</button>
</form>

Two-way Binding (Двусторонняя привязка)

<!-- ngModel для форм (требует FormsModule) -->
<input [(ngModel)]="name">
 
<!-- Эквивалент развёрнутой записи -->
<input [ngModel]="name" (ngModelChange)="name = $event">
 
<!-- Кастомная двусторонняя привязка -->
<app-counter [(count)]="counterValue"></app-counter>
// counter.component.ts
@Component({
  selector: 'app-counter',
  template: `
    <button (click)="decrement()">-</button>
    <span>{{ count }}</span>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  @Input() count: number = 0;
  @Output() countChange = new EventEmitter<number>(); // Имя: propertyName + 'Change'
 
  increment(): void {
    this.count++;
    this.countChange.emit(this.count);
  }
 
  decrement(): void {
    this.count--;
    this.countChange.emit(this.count);
  }
}

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

<!-- *ngIf - условный рендеринг -->
<div *ngIf="isVisible">Visible content</div>
 
<div *ngIf="user; else noUser">
  Welcome, {{ user.name }}!
</div>
<ng-template #noUser>
  Please log in
</ng-template>
 
<!-- *ngIf с then -->
<div *ngIf="isLoading; then loadingTpl; else contentTpl"></div>
<ng-template #loadingTpl>Loading...</ng-template>
<ng-template #contentTpl>Content loaded</ng-template>
 
<!-- *ngFor - итерация -->
<ul>
  <li *ngFor="let item of items">{{ item.name }}</li>
</ul>
 
<!-- *ngFor с контекстными переменными -->
<ul>
  <li *ngFor="let item of items;
              let i = index;
              let first = first;
              let last = last;
              let even = even;
              let odd = odd;
              trackBy: trackByFn">
    {{ i + 1 }}. {{ item.name }}
    <span *ngIf="first">(First)</span>
    <span *ngIf="last">(Last)</span>
  </li>
</ul>
 
<!-- *ngSwitch - множественное условие -->
<div [ngSwitch]="status">
  <p *ngSwitchCase="'active'">User is active</p>
  <p *ngSwitchCase="'pending'">User is pending</p>
  <p *ngSwitchCase="'blocked'">User is blocked</p>
  <p *ngSwitchDefault>Unknown status</p>
</div>
 
<!-- @if @for @switch (Angular 17+ Control Flow) -->
@if (isLoggedIn) {
  <p>Welcome back!</p>
} @else {
  <p>Please log in</p>
}
 
@for (item of items; track item.id) {
  <div>{{ item.name }}</div>
} @empty {
  <div>No items found</div>
}
 
@switch (status) {
  @case ('active') { <span>Active</span> }
  @case ('pending') { <span>Pending</span> }
  @default { <span>Unknown</span> }
}

Template Reference Variables

<!-- Ссылка на DOM-элемент -->
<input #nameInput type="text">
<button (click)="greet(nameInput.value)">Greet</button>
 
<!-- Ссылка на компонент -->
<app-timer #timer></app-timer>
<button (click)="timer.start()">Start Timer</button>
<button (click)="timer.stop()">Stop Timer</button>
 
<!-- Ссылка на директиву -->
<form #myForm="ngForm" (ngSubmit)="onSubmit(myForm)">
  <input name="email" ngModel required>
  <button [disabled]="myForm.invalid">Submit</button>
</form>

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

Angular компоненты проходят через серию этапов от создания до уничтожения.

import {
  Component,
  OnInit,
  OnDestroy,
  OnChanges,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked,
  SimpleChanges,
  Input
} from '@angular/core';
 
@Component({
  selector: 'app-lifecycle',
  template: `<p>{{ data }}</p>`
})
export class LifecycleComponent implements
  OnInit,
  OnDestroy,
  OnChanges,
  DoCheck,
  AfterContentInit,
  AfterContentChecked,
  AfterViewInit,
  AfterViewChecked
{
  @Input() data: string = '';
 
  constructor() {
    // 1. Вызывается при создании экземпляра
    // Используется для инъекции зависимостей
    // НЕ используется для инициализации данных
    console.log('1. Constructor');
  }
 
  ngOnChanges(changes: SimpleChanges): void {
    // 2. Вызывается при изменении @Input свойств
    // Вызывается ДО ngOnInit и при каждом изменении
    console.log('2. OnChanges', changes);
 
    if (changes['data']) {
      console.log('Previous:', changes['data'].previousValue);
      console.log('Current:', changes['data'].currentValue);
      console.log('First change:', changes['data'].firstChange);
    }
  }
 
  ngOnInit(): void {
    // 3. Вызывается ОДИН РАЗ после первого ngOnChanges
    // Используется для инициализации данных
    // Загрузка данных с сервера, подписки
    console.log('3. OnInit');
  }
 
  ngDoCheck(): void {
    // 4. Вызывается при каждой проверке изменений
    // Используется для кастомной логики обнаружения изменений
    // ОСТОРОЖНО: вызывается очень часто!
    console.log('4. DoCheck');
  }
 
  ngAfterContentInit(): void {
    // 5. Вызывается после проецирования контента (ng-content)
    // Вызывается ОДИН РАЗ
    console.log('5. AfterContentInit');
  }
 
  ngAfterContentChecked(): void {
    // 6. Вызывается после проверки проецированного контента
    console.log('6. AfterContentChecked');
  }
 
  ngAfterViewInit(): void {
    // 7. Вызывается после инициализации view и дочерних view
    // Здесь можно работать с @ViewChild
    // Вызывается ОДИН РАЗ
    console.log('7. AfterViewInit');
  }
 
  ngAfterViewChecked(): void {
    // 8. Вызывается после проверки view
    console.log('8. AfterViewChecked');
  }
 
  ngOnDestroy(): void {
    // 9. Вызывается перед уничтожением компонента
    // Используется для очистки: отписки, таймеры, etc.
    console.log('9. OnDestroy');
  }
}

Порядок вызова

Constructor
↓
ngOnChanges (если есть @Input)
↓
ngOnInit
↓
ngDoCheck
↓
ngAfterContentInit
↓
ngAfterContentChecked
↓
ngAfterViewInit
↓
ngAfterViewChecked
↓
(при изменениях) ngOnChanges → ngDoCheck → ngAfterContentChecked → ngAfterViewChecked
↓
ngOnDestroy

Dependency Injection (DI)

Основы DI

DI — это паттерн, при котором зависимости передаются извне, а не создаются внутри класса.

// Без DI (плохо)
class UserComponent {
  private apiService: ApiService;
 
  constructor() {
    this.apiService = new ApiService(); // Жёсткая связь
  }
}
 
// С DI (хорошо)
class UserComponent {
  constructor(private apiService: ApiService) {
    // Angular сам создаст и передаст экземпляр
  }
}

Создание сервисов

// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
@Injectable({
  // Сервис доступен во всём приложении (singleton)
  providedIn: 'root'
})
export class UserService {
  private apiUrl = '/api/users';
 
  // Приватное состояние
  private usersSubject = new BehaviorSubject<User[]>([]);
 
  // Публичный Observable для подписки
  users$ = this.usersSubject.asObservable();
 
  constructor(private http: HttpClient) {}
 
  // Методы работы с данными
  loadUsers(): void {
    this.http.get<User[]>(this.apiUrl).subscribe(users => {
      this.usersSubject.next(users);
    });
  }
 
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`${this.apiUrl}/${id}`);
  }
 
  createUser(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(this.apiUrl, user);
  }
 
  updateUser(id: number, user: Partial<User>): Observable<User> {
    return this.http.put<User>(`${this.apiUrl}/${id}`, user);
  }
 
  deleteUser(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

Использование в компоненте

// user-list.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UserService } from '../services/user.service';
 
@Component({
  selector: 'app-user-list',
  template: `
    <div *ngFor="let user of users">
      {{ user.name }} - {{ user.email }}
    </div>
  `
})
export class UserListComponent implements OnInit, OnDestroy {
  users: User[] = [];
 
  // Subject для отписки
  private destroy$ = new Subject<void>();
 
  constructor(private userService: UserService) {}
 
  ngOnInit(): void {
    // Подписка с автоматической отпиской
    this.userService.users$
      .pipe(takeUntil(this.destroy$))
      .subscribe(users => {
        this.users = users;
      });
 
    // Загрузка данных
    this.userService.loadUsers();
  }
 
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Уровни предоставления сервисов

// 1. На уровне приложения (singleton)
@Injectable({
  providedIn: 'root'
})
export class GlobalService {}
 
// 2. На уровне модуля
@NgModule({
  providers: [ModuleService]
})
export class FeatureModule {}
 
// 3. На уровне компонента (новый экземпляр для каждого компонента)
@Component({
  selector: 'app-example',
  providers: [LocalService]
})
export class ExampleComponent {}

Injection Tokens

// Для инъекции примитивов или конфигурации
import { InjectionToken } from '@angular/core';
 
export interface AppConfig {
  apiUrl: string;
  production: boolean;
}
 
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
 
// Регистрация
@NgModule({
  providers: [
    {
      provide: APP_CONFIG,
      useValue: {
        apiUrl: 'https://api.example.com',
        production: true
      }
    }
  ]
})
export class AppModule {}
 
// Использование
@Injectable({ providedIn: 'root' })
export class ApiService {
  constructor(@Inject(APP_CONFIG) private config: AppConfig) {
    console.log(this.config.apiUrl);
  }
}

Формы

Template-driven Forms

Простой подход для несложных форм.

// Требуется FormsModule
import { FormsModule } from '@angular/forms';
 
@Component({
  selector: 'app-login-form',
  template: `
    <form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
      <div>
        <label for="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          [(ngModel)]="model.email"
          required
          email
          #email="ngModel">
        <div *ngIf="email.invalid && email.touched" class="error">
          <span *ngIf="email.errors?.['required']">Email is required</span>
          <span *ngIf="email.errors?.['email']">Invalid email format</span>
        </div>
      </div>
 
      <div>
        <label for="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          [(ngModel)]="model.password"
          required
          minlength="6"
          #password="ngModel">
        <div *ngIf="password.invalid && password.touched" class="error">
          <span *ngIf="password.errors?.['required']">Password is required</span>
          <span *ngIf="password.errors?.['minlength']">
            Min length: {{ password.errors?.['minlength'].requiredLength }}
          </span>
        </div>
      </div>
 
      <button type="submit" [disabled]="loginForm.invalid">
        Login
      </button>
    </form>
  `
})
export class LoginFormComponent {
  model = {
    email: '',
    password: ''
  };
 
  onSubmit(form: NgForm): void {
    if (form.valid) {
      console.log('Form data:', this.model);
    }
  }
}

Reactive Forms

Более мощный и гибкий подход.

import { Component, OnInit } from '@angular/core';
import {
  FormBuilder,
  FormGroup,
  FormArray,
  Validators,
  AbstractControl,
  ValidationErrors
} from '@angular/forms';
 
@Component({
  selector: 'app-registration-form',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <!-- Простое поле -->
      <div>
        <label>Name:</label>
        <input formControlName="name">
        <div *ngIf="form.get('name')?.invalid && form.get('name')?.touched">
          <span *ngIf="form.get('name')?.errors?.['required']">Name is required</span>
        </div>
      </div>
 
      <!-- Email с кастомной валидацией -->
      <div>
        <label>Email:</label>
        <input formControlName="email">
        <div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
          <span *ngIf="form.get('email')?.errors?.['required']">Email is required</span>
          <span *ngIf="form.get('email')?.errors?.['email']">Invalid email</span>
          <span *ngIf="form.get('email')?.errors?.['emailTaken']">Email already taken</span>
        </div>
      </div>
 
      <!-- Вложенная группа -->
      <div formGroupName="address">
        <h4>Address</h4>
        <input formControlName="street" placeholder="Street">
        <input formControlName="city" placeholder="City">
        <input formControlName="zip" placeholder="ZIP">
      </div>
 
      <!-- FormArray для динамических полей -->
      <div>
        <h4>Phone Numbers</h4>
        <div formArrayName="phones">
          <div *ngFor="let phone of phonesArray.controls; let i = index">
            <input [formControlName]="i">
            <button type="button" (click)="removePhone(i)">Remove</button>
          </div>
        </div>
        <button type="button" (click)="addPhone()">Add Phone</button>
      </div>
 
      <button type="submit" [disabled]="form.invalid">Submit</button>
    </form>
 
    <!-- Отладка -->
    <pre>{{ form.value | json }}</pre>
    <pre>Valid: {{ form.valid }}</pre>
  `
})
export class RegistrationFormComponent implements OnInit {
  form!: FormGroup;
 
  constructor(private fb: FormBuilder) {}
 
  ngOnInit(): void {
    this.form = this.fb.group({
      // Простые поля с валидацией
      name: ['', [Validators.required, Validators.minLength(2)]],
      email: ['', [Validators.required, Validators.email], [this.emailValidator()]],
 
      // Вложенная группа
      address: this.fb.group({
        street: [''],
        city: ['', Validators.required],
        zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]]
      }),
 
      // Массив значений
      phones: this.fb.array([
        this.fb.control('', Validators.required)
      ])
    });
  }
 
  // Getter для удобного доступа
  get phonesArray(): FormArray {
    return this.form.get('phones') as FormArray;
  }
 
  addPhone(): void {
    this.phonesArray.push(this.fb.control('', Validators.required));
  }
 
  removePhone(index: number): void {
    this.phonesArray.removeAt(index);
  }
 
  // Кастомный асинхронный валидатор
  emailValidator() {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      return this.checkEmailExists(control.value).pipe(
        map(exists => exists ? { emailTaken: true } : null)
      );
    };
  }
 
  checkEmailExists(email: string): Observable<boolean> {
    // Имитация проверки на сервере
    return of(email === 'taken@example.com').pipe(delay(500));
  }
 
  onSubmit(): void {
    if (this.form.valid) {
      console.log(this.form.value);
    } else {
      // Пометить все поля как touched
      this.form.markAllAsTouched();
    }
  }
}

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

// validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
 
// Синхронный валидатор
export function forbiddenNameValidator(forbiddenName: RegExp): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const forbidden = forbiddenName.test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  };
}
 
// Валидатор для сравнения полей
export function matchFieldsValidator(field1: string, field2: string): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const value1 = group.get(field1)?.value;
    const value2 = group.get(field2)?.value;
    return value1 === value2 ? null : { fieldsMismatch: true };
  };
}
 
// Использование
this.form = this.fb.group({
  username: ['', [forbiddenNameValidator(/admin/i)]],
  password: ['', Validators.required],
  confirmPassword: ['', Validators.required]
}, {
  validators: matchFieldsValidator('password', 'confirmPassword')
});

Роутинг

Базовая настройка

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
 
import { HomeComponent } from './pages/home/home.component';
import { AboutComponent } from './pages/about/about.component';
import { UserListComponent } from './pages/users/user-list.component';
import { UserDetailComponent } from './pages/users/user-detail.component';
import { NotFoundComponent } from './pages/not-found/not-found.component';
import { AuthGuard } from './guards/auth.guard';
 
const routes: Routes = [
  // Простой маршрут
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
 
  // Маршрут с параметром
  { path: 'users', component: UserListComponent },
  { path: 'users/:id', component: UserDetailComponent },
 
  // Защищённый маршрут
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard]
  },
 
  // Lazy loading модуля
  {
    path: 'dashboard',
    loadChildren: () => import('./features/dashboard/dashboard.module')
      .then(m => m.DashboardModule)
  },
 
  // Редирект
  { path: 'home', redirectTo: '', pathMatch: 'full' },
 
  // Wildcard (404)
  { path: '**', component: NotFoundComponent }
];
 
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Навигация

<!-- Ссылки -->
<nav>
  <a routerLink="/">Home</a>
  <a routerLink="/about">About</a>
  <a [routerLink]="['/users', userId]">User Profile</a>
 
  <!-- С query params -->
  <a [routerLink]="['/products']"
     [queryParams]="{ category: 'electronics', sort: 'price' }">
    Products
  </a>
 
  <!-- Активный стиль -->
  <a routerLink="/about"
     routerLinkActive="active"
     [routerLinkActiveOptions]="{ exact: true }">
    About
  </a>
</nav>
 
<!-- Место для рендеринга роутов -->
<router-outlet></router-outlet>
// Программная навигация
import { Router, ActivatedRoute } from '@angular/router';
 
@Component({...})
export class SomeComponent {
  constructor(
    private router: Router,
    private route: ActivatedRoute
  ) {}
 
  goToUser(id: number): void {
    // Абсолютный путь
    this.router.navigate(['/users', id]);
 
    // Относительный путь
    this.router.navigate(['../edit'], { relativeTo: this.route });
 
    // С query params
    this.router.navigate(['/search'], {
      queryParams: { q: 'angular' }
    });
  }
}

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

// user-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { switchMap } from 'rxjs/operators';
 
@Component({
  selector: 'app-user-detail',
  template: `
    <div *ngIf="user">
      <h2>{{ user.name }}</h2>
    </div>
  `
})
export class UserDetailComponent implements OnInit {
  user: User | null = null;
 
  constructor(
    private route: ActivatedRoute,
    private userService: UserService
  ) {}
 
  ngOnInit(): void {
    // Способ 1: Snapshot (не реагирует на изменения)
    const id = this.route.snapshot.paramMap.get('id');
 
    // Способ 2: Observable (реагирует на изменения)
    this.route.paramMap.pipe(
      switchMap((params: ParamMap) => {
        const id = Number(params.get('id'));
        return this.userService.getUser(id);
      })
    ).subscribe(user => {
      this.user = user;
    });
 
    // Query params
    this.route.queryParamMap.subscribe(params => {
      const sort = params.get('sort');
    });
  }
}

Route Guards

// auth.guard.ts
import { Injectable } from '@angular/core';
import {
  CanActivate,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  Router
} from '@angular/router';
 
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
 
  constructor(
    private authService: AuthService,
    private router: Router
  ) {}
 
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): boolean | Observable<boolean> {
 
    if (this.authService.isAuthenticated()) {
      return true;
    }
 
    // Редирект на логин
    this.router.navigate(['/login'], {
      queryParams: { returnUrl: state.url }
    });
    return false;
  }
}
 
// Functional guard (Angular 14+)
export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);
 
  if (authService.isAuthenticated()) {
    return true;
  }
 
  return router.createUrlTree(['/login']);
};

Resolvers

// user.resolver.ts
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
 
@Injectable({ providedIn: 'root' })
export class UserResolver implements Resolve<User> {
 
  constructor(private userService: UserService) {}
 
  resolve(route: ActivatedRouteSnapshot): Observable<User> {
    const id = Number(route.paramMap.get('id'));
    return this.userService.getUser(id);
  }
}
 
// В роутах
const routes: Routes = [
  {
    path: 'users/:id',
    component: UserDetailComponent,
    resolve: { user: UserResolver }
  }
];
 
// В компоненте
ngOnInit() {
  this.user = this.route.snapshot.data['user'];
  // или
  this.route.data.subscribe(data => {
    this.user = data['user'];
  });
}

HTTP Client

Базовое использование

// api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry, map } from 'rxjs/operators';
 
interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}
 
@Injectable({ providedIn: 'root' })
export class ApiService {
  private baseUrl = 'https://api.example.com';
 
  constructor(private http: HttpClient) {}
 
  // GET запрос
  getUsers(): Observable<User[]> {
    return this.http.get<ApiResponse<User[]>>(`${this.baseUrl}/users`).pipe(
      map(response => response.data),
      retry(3),
      catchError(this.handleError)
    );
  }
 
  // GET с параметрами
  searchUsers(query: string, page: number): Observable<User[]> {
    const params = new HttpParams()
      .set('q', query)
      .set('page', page.toString())
      .set('limit', '10');
 
    return this.http.get<User[]>(`${this.baseUrl}/users`, { params });
  }
 
  // POST запрос
  createUser(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(`${this.baseUrl}/users`, user);
  }
 
  // PUT запрос
  updateUser(id: number, user: Partial<User>): Observable<User> {
    return this.http.put<User>(`${this.baseUrl}/users/${id}`, user);
  }
 
  // PATCH запрос
  patchUser(id: number, updates: Partial<User>): Observable<User> {
    return this.http.patch<User>(`${this.baseUrl}/users/${id}`, updates);
  }
 
  // DELETE запрос
  deleteUser(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/users/${id}`);
  }
 
  // С кастомными заголовками
  uploadFile(file: File): Observable<any> {
    const formData = new FormData();
    formData.append('file', file);
 
    const headers = new HttpHeaders({
      'Accept': 'application/json'
    });
 
    return this.http.post(`${this.baseUrl}/upload`, formData, {
      headers,
      reportProgress: true,
      observe: 'events'
    });
  }
 
  // Обработка ошибок
  private handleError(error: HttpErrorResponse) {
    let errorMessage = 'An error occurred';
 
    if (error.error instanceof ErrorEvent) {
      // Client-side error
      errorMessage = error.error.message;
    } else {
      // Server-side error
      errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
    }
 
    console.error(errorMessage);
    return throwError(() => new Error(errorMessage));
  }
}

HTTP Interceptors

// auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent
} from '@angular/common/http';
import { Observable } from 'rxjs';
 
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
 
  constructor(private authService: AuthService) {}
 
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
 
    const token = this.authService.getToken();
 
    if (token) {
      const authReq = req.clone({
        headers: req.headers.set('Authorization', `Bearer ${token}`)
      });
      return next.handle(authReq);
    }
 
    return next.handle(req);
  }
}
 
// error.interceptor.ts
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
 
  constructor(private router: Router) {}
 
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
 
    return next.handle(req).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          this.router.navigate(['/login']);
        }
        if (error.status === 403) {
          this.router.navigate(['/forbidden']);
        }
        if (error.status === 500) {
          // Показать уведомление
        }
        return throwError(() => error);
      })
    );
  }
}
 
// Регистрация в модуле
@NgModule({
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: ErrorInterceptor,
      multi: true
    }
  ]
})
export class AppModule {}

RxJS в Angular

Основные операторы

import {
  Observable,
  Subject,
  BehaviorSubject,
  ReplaySubject,
  of,
  from,
  interval,
  timer,
  forkJoin,
  combineLatest,
  merge,
  concat
} from 'rxjs';
 
import {
  map,
  filter,
  tap,
  take,
  takeUntil,
  takeWhile,
  skip,
  first,
  last,
  distinctUntilChanged,
  debounceTime,
  throttleTime,
  delay,
  switchMap,
  mergeMap,
  concatMap,
  exhaustMap,
  catchError,
  retry,
  retryWhen,
  finalize,
  share,
  shareReplay
} from 'rxjs/operators';

Трансформация данных

// map - преобразование каждого значения
this.users$.pipe(
  map(users => users.filter(u => u.active))
);
 
// filter - фильтрация значений
this.numbers$.pipe(
  filter(n => n > 10)
);
 
// tap - side effects без изменения потока
this.data$.pipe(
  tap(data => console.log('Received:', data)),
  tap(data => this.store.dispatch(setData(data)))
);
 
// distinctUntilChanged - только уникальные последовательные значения
this.searchQuery$.pipe(
  distinctUntilChanged(),
  switchMap(query => this.search(query))
);
 
// distinctUntilChanged с компаратором
this.user$.pipe(
  distinctUntilChanged((prev, curr) => prev.id === curr.id)
);

Обработка времени

// debounceTime - ждёт паузу перед эмитом
this.searchInput.valueChanges.pipe(
  debounceTime(300), // Ждёт 300ms без ввода
  distinctUntilChanged(),
  switchMap(term => this.searchService.search(term))
);
 
// throttleTime - ограничивает частоту эмитов
fromEvent(window, 'scroll').pipe(
  throttleTime(100), // Максимум раз в 100ms
  map(() => window.scrollY)
);
 
// delay - задержка
this.notification$.pipe(
  delay(3000) // Показать через 3 секунды
);

Flattening операторы

// switchMap - отменяет предыдущий запрос при новом
// Используйте для поиска, автокомплита
this.searchTerm$.pipe(
  switchMap(term => this.api.search(term))
);
 
// mergeMap (flatMap) - параллельное выполнение
// Используйте когда порядок не важен
this.userIds$.pipe(
  mergeMap(id => this.api.getUser(id))
);
 
// concatMap - последовательное выполнение
// Используйте когда важен порядок
this.tasks$.pipe(
  concatMap(task => this.processTask(task))
);
 
// exhaustMap - игнорирует новые пока текущий не завершён
// Используйте для предотвращения дублей (клик по кнопке)
this.submitClick$.pipe(
  exhaustMap(() => this.api.submit(this.form.value))
);

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

// forkJoin - ждёт завершения всех
forkJoin({
  users: this.api.getUsers(),
  products: this.api.getProducts(),
  settings: this.api.getSettings()
}).subscribe(({ users, products, settings }) => {
  // Все данные загружены
});
 
// combineLatest - эмитит при изменении любого
combineLatest([
  this.route.params,
  this.route.queryParams
]).pipe(
  map(([params, query]) => ({ ...params, ...query }))
);
 
// merge - объединяет в один поток
merge(
  fromEvent(this.el, 'mouseenter'),
  fromEvent(this.el, 'focus')
).subscribe(() => this.showTooltip());
 
// concat - последовательное объединение
concat(
  this.loadInitialData(),
  this.loadAdditionalData()
);

Обработка ошибок

// catchError - перехват и обработка
this.api.getData().pipe(
  catchError(error => {
    console.error('Error:', error);
    return of([]); // Возврат fallback значения
  })
);
 
// retry - повтор при ошибке
this.api.getData().pipe(
  retry(3) // Повторить 3 раза
);
 
// retryWhen с задержкой
this.api.getData().pipe(
  retryWhen(errors =>
    errors.pipe(
      delay(1000), // Ждать 1 секунду
      take(3) // Максимум 3 попытки
    )
  )
);
 
// finalize - выполняется в конце (успех или ошибка)
this.api.getData().pipe(
  finalize(() => this.isLoading = false)
);

Subjects

// Subject - базовый multicast
const subject = new Subject<number>();
subject.subscribe(v => console.log('A:', v));
subject.subscribe(v => console.log('B:', v));
subject.next(1); // A: 1, B: 1
subject.next(2); // A: 2, B: 2
 
// BehaviorSubject - хранит последнее значение
const behavior = new BehaviorSubject<number>(0); // Начальное значение
behavior.subscribe(v => console.log('Value:', v)); // Сразу получит 0
behavior.next(1);
console.log(behavior.getValue()); // Синхронный доступ к значению
 
// ReplaySubject - хранит N последних значений
const replay = new ReplaySubject<number>(3); // Хранит 3 значения
replay.next(1);
replay.next(2);
replay.next(3);
replay.next(4);
replay.subscribe(v => console.log(v)); // 2, 3, 4
 
// AsyncSubject - эмитит только последнее значение при complete
const async = new AsyncSubject<number>();
async.next(1);
async.next(2);
async.subscribe(v => console.log(v));
async.next(3);
async.complete(); // Только сейчас эмитит: 3

Паттерн отписки

@Component({...})
export class MyComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
 
  ngOnInit(): void {
    // Все подписки автоматически отпишутся
    this.data$.pipe(
      takeUntil(this.destroy$)
    ).subscribe();
 
    this.otherData$.pipe(
      takeUntil(this.destroy$)
    ).subscribe();
  }
 
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
 
// Или с использованием DestroyRef (Angular 16+)
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 
@Component({...})
export class MyComponent {
  private destroyRef = inject(DestroyRef);
 
  constructor() {
    this.data$.pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();
  }
}

Async Pipe

@Component({
  template: `
    <!-- Автоматическая подписка и отписка -->
    <div *ngIf="user$ | async as user">
      {{ user.name }}
    </div>
 
    <!-- Несколько async -->
    <ng-container *ngIf="{
      users: users$ | async,
      loading: loading$ | async
    } as vm">
      <div *ngIf="vm.loading">Loading...</div>
      <ul *ngIf="!vm.loading">
        <li *ngFor="let user of vm.users">{{ user.name }}</li>
      </ul>
    </ng-container>
  `
})
export class UserComponent {
  user$ = this.userService.getUser(1);
  users$ = this.userService.getUsers();
  loading$ = this.store.select(selectLoading);
}

NgRx — State Management

NgRx — это библиотека для управления состоянием, основанная на Redux паттерне.

Установка

ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools
ng add @ngrx/entity

Архитектура NgRx

┌─────────────────────────────────────────────────────────┐
│                        Component                         │
│                                                         │
│    dispatch(Action) ──────────┐                        │
│                                │                        │
│    ◄────────── select(State) ──┴──┐                    │
└─────────────────────────────────────────────────────────┘
              │                     ▲
              ▼                     │
┌─────────────────────┐   ┌─────────────────────┐
│       Action        │   │       Selector      │
│  { type, payload }  │   │   (state) => data   │
└─────────────────────┘   └─────────────────────┘
              │                     ▲
              ▼                     │
┌─────────────────────────────────────────────────────────┐
│                         Store                            │
│                                                         │
│    ┌─────────────┐         ┌─────────────────┐         │
│    │   Reducer   │ ──────► │      State      │         │
│    │ (state,     │         │                 │         │
│    │  action) => │         │  { users: [],   │         │
│    │  newState   │         │    loading: ... │         │
│    └─────────────┘         └─────────────────┘         │
└─────────────────────────────────────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────────────────────┐
│                        Effects                           │
│                                                         │
│    Action ──► Side Effect (API) ──► Action              │
└─────────────────────────────────────────────────────────┘

State (Состояние)

// store/user/user.state.ts
export interface User {
  id: number;
  name: string;
  email: string;
}
 
export interface UserState {
  users: User[];
  selectedUser: User | null;
  loading: boolean;
  error: string | null;
}
 
export const initialUserState: UserState = {
  users: [],
  selectedUser: null,
  loading: false,
  error: null
};

Actions (Действия)

// store/user/user.actions.ts
import { createAction, props } from '@ngrx/store';
import { User } from './user.state';
 
// Загрузка пользователей
export const loadUsers = createAction(
  '[User List] Load Users'
);
 
export const loadUsersSuccess = createAction(
  '[User API] Load Users Success',
  props<{ users: User[] }>()
);
 
export const loadUsersFailure = createAction(
  '[User API] Load Users Failure',
  props<{ error: string }>()
);
 
// Выбор пользователя
export const selectUser = createAction(
  '[User List] Select User',
  props<{ userId: number }>()
);
 
// CRUD операции
export const addUser = createAction(
  '[User Form] Add User',
  props<{ user: Omit<User, 'id'> }>()
);
 
export const addUserSuccess = createAction(
  '[User API] Add User Success',
  props<{ user: User }>()
);
 
export const updateUser = createAction(
  '[User Form] Update User',
  props<{ user: User }>()
);
 
export const deleteUser = createAction(
  '[User List] Delete User',
  props<{ userId: number }>()
);

Reducer (Редьюсер)

// store/user/user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialUserState } from './user.state';
import * as UserActions from './user.actions';
 
export const userReducer = createReducer(
  initialUserState,
 
  // Загрузка
  on(UserActions.loadUsers, (state) => ({
    ...state,
    loading: true,
    error: null
  })),
 
  on(UserActions.loadUsersSuccess, (state, { users }) => ({
    ...state,
    users,
    loading: false
  })),
 
  on(UserActions.loadUsersFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error
  })),
 
  // Выбор
  on(UserActions.selectUser, (state, { userId }) => ({
    ...state,
    selectedUser: state.users.find(u => u.id === userId) || null
  })),
 
  // Добавление
  on(UserActions.addUserSuccess, (state, { user }) => ({
    ...state,
    users: [...state.users, user]
  })),
 
  // Обновление
  on(UserActions.updateUser, (state, { user }) => ({
    ...state,
    users: state.users.map(u => u.id === user.id ? user : u)
  })),
 
  // Удаление
  on(UserActions.deleteUser, (state, { userId }) => ({
    ...state,
    users: state.users.filter(u => u.id !== userId)
  }))
);

Effects (Эффекты)

// store/user/user.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, tap } from 'rxjs/operators';
 
import { UserService } from '../../services/user.service';
import * as UserActions from './user.actions';
 
@Injectable()
export class UserEffects {
 
  constructor(
    private actions$: Actions,
    private userService: UserService,
    private router: Router
  ) {}
 
  // Загрузка пользователей
  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers),
      mergeMap(() =>
        this.userService.getUsers().pipe(
          map(users => UserActions.loadUsersSuccess({ users })),
          catchError(error =>
            of(UserActions.loadUsersFailure({ error: error.message }))
          )
        )
      )
    )
  );
 
  // Добавление пользователя
  addUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.addUser),
      mergeMap(({ user }) =>
        this.userService.createUser(user).pipe(
          map(newUser => UserActions.addUserSuccess({ user: newUser })),
          catchError(error =>
            of(UserActions.loadUsersFailure({ error: error.message }))
          )
        )
      )
    )
  );
 
  // Эффект без dispatch (side effect only)
  addUserSuccess$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.addUserSuccess),
      tap(() => this.router.navigate(['/users']))
    ),
    { dispatch: false }
  );
}

Selectors (Селекторы)

// store/user/user.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { UserState } from './user.state';
 
// Feature selector
export const selectUserState = createFeatureSelector<UserState>('users');
 
// Простые селекторы
export const selectAllUsers = createSelector(
  selectUserState,
  (state) => state.users
);
 
export const selectUsersLoading = createSelector(
  selectUserState,
  (state) => state.loading
);
 
export const selectUsersError = createSelector(
  selectUserState,
  (state) => state.error
);
 
export const selectSelectedUser = createSelector(
  selectUserState,
  (state) => state.selectedUser
);
 
// Вычисляемые селекторы
export const selectActiveUsers = createSelector(
  selectAllUsers,
  (users) => users.filter(u => u.active)
);
 
export const selectUsersCount = createSelector(
  selectAllUsers,
  (users) => users.length
);
 
// Параметризованный селектор
export const selectUserById = (userId: number) => createSelector(
  selectAllUsers,
  (users) => users.find(u => u.id === userId)
);
 
// Комбинированный селектор
export const selectUserViewModel = createSelector(
  selectAllUsers,
  selectUsersLoading,
  selectUsersError,
  (users, loading, error) => ({
    users,
    loading,
    error,
    hasUsers: users.length > 0
  })
);

Регистрация Store

// app.module.ts
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
 
import { userReducer } from './store/user/user.reducer';
import { UserEffects } from './store/user/user.effects';
 
@NgModule({
  imports: [
    // Корневой store
    StoreModule.forRoot({
      users: userReducer
    }),
 
    // Эффекты
    EffectsModule.forRoot([UserEffects]),
 
    // DevTools (только для разработки)
    StoreDevtoolsModule.instrument({
      maxAge: 25,
      logOnly: environment.production
    })
  ]
})
export class AppModule {}
 
// Для feature модулей
@NgModule({
  imports: [
    StoreModule.forFeature('users', userReducer),
    EffectsModule.forFeature([UserEffects])
  ]
})
export class UserModule {}

Использование в компоненте

// user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
 
import * as UserActions from '../store/user/user.actions';
import * as UserSelectors from '../store/user/user.selectors';
import { User } from '../store/user/user.state';
 
@Component({
  selector: 'app-user-list',
  template: `
    <div *ngIf="loading$ | async" class="loading">
      Loading...
    </div>
 
    <div *ngIf="error$ | async as error" class="error">
      {{ error }}
    </div>
 
    <ul *ngIf="users$ | async as users">
      <li *ngFor="let user of users"
          (click)="selectUser(user.id)"
          [class.selected]="(selectedUser$ | async)?.id === user.id">
        {{ user.name }} - {{ user.email }}
        <button (click)="deleteUser(user.id); $event.stopPropagation()">
          Delete
        </button>
      </li>
    </ul>
 
    <button (click)="loadUsers()">Reload</button>
  `
})
export class UserListComponent implements OnInit {
  // Подписки на store
  users$: Observable<User[]> = this.store.select(UserSelectors.selectAllUsers);
  loading$: Observable<boolean> = this.store.select(UserSelectors.selectUsersLoading);
  error$: Observable<string | null> = this.store.select(UserSelectors.selectUsersError);
  selectedUser$: Observable<User | null> = this.store.select(UserSelectors.selectSelectedUser);
 
  constructor(private store: Store) {}
 
  ngOnInit(): void {
    this.loadUsers();
  }
 
  loadUsers(): void {
    this.store.dispatch(UserActions.loadUsers());
  }
 
  selectUser(userId: number): void {
    this.store.dispatch(UserActions.selectUser({ userId }));
  }
 
  deleteUser(userId: number): void {
    this.store.dispatch(UserActions.deleteUser({ userId }));
  }
}

NgRx Entity

Для упрощения работы с коллекциями:

// store/user/user.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
 
export interface User {
  id: number;
  name: string;
  email: string;
}
 
export interface UserState extends EntityState<User> {
  selectedUserId: number | null;
  loading: boolean;
  error: string | null;
}
 
// Адаптер
export const userAdapter = createEntityAdapter<User>({
  selectId: (user) => user.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});
 
// Начальное состояние
export const initialState: UserState = userAdapter.getInitialState({
  selectedUserId: null,
  loading: false,
  error: null
});
 
// Reducer с адаптером
export const userReducer = createReducer(
  initialState,
 
  on(UserActions.loadUsersSuccess, (state, { users }) =>
    userAdapter.setAll(users, { ...state, loading: false })
  ),
 
  on(UserActions.addUserSuccess, (state, { user }) =>
    userAdapter.addOne(user, state)
  ),
 
  on(UserActions.updateUser, (state, { user }) =>
    userAdapter.updateOne({ id: user.id, changes: user }, state)
  ),
 
  on(UserActions.deleteUser, (state, { userId }) =>
    userAdapter.removeOne(userId, state)
  )
);
 
// Селекторы от адаптера
export const {
  selectIds,
  selectEntities,
  selectAll,
  selectTotal
} = userAdapter.getSelectors(selectUserState);

Signals (Angular 16+)

Signals — новый реактивный примитив для управления состоянием.

import { Component, signal, computed, effect } from '@angular/core';
 
@Component({
  selector: 'app-counter',
  template: `
    <h1>Count: {{ count() }}</h1>
    <h2>Double: {{ doubleCount() }}</h2>
 
    <button (click)="increment()">+</button>
    <button (click)="decrement()">-</button>
    <button (click)="reset()">Reset</button>
  `
})
export class CounterComponent {
  // Создание signal
  count = signal(0);
 
  // Computed signal (автоматически пересчитывается)
  doubleCount = computed(() => this.count() * 2);
 
  // Effect (side effect при изменении сигнала)
  constructor() {
    effect(() => {
      console.log(`Count changed to: ${this.count()}`);
      // Вызывается каждый раз при изменении count
    });
  }
 
  increment(): void {
    // Методы изменения
    this.count.update(value => value + 1);
  }
 
  decrement(): void {
    this.count.update(value => value - 1);
  }
 
  reset(): void {
    this.count.set(0);
  }
}

Signals vs Observables

import { signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { Observable, interval } from 'rxjs';
 
@Component({...})
export class ExampleComponent {
  // Observable -> Signal
  private timer$ = interval(1000);
  timerSignal = toSignal(this.timer$, { initialValue: 0 });
 
  // Signal -> Observable
  private count = signal(0);
  count$ = toObservable(this.count);
 
  // Использование в шаблоне
  // Signal: {{ timerSignal() }}
  // Observable: {{ count$ | async }}
}

Оптимизация и производительность

Change Detection

import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
 
@Component({
  selector: 'app-optimized',
  template: `...`,
  // OnPush - проверяет изменения только при:
  // 1. Изменении @Input ссылки
  // 2. Событии из шаблона
  // 3. Async pipe эмите
  // 4. Ручном вызове markForCheck/detectChanges
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OptimizedComponent {
  constructor(private cdr: ChangeDetectorRef) {}
 
  updateData(): void {
    // Принудительная проверка
    this.cdr.markForCheck();
 
    // Или полная детекция
    this.cdr.detectChanges();
  }
 
  // Отключение детекции
  pauseDetection(): void {
    this.cdr.detach();
  }
 
  // Включение обратно
  resumeDetection(): void {
    this.cdr.reattach();
  }
}

trackBy для ngFor

<div *ngFor="let item of items; trackBy: trackById">
  {{ item.name }}
</div>
trackById(index: number, item: Item): number {
  return item.id;
}

Lazy Loading

// app-routing.module.ts
const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(m => m.AdminModule)
  },
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(c => c.DashboardComponent)
  }
];

Pure Pipes

// Чистый pipe (по умолчанию) - вызывается только при изменении входа
@Pipe({
  name: 'myPipe',
  pure: true // default
})
export class MyPurePipe implements PipeTransform {
  transform(value: any): any {
    return processValue(value);
  }
}
 
// Нечистый pipe - вызывается при каждой проверке изменений
@Pipe({
  name: 'myImpurePipe',
  pure: false // используйте осторожно!
})
export class MyImpurePipe implements PipeTransform {
  transform(value: any): any {
    return processValue(value);
  }
}

Defer (Angular 17+)

<!-- Ленивая загрузка с условиями -->
@defer (on viewport) {
  <app-heavy-component />
} @placeholder {
  <div>Loading placeholder...</div>
} @loading (minimum 500ms) {
  <app-spinner />
} @error {
  <div>Failed to load component</div>
}
 
<!-- Условия загрузки -->
@defer (on idle) { ... }           <!-- Когда браузер idle -->
@defer (on viewport) { ... }        <!-- Когда виден во viewport -->
@defer (on interaction) { ... }     <!-- При взаимодействии -->
@defer (on hover) { ... }           <!-- При наведении -->
@defer (on timer(2000)) { ... }     <!-- Через 2 секунды -->
@defer (when condition) { ... }     <!-- При условии -->
@defer (prefetch on idle) { ... }   <!-- Предзагрузка -->

Тестирование

Unit тесты компонента

// user-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
 
import { UserListComponent } from './user-list.component';
import { UserService } from '../services/user.service';
 
describe('UserListComponent', () => {
  let component: UserListComponent;
  let fixture: ComponentFixture<UserListComponent>;
  let userServiceSpy: jasmine.SpyObj<UserService>;
 
  const mockUsers = [
    { id: 1, name: 'John', email: 'john@example.com' },
    { id: 2, name: 'Jane', email: 'jane@example.com' }
  ];
 
  beforeEach(async () => {
    // Создание mock сервиса
    userServiceSpy = jasmine.createSpyObj('UserService', ['getUsers', 'deleteUser']);
    userServiceSpy.getUsers.and.returnValue(of(mockUsers));
 
    await TestBed.configureTestingModule({
      declarations: [UserListComponent],
      providers: [
        { provide: UserService, useValue: userServiceSpy }
      ]
    }).compileComponents();
 
    fixture = TestBed.createComponent(UserListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
 
  it('should create', () => {
    expect(component).toBeTruthy();
  });
 
  it('should load users on init', () => {
    expect(userServiceSpy.getUsers).toHaveBeenCalled();
    expect(component.users.length).toBe(2);
  });
 
  it('should display users in template', () => {
    const userElements = fixture.debugElement.queryAll(By.css('.user-item'));
    expect(userElements.length).toBe(2);
    expect(userElements[0].nativeElement.textContent).toContain('John');
  });
 
  it('should delete user when delete button clicked', () => {
    userServiceSpy.deleteUser.and.returnValue(of(void 0));
 
    const deleteButton = fixture.debugElement.query(By.css('.delete-btn'));
    deleteButton.triggerEventHandler('click', null);
 
    expect(userServiceSpy.deleteUser).toHaveBeenCalledWith(1);
  });
});

Тестирование сервисов

// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 
import { UserService } from './user.service';
 
describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;
 
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService]
    });
 
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });
 
  afterEach(() => {
    httpMock.verify(); // Проверка что нет незавершённых запросов
  });
 
  it('should fetch users', () => {
    const mockUsers = [{ id: 1, name: 'John' }];
 
    service.getUsers().subscribe(users => {
      expect(users.length).toBe(1);
      expect(users[0].name).toBe('John');
    });
 
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
 
  it('should create user', () => {
    const newUser = { name: 'Jane', email: 'jane@example.com' };
    const createdUser = { id: 1, ...newUser };
 
    service.createUser(newUser).subscribe(user => {
      expect(user.id).toBe(1);
    });
 
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('POST');
    expect(req.request.body).toEqual(newUser);
    req.flush(createdUser);
  });
});

Best Practices

1. Структура проекта

src/app/
├── core/                    # Singleton сервисы, guards, interceptors
│   ├── services/
│   ├── guards/
│   ├── interceptors/
│   └── core.module.ts
├── shared/                  # Переиспользуемые компоненты, pipes, directives
│   ├── components/
│   ├── directives/
│   ├── pipes/
│   └── shared.module.ts
├── features/               # Feature modules
│   ├── users/
│   │   ├── components/
│   │   ├── pages/
│   │   ├── services/
│   │   ├── store/
│   │   └── users.module.ts
│   └── products/
├── store/                  # Root store (если не feature modules)
└── app.module.ts

2. Именование

// Компоненты: kebab-case для файлов, PascalCase для классов
// user-profile.component.ts
export class UserProfileComponent {}
 
// Сервисы
// user.service.ts
export class UserService {}
 
// Интерфейсы
// user.model.ts или user.interface.ts
export interface User {}
 
// Actions (NgRx)
// [Source] Event
export const loadUsers = createAction('[User Page] Load Users');

3. Подписки

// ❌ Плохо - утечка памяти
ngOnInit() {
  this.data$.subscribe(data => this.data = data);
}
 
// ✅ Хорошо - async pipe
// В шаблоне: {{ data$ | async }}
 
// ✅ Хорошо - takeUntil
private destroy$ = new Subject<void>();
ngOnInit() {
  this.data$.pipe(takeUntil(this.destroy$)).subscribe();
}
ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

4. Immutability

// ❌ Плохо - мутация
this.users.push(newUser);
 
// ✅ Хорошо - новый массив
this.users = [...this.users, newUser];
 
// ❌ Плохо - мутация объекта
this.user.name = 'New Name';
 
// ✅ Хорошо - новый объект
this.user = { ...this.user, name: 'New Name' };

5. Smart и Dumb компоненты

// Smart (Container) - работает с данными
@Component({
  selector: 'app-user-list-container',
  template: `
    <app-user-list
      [users]="users$ | async"
      [loading]="loading$ | async"
      (selectUser)="onSelectUser($event)">
    </app-user-list>
  `
})
export class UserListContainerComponent {
  users$ = this.store.select(selectUsers);
  loading$ = this.store.select(selectLoading);
 
  onSelectUser(id: number) {
    this.store.dispatch(selectUser({ id }));
  }
}
 
// Dumb (Presentational) - только отображение
@Component({
  selector: 'app-user-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListComponent {
  @Input() users: User[] = [];
  @Input() loading: boolean = false;
  @Output() selectUser = new EventEmitter<number>();
}

Полезные ресурсы