Чтобы стать продуктивным разработчиком на Angular, потребуется понимание различных деревьев, из которых состоит приложение. На первый взгляд легко можно спутать дерево инжекторов DI и DOM-дерево непосредственных HTML-элементов и вьюх Angular. Они похожи и иногда имеют прямое соответствие, но далеко не всегда. В статье рассмотрим различия, научимся держать их в уме и освоим, как можно обойти возможные трудности, связанные с ними.

Тестовый пример

Предположим, у нас есть our-component с такой структурой:

<parent>
  <child *ngFor=”let child of children”>
    <ng-container 
      *ngTemplateOutlet=”template; context: { $implicit: child }”
    ></ng-container>
  </child>
</parent>

И допустим, мы используем его вот так:

<our-component [children]=”children” [template]=”template”></our-component>
<ng-template #template let-child>
  <some-component [item]=”child”></some-component>
</ng-template>

Шаблон компонента parent будет:

<parent-header></parent-header>
<ng-content></ng-content>

Шаблон child аналогичен:

<child-header></child-header>
<ng-content></ng-content>

Это самый простой расклад, на котором видно нужные различия. Если у нас всего один ребенок, то итоговый DOM будет выглядеть так:

<our-component>
  <parent>
    <parent-header></parent-header>
    <child>
      <child-header></child-header>
      <some-component></some-component>
    </child>
  </parent>
</our-component>

Дерево View

Приложение Angular верхнеуровнево состоит из вьюх, а не из DOM-элементов. Это позволяет поместить директивы на несуществующие элементы, такие как ng-container и переставляемые куски разметки ng-template. Такое деление важно для проверки изменений. Раскрасим итоговую разметку, чтобы увидеть пример в разбивке на view:

Такое неочевидное деление вызвано проекцией контента. Иерархия view выглядит следующим образом:

Хотя some-component и находится внутри child, который внутри parent, он все же часть верхнего view.

Это означает, что если вызвать ChangeDetectorRef.markForCheck() внутри some-component, пометив его и все родительские view для проверки изменений, это повлияет только на самый верхний view

Если компоненты используют OnPush, то обработчики событий внутри some-component не запустят проверку изменений ни в child, ни в parent, ни даже в our-component. Чуть менее важно, но все равно любопытно, что child и parent находятся параллельно на одном уровне, хоть в DOM они и вложены один в другой.

@HostBinding называется так неспроста: он равноценен байндингу на хост-элементе и принадлежит родительскому view.

DI-дерево

Теперь посмотрим на дерево инжекторов. Внутри него общение между директивами можно организовать через паттерны Dependency Injection. Кратко озвучу их и покажу, как различия DI- и DOM-деревьев могут нам мешать.

Если нужны какие-то данные из компонента выше, мы можем заинжектить его в ребенка через конструктор или функцию inject. Это мощный инструмент, так как он проходит всю иерархию вверх и позволяет избежать «дриллинга» входных данных через несколько слоев. В Taiga UI, open-source-библиотеке, которой занимается моя команда, мы используем директивы для задания общих свойств компонентов. К примеру, можно задать размер всех полей ввода прямо на самой форме.

При OnPush-стратегии ваши дочерние компоненты ничего не узнают об изменениях в верхних вьюхах. Помочь с этим может подход контроллеров, который мой коллега @MarsiBarsi описал в своей статье:

Внедрение родителя позволяет детям общаться с ним в обе стороны. Они могут вызывать методы родителя, передавая в них данные. К примеру, компонент аккордеона может разрешать за раз открывать только один пункт:

<accordion>
  <accordion-item>...</accordion-item>
  <accordion-item>...</accordion-item>
  <accordion-item>...</accordion-item>
</accordion>

Дочерние компоненты сообщают родителю, что их открыли, а родитель может приказать всем остальным дочерним элементам закрыться. В этом примере взаимодействие родителя с детьми может осуществляться через @ContentChildren. Этот механизм описан в моей прошлой статье:

Если вам доводилось создавать ControlValueAccessor, вы помните, что дочерний NgControl вызывает методы NG_VALUE_ACCESSOR, передавая колбэки для обновления значения и состояния touched. Это похожий подход.

