Общение компонентов с помощью сигналов¶
Сигналы определяют будущее Angular. Однако сама концепция Signals — это лишь одна часть общей истории. Нам также нужен способ взаимодействия с (суб)компонентами через сигналы. В Angular 17.1 появились входные сигналы, а в Angular 17.2 мы получили двустороннее связывание на основе сигналов и поддержку запросов к контенту и представлению. В дополнение к входным сигналам в версии 17.3 появился новый API вывода.
В этой главе я покажу, как использовать эти новые возможности.
Исходный код
📁 Исходный код (см. разные ветки)
Входные сигналы¶
Входные сигналы позволяют нам получать данные через привязки свойств в виде сигналов. Для описания использования сигнальных входов я использую простой OptionComponent, представляющий — для простоты — невыбираемую опцию. Здесь представлены три таких компонента:
Определение входного сигнала¶
Входные сигналы — это аналог традиционного декоратора @Input:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Эта функция input подхватывается компилятором Angular и выдает исходный код для привязки свойств. Поэтому использовать ее следует только вместе со свойствами. Другие концепции взаимодействия, рассмотренные здесь, также используют эту технику.
Наличие функции вместо декоратора позволяет сообщить TypeScript о правильном типе и о том, включает ли она undefined. В примере, показанном ранее, label становится InputSignal<string> — входным сигналом, предоставляющим string. Значение undefined невозможно, так как input.required определяет обязательное свойство.
Сигнал InputSignal всегда доступен только для чтения и может использоваться как сигнал Signal. Например, шаблон выше запрашивает его текущее значение, вызывая геттер (label()).
Привязка к входному сигналу¶
В случае с нашим InputSignal<string> вызывающий должен передать string:
1 2 3 | |
Если эта строка приходит из Signal, мы должны прочитать ее в шаблоне:
1 | |
Вычисляемые сигналы и эффекты как замена крючкам жизненного цикла¶
Все изменения переданного Сигнала будут отражены InputSignal в компоненте. Внутри компонента оба сигнала связаны через граф, который поддерживает Angular. Хуки жизненного цикла, такие как ngOnInit и ngOnChanges, теперь могут быть заменены на computed и effect:
1 2 3 4 5 6 7 8 | |
Опции для входных сигналов¶
Вот некоторые дополнительные опции для настройки InputSignal:
| Исходный код | Описание |
|---|---|
label = input<string>(); | Необязательное свойство, представленное InputSignal<string | undefined> |
label = input(‘Hello’); | Необязательное свойство, представленное InputSignal<string> with an initial value of Hello |
label = input<string | undefined>(‘Hello’); | Необязательное свойство, представленное InputSignal<string | undefined> с начальным значением Hello. |
Требуемые входы не могут иметь значение по умолчанию!¶
По определению, input.required не может иметь значение по умолчанию. На первый взгляд это логично, однако здесь есть подводный камень: Если вы попытаетесь прочитать значение требуемого ввода до того, как он будет привязан, Angular выбросит исключение.
Следовательно, вы не можете напрямую получить доступ к нему в конструкторе. Вместо этого можно использовать ngOnInit или ngOnChanges. Кроме того, использование входов внутри computed или effect всегда безопасно, так как они впервые срабатывают только после инициализации компонента:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Псевдонимы для входных сигналов¶
И input, и input.require также принимают объект-параметр, который позволяет определить alias:
1 | |
В этом случае вызывающая сторона должна привязаться к имени свойства, определенного псевдонимом:
1 2 3 4 5 6 7 | |
В большинстве случаев следует избегать использования псевдонимов, поскольку они создают ненужную непрямую связь. Часто встречающееся исключение из этого правила — переименование одного из свойств директивы в соответствии с настроенным селектором атрибутов.
Трансформатор для входных сигналов¶
Трансформаторы уже были доступны для традиционных @Inputs. Они позволяют преобразовать значение, переданное через привязку свойства. В следующем случае используется трансформатор booleanAttribute, который можно найти в angular/core:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Этот трансформатор преобразует строки в булевы значения:
1 | |
Также, если атрибут присутствует, но ему не было присвоено значение, предполагается true:
1 | |
Тип этого сигнала — InputSignal<boolean, unknown>. Первый параметр типа (boolean) представляет собой значение, полученное от трансформатора; второй (unknown) — это значение, связанное в шаблоне вызывающей стороны и переданное трансформатору. Кроме booleanAttribute, @angular/core также предоставляет трансформатор numberAttribute, который преобразует переданные строки в числа.
Если вы хотите реализовать собственный трансформатор, просто предоставьте функцию, принимающую связанное значение и возвращающую значение, которое должно быть использовано вызываемым компонентом:
1 2 3 | |
Затем зарегистрируйте эту функцию в вашем input:
1 2 3 4 5 6 7 | |
Двустороннее связывание данных с помощью сигналов модели¶
Входные сигналы доступны только для чтения. Если вы хотите передать сигнал, который может быть обновлен вызываемым компонентом, вам нужно создать так называемый модельный сигнал. Чтобы продемонстрировать это, я использую простой TabbedPaneComponent:
Вот как потребитель может использовать этот компонент:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Ему передается несколько TabComponent. Кроме того, сигнал current привязывается через двустороннее связывание. Для этого TabbedPaneComponent должен предоставить сигнал модели, используя model:
1 2 3 4 5 | |
Здесь 0 — это начальное значение. Опции аналогичны опциям для ввода: model.required определяет обязательное свойство, и вы можете указать псевдоним через объект options. Однако трансформатор не может быть определен.
Если этот компонент обновляет сигнал модели, новое значение распространяется до сигнала, связанного в шаблоне:
1 | |
Двусторонняя привязка данных как комбинация входа и выхода¶
Как обычно в Angular, двусторонние привязки на основе сигналов могут быть определены с входом (только для чтения) и соответствующим выходом. Имя Output должно быть именем Input с суффиксом Change. Таким образом, для current нам нужно определить currentChange:
1 2 3 4 5 | |
Для настройки вывода мы используем новый API вывода. Для запуска события приложение должно вызвать метод emit выхода:
1 2 3 | |
Запросы содержимого с помощью сигналов¶
Представленный в предыдущем разделе TabbedPaneComponent также позволяет нам продемонстрировать еще один вариант: Запросы содержимого, которые получают проецируемые компоненты или директивы.
Как показано выше, в TabbedPaneComponent передается несколько TabComponents. Они проецируются в представление TabbedPaneComponent. Однако в данный момент мы хотим отображать только один из них. Следовательно, TabbedPaneComponent должен получить программный доступ к своим TabComponents. Это можно сделать с помощью новой функции contentChildren:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | |
Функция contentChildren является аналогом традиционного декоратора @ContentChildren. Так как TabComponent был передан в качестве так называемого locator, он возвращает Сигнал с Массивом, содержащим все спроецированные TabComponent.
Наличие проецируемых узлов в виде сигнала позволяет нам реактивно проецировать их с помощью computed. В приведенном примере эта опция используется для получения сигнала currentTab.
Проецируемый TabComponent использует этот сигнал, чтобы узнать, должен ли он быть видимым:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
Для этого нам нужно знать, что мы можем получить всех родителей, расположенных в DOM, с помощью инъекции зависимостей. Сигнал visible является производным от сигнала currentTab.
Эта процедура обычна для реактивного мира: Вместо того, чтобы императивно задавать значения, они декларативно выводятся из других значений.
Запросы содержимого для потомков¶
По умолчанию запрос содержимого раскрывает только прямые дочерние элементы содержимого. «Внуки», как на 3-й вкладке ниже, игнорируются:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Чтобы также получить информацию о таких узлах, мы можем установить опцию descendants в значение true:
1 | |
API вывода¶
Для обеспечения симметричности API в Angular 17.3 появился новый API вывода. Как уже было показано ранее, функция output теперь используется для определения события, предоставляемого компонентом. Как и в случае с новым API ввода, компилятор Angular подхватывает вызов output и выдает соответствующий код. Для запуска события используется метод emit возвращаемого OutputEmitterRef:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Предоставление Observables в качестве выходов¶
Помимо этого простого способа настройки выходов, вы можете использовать Observable в качестве источника для выхода. Для этого в RxJS interop layer есть функция outputFromObservable:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | |
Функция outputFromObservable преобразует Observable в OutputEmitterRef. В показанном примере оператор scan запоминает предыдущую активированную вкладку, а skip гарантирует, что при первоначальной установке current не будет выдано событие. Последнее обеспечивает равенство возможностей с показанным ранее примером.
Запросы просмотра с сигналами¶
В то время как запрос содержимого возвращает проецируемые узлы, запрос вида возвращает узлы из своего собственного представления. Это узлы, найденные в шаблоне соответствующего компонента. В большинстве случаев предпочтительным решением является использование привязки данных. Однако в некоторых ситуациях необходимо получить программный доступ к дочернему представлению.
Чтобы продемонстрировать, как запрашивать дочерние элементы представления, я использую простую форму для установки имени пользователя и пароля:
Оба поля input помечены как required. Если при нажатии Save валидация не проходит, то фокус должно получить первое поле с ошибкой валидации. Для этого нам нужен доступ к директиве NgForm, которую FormModule добавляет к нашему тегу form, а также к узлам DOM, представляющим поля input:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | |
Оба действия выполняются с помощью функции viewChild. В первом случае в качестве локатора в примере передается тип NgForm. Однако простое определение местоположения полей с типом не работает, так как может быть несколько дочерних полей с этим типом. Поэтому входы помечаются хендлами (#userName и #password), а в качестве локатора передается имя соответствующего хендла.
Дочерние элементы представления могут быть представлены различными типами: Тип соответствующего компонента или директивы, ElementRef, представляющий его узел DOM, или ViewContainerRef. Последний тип используется в следующем разделе.
Нужный тип можно указать с помощью опции read, использованной в предыдущем примере.
Запросы и ViewContainerRef¶
Бывают ситуации, когда необходимо динамически добавить компонент в placeholder. Примером могут служить модальные диалоги или тосты. Простой способ добиться этого — использовать директиву *ngComponentOutlet. Более гибким способом является запрос к ViewContainerRef плацдарма.
Контейнер представлений можно рассматривать как невидимый контейнер вокруг каждого компонента и статического HTML. Завладев им, вы можете добавлять другие компоненты или шаблоны.
Чтобы продемонстрировать это, я использую простой пример, показывающий тост:
В примере используется ng-container в качестве заполнителя:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Свойство read дает понять, что мы хотим читать не компонент-плейсхолдер, а его ViewContainerRef. Метод createComponent инстанцирует и добавляет ToastComponent. Возвращаемый ComponentRef используется для установки свойства label нового компонента. Для этого используется его метод setInput. Через две секунды метод destroy снова удаляет тост.
Для простоты здесь был жестко закодирован компонент ToastComponent. В более общих реальных сценариях используемый компонент можно настроить, например, вызвав сервисный метод, приняв тип Компонента и уведомив другой Компонент, который добавит Компонент этого типа в placeholder.
Программная настройка вывода¶
В предыдущем примере для присвоения значения входу title компонента ToastComponent была вызвана функция setInput. Здесь я хочу обсудить, как определить обработчики событий для таких динамически добавляемых компонентов.
Предположим, что ToastComponent показывает ссылку подтверждения:
При нажатии на эту ссылку он выдает событие confirmed:
1 2 3 4 5 6 7 8 9 | |
Чтобы создать обработчик этого события, мы можем напрямую использовать свойство instance возвращаемого ComponentRef. Оно указывает на добавленный экземпляр компонента и, следовательно, предоставляет доступ ко всем его свойствам:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | |
Метод OutputEmitterRef subscribe позволяет определить обработчик события. В нашем случае он просто удаляет тост с помощью destroy и записывает полученную строку в консоль.
Однако в этом примере есть небольшая ошибка. Независимо от того, нажмет пользователь на ссылку подтверждения или нет, пример вызывает destroy через 5 секунд. Следовательно, тост может быть удален дважды: один раз после подтверждения, а другой раз после отображения в течение 5 секунд.
К счастью, уничтожение компонента дважды не приводит к ошибке. Чтобы решить эту проблему, мы можем ввести флаг destroyed. В следующем разделе показан более мощный подход: Потребление выходов как Observables.
Потребление выходов в качестве Observables¶
Несмотря на то, что OutputEmitterRef предоставляет метод subscribe, он не является Observable. Однако оригинальный EventEmitter, используемый вместе с декоратором @Output, был таковым. Чтобы вернуть все возможности, связанные с выводами на основе Observable, вы можете использовать функцию outputToObservable, которая является частью RxJS interop layer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
Функция outputToObservable преобразует OutputEmitterRef в Observable. В приведенном примере она используется для выражения события подтверждения и 5 секундного тайм-аута как наблюдаемых. Оператор race гарантирует, что будет использован только тот Observable, который первым выдал значение.
Observable, возвращаемый оператором outputToObservable, завершается, когда Angular уничтожает компонент вывода. По этой причине нет необходимости отписываться вручную.
Паритет между запросами к содержимому и представлению¶
До сих пор мы работали с contentChildren для запроса нескольких проектируемых дочерних элементов и viewChild для получения одного узла в представлении. Однако у обеих концепций есть паритет возможностей: Например, существует функция contentChild и viewChildren.
Кроме того, все опции, которые мы использовали выше для запросов к представлению или содержимому, такие как использование дескрипторов в качестве локаторов или использование свойства read, работают для обоих типов запросов.
Заключение¶
Несколько новых функций заменяют декораторы свойств и помогают настроить концепции связывания данных. Эти функции подхватываются компилятором Angular и выдают соответствующий код.
Функция input определяет входы для привязки свойств, model определяет входы для двухсторонней привязки данных, а contentChild(ren) и viewChild(ren) заботятся о запросах к содержимому и виду. Использование этих функций приводит к появлению сигналов, которые могут быть спроецированы с помощью computed и использованы в эффектах.




