Angular 18 Signals: нові реактивні API та виявлення змін без Zone.js

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

Ілюстрація Angular 18 з Signals та сучасною реактивністю

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.

signals-basics.component.tstypescript
// 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 тільки для читання, гарантуючи, що дані завжди передаються від батьківського до дочірнього компонента без випадкових модифікацій.

book-card.component.tstypescript
// 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:

book-list.component.tstypescript
// 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, який автоматично поширює зміни до батьківського компонента.

search-input.component.tstypescript
// 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 [()] для двостороннього зв'язування:

app.component.tstypescript
// 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)
    );
  });
}
model() vs input()

input() використовується для даних тільки для читання (батько → дочірній). model() застосовується, коли дочірній компонент має модифікувати значення (двосторонньо).

Signal Queries з viewChild() та contentChild()

Функції viewChild(), viewChildren(), contentChild() та contentChildren() замінюють відповідні декоратори. Вони повертають Signals, усуваючи потребу в хуках життєвого циклу на кшталт ngAfterViewInit.

form-container.component.tstypescript
// 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() працює аналогічно:

card.component.tstypescript
// 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 браузера.

main.tstypescript
// 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:

json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

У режимі zoneless виявлення змін спрацьовує автоматично в таких випадках: оновлення Signal, виклик markForCheck(), отримання нового значення через AsyncPipe або підключення/відключення компонента.

zoneless-counter.component.tstypescript
// 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);
    }
  }
}
Сумісність з Zoneless

Компоненти, що використовують ChangeDetectionStrategy.OnPush та Signals, загалом сумісні з режимом zoneless. Слід уникати прямих модифікацій властивостей, які не є Signals.

Міграція існуючих компонентів

Міграція на API, засновані на сигналах, може відбуватися поступово. Нижче наведено приклад рефакторингу традиційного компонента:

typescript
// 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);
  }
}
typescript
// 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:

best-practices.component.tstypescript
// 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 стане нормою. Ця трансформація є розумною інвестицією в довгострокову підтримуваність та продуктивність.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

#angular 18
#angular signals
#zoneless
#signal inputs
#reactivity

Поділитися

Пов'язані статті