Еще один распространенный паттерн в DI — отслеживание родителем детей, когда это по какой-то причине невозможно через @ViewChildren/@ContentChildren. Дети регистрируются на родителе, а в ngOnDestroy сообщают ему, что их больше нет. Мы так связывали несколько областей, разнесенных по DOM через порталы. Подробности можно почитать на английском в моей статье про директиву ActiveZone.

Различия с DOM-деревом

Теперь посмотрим ситуации, когда различия между DI- и DOM-деревьями помешают нам использовать описанные подходы. Начнем с такого шаблона:

<parent-component>
  <child-component></child-component>
</parent-component>

Все довольно просто, но что, если мы захотим подействовать на детей внутри шаблона родителя? Напишем шаблон родителя так:

<div myDirective>
  <ng-content></ng-content>
</div>

В результате получим такой DOM:

<parent-component>
  <div myDirective>
    <child-component></child-component>
  </div>
</parent-component>

Поскольку контент рендерится раньше View, child-component не будет находиться под myDirective в дереве DI и не сможет ее заинжектить. Это легко понять, ведь контент является частью верхнего view и потому обработается раньше.

Другая ситуация, в которой DI- и DOM-деревья разнятся, — использование ng-template. Предположим, у нас есть такой компонент:

<child-component [template]=”tmp”></child-component>
<ng-template #tmp>
  <div myDirective>Hey!</div>
</ng-template>

И допустим, что child-component сразу инстанциирует шаблон. Тогда мы получим такой DOM:

<parent-component>
  <child-component>
    <div myDirective>Hey!</div>
  </child-component>
</parent-component>

Хотя myDirective и находится внутри child-component, она является непосредственным ребенком parent-component в дереве DI, так как именно там определен ng-template. Иногда это можно поправить, перенеся ng-template так, чтобы тег child-component его оборачивал.

В новых версиях Angular появилась возможность передать инжектор в ngTemplateOutlet, что может помочь по аналогии с инжектором для ngComponentOutlet.

Работа с различиями

Иногда нужно передать данные от DOM-родителя к DOM-ребенку и обратно, несмотря на DI-структуру. В нашем распоряжении есть нативные инструменты, которые легко использовать в Angular. Это CSS-переменные и кастомные DOM-события. Давайте посмотрим на пример из Taiga UI, чтобы разобраться. 

Наши поля ввода построены с использованием базового компонента PrimitiveTextfield с заложенным внутрь тегом input. Иногда нужно добавить на него нативные атрибуты, такие как inputMode или autocomplete. В таком случае можно написать вот так:

<tui-input>
  <input tuiTextfield inputMode=”email” />
</tui-input>

Что создаст приблизительно такую DOM-структуру:

<tui-input>
  <tui-primitive-textfield>
    <input tuiTextfield inputMode=”email” />
  </tui-primitive-textfield>
</tui-input>

PrimitiveTextfield отвечает за иконку крестика, очищающего поле. Он знает, видна она или нет. Это влияет на правый паддинг у инпута, ведь мы не хотим, чтобы текст на нее заезжал. Но input tuiTextfield не контролируется компонентом PrimitiveTextfield и ничего про него не знает.

В Angular можно легко задавать значение CSS-переменных. Мы даже можем добавить единицы измерения и делать это одинаково просто в шаблоне и с помощью @HostBinding. CSS-переменные проходят через DOM и даже через Shadow DOM, так что они очень полезны при работе с веб-компонентами или нативной инкапсуляцией стилей в Angular-компонентах.

Вот как мы зададим паддинг в PrimitiveTextfield:

@HostBinding(‘style.--padding.rem’)
get padding(): number {
  return this.hasCleaner ? 1.5 : 0;
}

Затем на инпуте просто объявим padding-right: var(--padding). Иногда это помогает избежать лишних проверок изменений, ведь CSS распространяется мгновенно. Это применимо только для стилей, так что польза от такого подхода ограничена, но при разработке низкоуровневых компонентов может очень пригодиться.

Кастомные события

Передать более общие данные можно с помощью кастомных событий. Не получится инициировать передачу данных выше по DOM-дереву, но дети могут вызывать кастомное событие и с его помощью передать и даже запросить данные у родителей. Посмотрим пример — есть компонент навигации со ссылками:

<nav tabs>
  <a tab>Tab</a>
  …
</nav>

Родительский компонент nav tabs отслеживает активную ссылку. Для этого каждая ссылка может заинжектить родителя и оповещать его об активации — через клик или routerLink.

