Содержание
Что изменилось в Angular 20
Angular 20 вышел в мае 2025 и стал самым значительным релизом за несколько лет. С тех пор вышли Angular 21 (ноябрь 2025) и Angular 22 (июнь 2026) — но всё описанное ниже актуально и там: Signals, Zoneless и новый control flow остались фундаментом фреймворка, а Signal Forms в 22-й версии доведены до stable.
Главное:
- Signals API — полностью стабилен (
signal,computed,effect,linkedSignal,toSignal,toObservable) - Zoneless change detection — стабилен начиная с Angular 20.2
- Новый control flow (
@if,@for,@switch) — стабилен с v17, теперь рекомендуемый resource()иhttpResource()— встроенная загрузка async данных- Standalone по умолчанию — NgModules уже не нужны для новых проектов
- Требуется TypeScript 5.8+
Signals — реактивность без Zone.js
Основы
import { signal, computed, effect } from '@angular/core';
@Component({
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+1</button>
`
})
export class CounterComponent {
count = signal(0); // WritableSignal<number>
double = computed(() => this.count() * 2); // Signal<number>
constructor() {
effect(() => {
// Автоматически перезапускается при изменении count()
console.log('Count changed:', this.count());
});
}
increment() {
this.count.update(v => v + 1); // функциональное обновление
// или
this.count.set(this.count() + 1); // прямое присвоение
}
}
linkedSignal (Angular 19+)
linkedSignal — signal который синхронизируется с другим signal:
import { signal, linkedSignal } from '@angular/core';
@Component({
standalone: true,
template: `
<select (change)="category.set($event.target.value)">
<option>Electronics</option>
<option>Books</option>
</select>
<!-- page сбрасывается при смене категории -->
<span>Page: {{ page() }}</span>
<button (click)="page.set(page() + 1)">Next</button>
`
})
export class ProductListComponent {
category = signal('Electronics');
// page сбрасывается в 1 при каждом изменении category
page = linkedSignal({
source: this.category,
computation: () => 1
});
}
Работа с Observable — toSignal / toObservable
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
@Component({ standalone: true })
export class SearchComponent {
searchQuery = signal('');
// Signal → Observable (для debounce/switchMap)
results$ = toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.api.search(query))
);
// Observable → Signal (для использования в шаблоне)
results = toSignal(this.results$, { initialValue: [] });
}
Zoneless Change Detection
Zone.js — библиотека которая патчила браузерные API (setTimeout, fetch, DOM events) чтобы Angular знал когда запускать change detection. Это добавляло ~100KB к бандлу и замедляло приложение.
Zoneless работает с Signals — Angular знает что изменилось, не нужно патчить всё подряд.
// main.ts — включаем Zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
// ...другие провайдеры
]
});
// Компонент для Zoneless — используй Signals или OnPush
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // обязательно с Zoneless!
template: `
<h1>{{ title() }}</h1>
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class AppComponent {
title = signal('My App');
items = signal<Item[]>([]);
}
Без ChangeDetectionStrategy.OnPush Zoneless не работает корректно. Компонент не будет обновляться без явных сигналов от Signals или markForCheck().
Новый Control Flow: @if, @for, @switch
Заменяет *ngIf, *ngFor, *ngSwitch. Встроен в компилятор, не требует импорта.
<!-- @if — заменяет *ngIf -->
@if (user(); as u) {
<p>Hello, {{ u.name }}!</p>
} @else if (loading()) {
<app-spinner />
} @else {
<p>Please log in</p>
}
<!-- @for — заменяет *ngFor -->
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
} @empty {
<p>No products found</p>
}
<!-- @switch — заменяет *ngSwitch -->
@switch (status()) {
@case ('active') { <span class="green">Active</span> }
@case ('pending') { <span class="yellow">Pending</span> }
@default { <span class="gray">Unknown</span> }
}
@for требует track — это не просто рекомендация, а обязательный синтаксис. Компилятор выдаст ошибку без него.
resource() и httpResource()
Встроенная загрузка данных с состоянием loading/error/data без сторонних библиотек.
resource()
import { resource, signal } from '@angular/core';
@Component({
standalone: true,
template: `
@if (productResource.isLoading()) {
<app-spinner />
} @else if (productResource.error()) {
<p>Error: {{ productResource.error() }}</p>
} @else {
<app-product [product]="productResource.value()" />
}
`
})
export class ProductDetailComponent {
productId = input.required<number>();
productResource = resource({
request: () => ({ id: this.productId() }), // реактивный request
loader: async ({ request }) => {
const response = await fetch(`/api/products/${request.id}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json() as Promise<Product>;
}
});
}
resource() автоматически перезагружает данные когда меняется productId.
httpResource() — для HTTP запросов
import { httpResource } from '@angular/common/http';
@Component({ standalone: true })
export class OrderListComponent {
userId = input.required<number>();
// HttpResource — использует HttpClient внутри
ordersResource = httpResource<Order[]>(
() => `/api/users/${this.userId()}/orders`
);
// С параметрами запроса
search = signal('');
searchResource = httpResource<Product[]>(() => ({
url: '/api/products',
params: { q: this.search(), limit: '20' }
}));
// Принудительная перезагрузка
refresh() {
this.ordersResource.reload();
}
}
httpResource возвращает объект с:
.value()— данные (Signal).isLoading()— boolean Signal.error()— Signal с ошибкой.reload()— принудительная перезагрузка
Standalone — больше не нужны NgModules
// Раньше — регистрировать в NgModule
@NgModule({
declarations: [ProductCardComponent],
imports: [CommonModule, RouterModule],
exports: [ProductCardComponent]
})
export class ProductsModule {}
// Теперь — standalone компонент сам объявляет зависимости
@Component({
standalone: true,
selector: 'app-product-card',
imports: [CurrencyPipe, RouterLink], // только то что нужно
template: `
<a [routerLink]="['/products', product().id]">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency:'RUB' }}</p>
</a>
`
})
export class ProductCardComponent {
product = input.required<Product>();
}
input(), output(), model() — новый API компонентов
// Старый синтаксис
@Input() productId!: number;
@Output() selected = new EventEmitter<Product>();
@Input({ required: true }) label!: string;
// Новый синтаксис (Angular 17.1+, Signal-based)
@Component({ standalone: true })
export class ProductComponent {
// inputs
productId = input.required<number>(); // обязательный
category = input<string>('all'); // с дефолтным значением
// output
selected = output<Product>();
// two-way binding
value = model<string>('');
// computed из input
productUrl = computed(() => `/products/${this.productId()}`);
}
// Использование в шаблоне родителя
// <app-product [productId]="42" (selected)="onSelect($event)" />
// <app-input [(value)]="searchQuery" />
Пример: полный компонент с Signals
import { Component, signal, computed, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { ChangeDetectionStrategy } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, CurrencyPipe],
template: `
<input [(ngModel)]="search" placeholder="Search..." />
@if (productsResource.isLoading()) {
<p>Loading...</p>
} @else if (productsResource.error()) {
<p>Failed to load products</p>
<button (click)="productsResource.reload()">Retry</button>
} @else {
<p>Found: {{ filteredProducts().length }}</p>
@for (p of filteredProducts(); track p.id) {
<div>
<strong>{{ p.name }}</strong>
<span>{{ p.price | currency:'RUB' }}</span>
</div>
} @empty {
<p>No products match your search</p>
}
}
`
})
export class ProductListComponent {
search = signal('');
productsResource = httpResource<Product[]>('/api/products');
filteredProducts = computed(() => {
const query = this.search().toLowerCase();
const products = this.productsResource.value() ?? [];
return query
? products.filter(p => p.name.toLowerCase().includes(query))
: products;
});
}
Миграция с Zone.js на Signals
Для управления состоянием на Signals есть отдельная статья — Signal Store вместо NgRx. Автоматизировать рутину миграции помогает Claude Code со скиллом /ng-upgrade.
Постепенный путь:
- Обнови до Angular 20, добавь TypeScript 5.8+
- Конвертируй компоненты на
ChangeDetectionStrategy.OnPush - Замени
@Input()/@Output()наinput()/output() - Замени
BehaviorSubjectнаsignal() - Замени
*ngIf/*ngForна@if/@for - Добавь
provideZonelessChangeDetection()и удалиzone.jsизpolyfills
Можно делать поэтапно — Zone.js и Signals работают вместе.
💡
ng update @angular/core @angular/cli— официальная миграция. Для каждой мажорной версии Angular предоставляет schematics которые автоматически конвертируют старый синтаксис. Запустиng updateчтобы увидеть доступные миграции.
Обсуждение
Пока нет комментариев. Будьте первым.