Привет всем! Меня зовут Егор Молчанов, я разработчик в компании Домклик.

Хочу рассказать вам о новых функциях Angular: input(), output() и model(). Они появивились сравнительно недавно и обещают в скором времени заменить привычные нам декораторы @Input и @Output. Разберëм, что они собой представляют, как использовать на практике, и как связаны с концепцией сигналов. Поехали!

Для чего были добавлены?

Последние обновления Angular направлены на полную замену Zone.js новой системой Signals.

В версии Angular 17 представлен набор новых API, обеспечивающих работу с сигналами: input(), output() и model(). Эти API предлагают альтернативный подход к обмену совойствами между компонентами и отслеживания изменений этих свойств, которые призваны заменить традиционный подход в Angular, основанный на Zone.js.

input() в сравнении с @Input

В Angular функция input() и декоратор @Input используются для передачи данных от родительского компонента к дочернему. Они импортируются из модуля @angular/core.

import { input, Input } from '@angular/core';

Для наглядности приведу несколько примеров реализации получения свойств с использованием обоих подходов.

Необязательное свойство:

export class ChildrenComponent {
  @Input() data?: Book[]
  
  data: InputSignal<Book[] | null> = input()

Свойство c изначальным значением:

export class ChildrenComponent {
  @Input() data: Book[] = [{id: 2, title: 'Сказка о рыбаке и рыбке'}]

  data: InputSignal<Book[]> = input([{id: 2, title: 'Сказка о рыбаке и рыбке'}])

Обязательное свойство:

export class ChildrenComponent {
  @Input({required: true}) data!: Book[]

  data: InputSignal<Book[]> = input.required()

Свойство с использованием псевдонима:

export class ChildrenComponent {
  @Input({alias: 'books'}) data?: Book[]

  data: InputSignal<Book[]> = input(null, {alias: 'books'})

Свойство, которое можно трансформировать:

export class ChildrenComponent {
  @Input({transform: (value: number) => value * 2}) data: number = 0

  data: InputSignal<number> = input(0, {transform: (value: number) => value * 2})

Как видите, функция input() сохранила все возможности декоратора @Input, но с ключевым отличием: она преобразует полученное значение в объект InputSignal.

InputSignal похож на обычный сигнал, но без методов set() и update(), которые позволяли бы изменять значение сигнала. Это означает, что InputSignal доступен только для чтения.

Несмотря на ограничение, InputSignal может успешно использоваться совместно с такими API как computed и effect, о которых мы поговорим позже.

Поскольку input() возвращает сигнал, для получения конкретного значения необходимо полученную data вызывать как функцию.

Передача свойства от родительского компонента к дочернему никак не изменилась.

<app-children [data]="books"></app-children>
<app-children [data]="books$ | async"></app-children>
<app-children [data]="books()"></app-children>

Мы можем передать обычную переменную, через async pipe или сигнал, предварительно вызвав его. Независимо от выбранного метода все входные данные в конечном итоге преобразуются в InputSignal.

Как отслеживать изменения у input()

В разработке часто возникает необходимость выполнять действия при изменении входных свойств компонента. Используя декоратор @Input, у нас есть несколько способов отслеживания этих изменений:

  • ngOnChanges — это жизненный цикл компонента Angular, который принимает объект SimpleChanges, cодержащий информацию об изменённых свойствах: как предыдущее значение previousValue и текущее значение currentValue.

    Вызывается ngOnChanges первый раз при инициализации компонента, перед первым вызовом ngOnInit, когда свойства компонента получают свои начальные значения. И при изменении свойств каждый раз, когда значение одного или нескольких свойств компонента меняется.

    Поэтому часто можем встретить код, когда сравнивается предыдущее и текущее значение определённого свойства или сразу нескольких, и только потом выполняем какую‑то логику:

    ngOnChanges(changes: SimpleChanges) {
      const prevValue = changes.data.previousValue;
      const currentValue = changes.data.currentValue;
    
      if (prevValue != currentValue) {
        // что-то делаем...
      } 
    }
  • @Input set — это сеттер для входного свойства в Angular, который вызывается, когда значение этого свойства изменяется. Он получает новое значение свойства в качестве параметра, но не предоставляет информацию о предыдущем значении:

    @Input() 
    set data(value: Book[]) {
      // какая-то логика
    }

Функция input() в Angular позволяет нам тоже отслеживать изменения свойств компонентов с помощью жизненного цикла ngOnChanges. Однако, в отличие от традиционного декоратора @Input(), у input() отсутствует сеттер.

Вместо сеттера, мы можем использовать возможности сигналов для отслеживания изменений свойств в input(). Сигналы предоставляют нам такие API как computed и effect для отслеживания изменений в сигналах:

  • сomputed — это особые функции, которые получают входные сигналы, на основе которых генерируют новый выходной сигнал. Важно отметить, что вычисляемые сигналы не имеют методов set() и update(). Это связано с их природой: они не хранят данные, а просто вычисляют их на основе входных сигналов. Представьте, что мы хотим отобразить пользователю не более трёх книг. Используя вычисляемый сигнал, мы можем создать функцию, которая получает InputSignal со списком всех книг и возвращает только первые три элемента:

    export class ChildrenComponent {
      data: InputSignal<Book[] | undefined> = input()
      filteredData = computed(() => {
        return this.data()?.slice(0, 3);
      });

    Также сomputed имеет опциональный параметр equal в объекте options. Функция equal принимает два аргумента: предыдущее значение, вычисленное computed, и новое значение, которое computed собирается вернуть. Она должна вернуть булево значение.