Но что, если мы хотим сделать, чтобы ссылки, которые не уместились, скрывались в выпадашку «Еще»? Для этого сделаем новый компонент-обертку и будем использовать структурные директивы, чтобы переставлять ссылки по своему усмотрению:

<tabs-with-more>
  <a *tab>Tab</a>
</tabs-with-more>

Внутри используем наш nav tabs-компонент:

<nav tabs><!-- здесь будут ngTemplateOutlet`ы для ссылок --></nav>
<button>
  Еще
  <!-- здесь будет выпадашка с остальными ссылками -->
</button>

Теперь ссылки не могут заинжектить nav tabs, поскольку не являются его детьми в DI-дереве. Вместо этого они могут задиспатчить new CustomEvent('tab-activated', {bubbles: true}), а с помощью @HostListener('tab-activated') родители смогут слушать это событие и реагировать необходимым образом. Не забывайте { bubbles: true }, чтобы событие всплывало. Значение по умолчанию — false.

Несколько трюков с событиями

Можно контролировать, какой из родителей отреагирует на событие. Если нужно, чтобы событие обработал ближайший родитель, — их можно слушать как обычно. Можно остановить всплытие, если требуется, а можно пропустить событие и позволить кому-то выше его подхватить. Но если вы хотите, чтобы самый верхний родитель первым поймал это событие, его можно слушать в capture-фазе.

Это просто сделать с библиотекой ng-event-plugins, которую мы создали. Она расширяет возможности Angular по обработке событий — можно написать @HostListener('tab-activated.capture.stop') и декларативно слушать событие в capture-фазе и одновременно остановить всплытие. Я советую обратить внимание на эту библиотеку: работать с ней становится настолько удобнее, что без нее я уже не представляю, как писать на Angular. Если вы используете Taiga UI, то она у вас уже включена.

Еще одна важная особенность обработки событий — синхронность. Хотя поле detail кастомного события изменять нельзя, в него можно положить мутируемый объект. Это позволяет детям запрашивать данные у родителей, а не только передавать.

Представим ради примера, что ссылка хочет спросить у компонента навигации, активна ли она сейчас. Вот как это можно сделать:

readonly element = inject(ElementRef).nativeElement;

active = false;

checkIfActive(): void {
  const detail = { active: false };

  this.element.dispatchEvent(‘is-active’, { bubbles: true, detail })
  this.active = detail.active;
}

Поскольку события обрабатываются синхронно, на следующей же строчке после вызова события все родители уже прореагировали на него. Наш компонент навигации может проверить, активна ли вкладка, и положить результат в detail.active. Потом ссылка может обновить свое внутреннее состояние. Так кастомные события позволяют обмениваться данными через DOM-деревья, если DI-дерево нам не подходит.

Вместо заключения

Надеюсь, после всех объяснений у вас появилось понимание различий DI-, DOM- и View-деревьев в Angular. Это довольно продвинутая тема, но я постарался изложить ее в практической форме. Полезно иметь пару подобных трюков в инвентаре знаний, особенно если нужно писать низкоуровневый код, как нам в Taiga UI. 

Важный урок тут в том, что Angular — это фреймворк на основе простого JavaScript. Чаще всего мы оперируем абстракциями, но понимание нативных механизмов может быть очень важно. Постижение веб-экосистемы поможет на карьерном пути от Angular-разработчика до фронтенд-специалиста.

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


  1. SlamJam
    24.08.2023 14:10
    +1

    Вдохновляюсь каждый раз читая ваши статьи. И каждый раз удивляюсь насколько плохо, оказывается, я знаю Ангуляр, хотя работаю с ним каждый день.
    В статье упомянут кейс с markForCheck, который повлияет только на родителя. А какой верный способ, чтобы обновить все компоненты по дереву view?


    1. Waterplea Автор
      24.08.2023 14:10

      Привет! Спасибо на добром слове ???? Я бы организовал такую ситуацию через общий Observable сервис, объявленный где-то сверху, чтобы у всех необходимых view был до него доступ и через него бы обновлял всё, что надо обновить. Другой вариант — делать у компонентов публичный метод refresh, достукиваться как-то до них и вызывать его. Стоит отметить, что через @View/ContentChild(ren) достучаться сразу до ChangeDetectorRef через read нельзя:
      https://github.com/angular/angular/issues/34347