Привет всем! Меня зовут Егор Молчанов, я разработчик в компании Домклик.
Хочу рассказать вам о новых функциях 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. Как вам подход с сигналами? Используете ли вы их уже в своих проектах? Поделитесь своим опытом в комментариях.