Angular 18 Signals: нові реактивні API та виявлення змін без Zone.js
Повний посібник з Angular 18 Signals: input(), model(), viewChild(), zoneless режим. Практичний гід з прикладами коду та міграцією компонентів.

Angular 18 стає переломним моментом в еволюції фреймворку завдяки стабілізації Signals. Цей новий реактивний примітив фундаментально змінює підхід до побудови компонентів Angular, пропонуючи сучасну альтернативу традиційним декораторам та прокладаючи шлях до виявлення змін без Zone.js.
API на основі сигналів в Angular 18: input(), model(), viewChild() та конфігурація zoneless для легших і продуктивніших застосунків.
Розуміння Signals в Angular 18
Signals представляють новий підхід до реактивності в Angular. На відміну від класичних декораторів на кшталт @Input(), які залежать від виявлення змін через Zone.js, Signals забезпечують точкову та явну реактивність. Кожен Signal інкапсулює значення та автоматично повідомляє споживачів при його зміні.
Такий підхід надає кілька переваг: краща продуктивність завдяки цілеспрямованим оновленням, нативна інтеграція з функціями computed() та effect(), а також підготовка до майбутнього Angular без Zone.js.
// Demonstration of fundamental Signal concepts
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter-container">
<h2>Counter: {{ count() }}</h2>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal - value can be modified
count = signal(0);
// Computed signal - automatically derived from count
// Only recalculates when count changes
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - executed on every count change
// Useful for side effects (logs, API calls, etc.)
effect(() => {
console.log(`New counter value: ${this.count()}`);
});
}
increment() {
// update() allows modification based on previous value
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() directly replaces the value
this.count.set(0);
}
}Signals працюють як реактивні контейнери: signal() створює записуваний Signal, computed() виводить обчислювані значення, а effect() дозволяє виконувати дії у відповідь на зміни.
Signal Inputs з input()
Функція input() замінює традиційний декоратор @Input(). Вона повертає InputSignal тільки для читання, гарантуючи, що дані завжди передаються від батьківського до дочірнього компонента без випадкових модифікацій.
// Component using signal inputs
import { Component, input, computed } from '@angular/core';
interface Book {
id: string;
title: string;
author: string;
price: number;
discountPercent?: number;
}
@Component({
selector: 'app-book-card',
standalone: true,
template: `
<article class="book-card">
<h3>{{ book().title }}</h3>
<p class="author">By {{ book().author }}</p>
@if (hasDiscount()) {
<p class="price">
<span class="original">\${{ book().price }}</span>
<span class="discounted">\${{ discountedPrice() }}</span>
</p>
} @else {
<p class="price">\${{ book().price }}</p>
}
@if (featured()) {
<span class="badge">Featured</span>
}
</article>
`
})
export class BookCardComponent {
// Required input - template won't compile without this prop
book = input.required<Book>();
// Optional input with default value
featured = input(false);
// Computed based on input - automatically recalculated
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// Discounted price calculation
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}Використання в шаблоні батьківського компонента залишається подібним, але з типобезпекою та реактивністю Signal:
// Parent component using book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
<div class="book-grid">
@for (book of books(); track book.id) {
<app-book-card
[book]="book"
[featured]="book.id === featuredBookId()"
/>
}
</div>
`
})
export class BookListComponent {
books = signal<Book[]>([
{ id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
{ id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
]);
featuredBookId = signal('1');
}Ключова відмінність від @Input(): signal inputs доступні тільки для читання. Виклик this.book.set() з дочірнього компонента неможливий, що посилює односпрямований потік даних.
Двостороннє зв'язування з model()
Для випадків, що потребують двосторонньої синхронізації, Angular 18 вводить model(). Ця функція створює записуваний Signal, який автоматично поширює зміни до батьківського компонента.
// Component with bidirectional binding via model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
<div class="search-container">
<input
type="text"
[value]="query()"
(input)="onInput($event)"
[placeholder]="placeholder()"
class="search-input"
/>
@if (query().length > 0) {
<button (click)="clear()" class="clear-btn">×</button>
}
<span class="char-count">{{ charCount() }} characters</span>
</div>
`
})
export class SearchInputComponent {
// model() creates a bidirectional Signal
// Modifications propagate to parent
query = model('');
// Classic input for configuration
placeholder = model('Search...');
// Output for additional events
searchSubmitted = output<string>();
// Computed based on model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// Update model - propagates to parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}Батьківський компонент використовує синтаксис banana-in-a-box [()] для двостороннього зв'язування:
// Using two-way binding with model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
<div class="app-container">
<app-search-input [(query)]="searchTerm" />
<p>Current search: {{ searchTerm() }}</p>
<div class="results">
@for (result of filteredResults(); track result.id) {
<div class="result-item">{{ result.name }}</div>
}
</div>
</div>
`
})
export class AppComponent {
// Local signal synchronized with child component
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// Reactive filtering based on searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}input() використовується для даних тільки для читання (батько → дочірній). model() застосовується, коли дочірній компонент має модифікувати значення (двосторонньо).
Signal Queries з viewChild() та contentChild()
Функції viewChild(), viewChildren(), contentChild() та contentChildren() замінюють відповідні декоратори. Вони повертають Signals, усуваючи потребу в хуках життєвого циклу на кшталт ngAfterViewInit.
// Demonstration of signal queries
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';
@Component({
selector: 'app-form-container',
standalone: true,
imports: [FormFieldComponent],
template: `
<form #formElement (submit)="onSubmit($event)">
<input #firstInput type="text" placeholder="Name" />
<app-form-field label="Email" />
<app-form-field label="Phone" />
<div class="actions">
<button type="submit">Submit</button>
<button type="button" (click)="focusFirst()">Focus first field</button>
</div>
</form>
`
})
export class FormContainerComponent {
// viewChild returns Signal<ElementRef | undefined>
formElement = viewChild<ElementRef>('formElement');
// viewChild.required guarantees element exists
firstInput = viewChild.required<ElementRef<HTMLInputElement>>('firstInput');
// Query on a component - returns the component itself
firstFormField = viewChild(FormFieldComponent);
// viewChildren for multiple elements
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect replaces ngAfterViewInit for queries
effect(() => {
// Signal is automatically resolved
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// React to list changes
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// Direct access via Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// Access the form
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}Для проєкції контенту та доступу до нього contentChild() працює аналогічно:
// Using contentChild for projected content
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header class="card-header">
<ng-content select="[card-title]" />
</header>
<div class="card-body">
<ng-content />
</div>
@if (hasFooter()) {
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
}
</div>
`
})
export class CardComponent {
// Detect if footer was projected
footerContent = contentChild<ElementRef>('[card-footer]');
// Computed to check footer presence
hasFooter = computed(() => this.footerContent() !== undefined);
}Готовий до співбесід з Angular?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Виявлення змін без Zone.js (Zoneless)
Angular 18 вводить виявлення змін без Zone.js в експериментальному режимі. Ця функціональність зменшує розмір бандла приблизно на 13 КБ та покращує продуктивність, усуваючи monkey-патчі на асинхронних API браузера.
// Configuring the application in zoneless mode
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// Enable experimental zoneless detection
provideExperimentalZonelessChangeDetection()
]
});Конфігурацію angular.json також необхідно оновити для видалення Zone.js:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}У режимі zoneless виявлення змін спрацьовує автоматично в таких випадках: оновлення Signal, виклик markForCheck(), отримання нового значення через AsyncPipe або підключення/відключення компонента.
// Component optimized for zoneless mode
import {
Component,
signal,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-zoneless-counter',
standalone: true,
// OnPush recommended for zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<p>Counter: {{ count() }}</p>
<button (click)="increment()">Increment</button>
@if (loading()) {
<p>Loading...</p>
}
@if (data()) {
<pre>{{ data() | json }}</pre>
}
</div>
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal<any>(null);
increment() {
// Signal update triggers detection
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals guarantee view updates
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}Компоненти, що використовують ChangeDetectionStrategy.OnPush та Signals, загалом сумісні з режимом zoneless. Слід уникати прямих модифікацій властивостей, які не є Signals.
Міграція існуючих компонентів
Міграція на API, засновані на сигналах, може відбуватися поступово. Нижче наведено приклад рефакторингу традиційного компонента:
// BEFORE: Component with classic decorators
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
<div #container>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class UserProfileLegacyComponent implements AfterViewInit {
@Input() user!: { name: string; email: string };
@ViewChild('container') container!: ElementRef;
ngAfterViewInit() {
console.log('Container ready:', this.container.nativeElement);
}
}// AFTER: Component migrated to Signals
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';
interface User {
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<div #container>
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
</div>
`
})
export class UserProfileComponent {
// input.required replaces @Input() with !
user = input.required<User>();
// viewChild.required replaces @ViewChild with !
container = viewChild.required<ElementRef>('container');
constructor() {
// effect replaces ngAfterViewInit for queries
effect(() => {
console.log('Container ready:', this.container().nativeElement);
});
}
}Переваги цієї міграції: суворіша типізація, автоматична реактивність, менше шаблонного коду та сумісність з режимом zoneless.
Найкращі практики з Signals
Ключові рекомендації для максимального використання Signals в Angular 18:
// Example of best practices with Signals
import {
Component,
signal,
computed,
effect,
untracked,
ChangeDetectionStrategy
} from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="cart">
<h2>Cart ({{ itemCount() }} items)</h2>
@for (item of items(); track item.id) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × \${{ item.price }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
}
<div class="cart-total">
<strong>Total: \${{ total() }}</strong>
</div>
</div>
`
})
export class CartComponent {
// Signal for mutable data
items = signal<Product[]>([]);
// Computed for derived values - avoids unnecessary recalculations
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect for side effects (analytics, persistence)
effect(() => {
const currentItems = this.items();
// untracked avoids creating a dependency
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() for modifications based on previous state
this.items.update(current => {
const existing = current.find(i => i.id === product.id);
if (existing) {
return current.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...current, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(current => current.filter(i => i.id !== id));
}
}Ключові принципи:
- Для похідних значень слід використовувати
computed()замість перерахунку в шаблоні update()є кращим варіантом ніжset(), коли нове значення залежить від попередньогоuntracked()в ефектах запобігає циклічним залежностям- Атрибут
trackв циклах@forобов'язковий для оптимізації рендерингу
Висновок
Angular 18 закладає фундамент для майбутнього без Zone.js завдяки Signals. Ключові висновки:
- input() замінює
@Input()з суворішою типізацією та гарантованим доступом тільки для читання - model() забезпечує реактивне двостороннє зв'язування між батьківським та дочірнім компонентами
- viewChild() та contentChild() усувають потребу в хуках життєвого циклу
- Zoneless зменшує розмір бандла та покращує продуктивність
- computed() та effect() доповнюють реактивну екосистему
- Поступова міграція можлива компонент за компонентом
Впровадження Signals готує застосунки Angular до майбутніх версій, де режим zoneless стане нормою. Ця трансформація є розумною інвестицією в довгострокову підтримуваність та продуктивність.
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