    Если equal возвращает true, то filteredData не будет обновляться, так как новое значение считается идентичным предыдущему.

    А если equal возвращает false, то filteredData обновится новым значением, так как оно отличается от предыдущего.

    Пример однократного изменения filteredData:

    export class ChildrenComponent {
      data: InputSignal<Book[] | undefined> = input()
      filteredData = computed(() => {
        return this.data()?.slice(0, 3);
      }, {
        equal: (before?: Book[], next?: Book[]) => {
          return Boolean(before);
        }
      });
  • Функция effect — это мощный инструмент, который позволяет выполнять побочные эффекты в компонентах. Она работает аналогично хуку useEffect в React: вызывается при инициализации компонента и каждый раз после изменения сигналов, переданных в неё. И не предоставляет доступ к предыдущему значению входных сигналов.

    Вы можете объявить effect в конструкторе или присвоить его свойству компонента:

    // в конструкторе 
    constructor() {
     effect(() => {
        this.filteredData()
        // какая-то логика
      });
    }
    
    // присвоить свойству компонента
    private filteredDataEffect = effect(() => {
      this.filteredData()
      // какая-то логика
    });
    

    Также функция effect возвращает объект EffectRef, у которого есть метод destroy(), вызов которого приводит к уничтожению эффекта:

    this.filteredDataEffect.destroy();

Таким образом функция input() предоставляет более гибкие возможности для отслеживания входных данных, их преобразования и выполнения каких-либо действий, в отличие от декоратора @Input.

output в сравнении с @Output

Давайте сравним директиву @Output с функцией output() на примере простого Emitter, который будет оповещать об изменении счётчика:

export class ChildrenComponent {
  @Output() editCounter: EventEmitter<number> = new EventEmitter<number>();

  editCounter: OutputEmitterRef<number> = output();

Напишем функцию, которая будет увеличивать счётчик:

onCounter() {
  this.counter++;
  this.editCounter.emit(this.counter);
}

Изменения в работе с выходными данными output() коснулись только возвращаемого объекта. Теперь вместо EventEmitter мы получаем OutputEmitterRef. Функциональность осталась прежней, мы тоже используем метод emit для передачи данных родителю.

Как и в случае с input(), вы можете использовать alias для настройки имени выходного события.

В родительских компонентах прослушивание выходных событий осталось неизменным, вне зависимости от того, используете ли вы директиву @Output или функцию output():

<app-children (editCounter)="onEditCounter($event)"></app-children>

onEditCounter будет получать значение в качестве входных данных, без преобразования их в сигнал, как это было в случае с input():

onEditCounter(event: number): void {
  console.log(typeof event)
}

Можно подчеркнуть, что у EventEmitter метод subscribe возвращал объект Subscription, что позволяло использовать возможности pipe из RxJS при обработке событий. OutputEmitterRef также может подписаться на события через subscribe, но теперь мы получаем объект OutputRefSubscription, который не поддерживает pipe. Тем не менее, у этого объекта всё ещё есть метод unsubscribe для отписки от событий.

this.editCounter.subscribe(res => {
    // что-то делаем 
})

Кроме того, подписаться на изменения @Output или output() можно и в родительском компоненте, используя директиву @ViewChild или функцию viewChild(). Но это уже совсем другая тема :)

Давайте перейдём к последней функции, о которой я хотел рассказать.

model()

Функция model() объединяет в себе функциональность input() и output(), обеспечивая механизм двусторонней привязки данных. Такая привязка позволяет передавать данные из родительского компонента в дочерний и отправлять изменения этих данных обратно родителю. Раньше для реализации двусторонней привязки использовалась комбинация директив @Input и @Output:

// Дочерний компонент
export class ChildrenComponent {
  @Input() counter: number = 0;
  @Output() editCounter = new EventEmitter<number>();
<!-- Родительский компонент -->
<app-children 
  [counter]="counter"
  (editCounter)="onEditCounter($event)"
  >
</app-children>

Функция model() упрощает этот процесс:

// Дочерний компонент
export class ChildrenComponent {
  counter: ModelSignal<number> = model(0)
<!-- Родительский компонент -->
<app-children [(counter)]="counter"></app-children>

Функция model() возвравращает нам объект ModelSignal. Это сигнал, практически идентичный InputSignal, за исключением отсутствия функции transform. Он также обладает опцией alias, позволяющей задать псевдоним для сигнала.

Отслеживать изменения model() можно аналогично с помощью ngOnChanges, computed и effect.

Для изменения свойства counter, можно использовать методы set() и update():

this.counter.set(3)
this.counter.update((c) => c + 1);

В отличие от EventEmitter, ModelSignal не обладает методом emit(), но изменения, внесённые с помощью set() или update(), автоматически отправляют событие об изменении в родительский компонент. Таким образом, значение counter в родительском компоненте тоже обновится.

Заключение

Это всё, что я хотел рассказать о таких функциях как input(), output() и model(). Angular постоянно развивается, и мы можем ожидать, что эти API будут дальше совершенствоваться и расширяться. Информация в статье актуальна для Angular версии 18.

Интересно узнать ваше мнение об этих функциях в Angular. Как вам подход с сигналами? Используете ли вы их уже в своих проектах? Поделитесь своим опытом в комментариях.

Комментарии (3)


  1. halatik92
    05.11.2024 20:44

    Спасибо, полезная статья


  1. Ziggi1111111
    05.11.2024 20:44

    Ну нет же. Для родителя, в случае model, ничего не поменялось. Просто в дочернем это объявлялось как
    @Input()someValue
    @Output()setSomeValue
    А в родителе так и было [(someValue)]