Polymorpheus: очень нужная деталька пазла Angular

Angular — отличный фреймворк. А ещё он очень заботливый: множество вещей уже продумано за вас, а когда всё-таки требуется что-то нестандартное — существующее поведение обычно легко переопределить или расширить.
Сегодня я хочу поговорить об одном таком случае и показать инструмент, который я создал много лет назад и с тех пор считаю абсолютной базой для Angular. Речь пойдёт о маленькой библиотеке Polymorpheus, которую мы в Taiga UI используем буквально повсюду, когда нужно отобразить какой-нибудь динамический контент.
Но для начала давайте вспомним, как с этим справляется сам Angular и зачем вообще может понадобиться выходить за рамки стандартных способов.

Динамический контент в Angular
Представим, что у нас есть объект — пользователь, User — которого мы хотим отобразить на странице. Давайте посмотрим, какие варианты Angular предоставляет нам из коробки, на что они способны и какие у них есть ограничения.
Интерполяция
Самый простой способ показать нашего пользователя — просто вывести его на страницу через интерполяцию:
{{ user }}Это вызовет его метод toString() и выведет [object Object], поэтому, если мы хотим получить что-нибудь более осмысленное, понадобится собственная реализация toString():
class User {
constructor(
readonly name: string,
readonly surname: string,
) {}
toString(): string {
return `${this.name} ${this.surname}`;
}
}Но это не самый удобный способ отображать данные. Приходится либо вручную интерполировать поля объекта, либо зашивать его строковое представление непосредственно в сам класс через toString(), что обычно никто не делает. Давайте перейдём к чему-нибудь поинтереснее.
Функция
Вместо того чтобы раскладывать объект по полям прямо в шаблоне или встраивать его строковое представление в сами данные, можно положиться на вызов функции:
{{ stringify(user) }}protected stringify({ name, surname }: User): string {
return `${name} ${surname}`;
}У вызовов функций из шаблона исторически сложилась дурная репутация. Но на самом деле она никогда не была особо заслуженной. Обычно аргумент звучал так: функция вызывается на каждом цикле change detection, следовательно, это плохо.
На практике плохо становится только тогда, когда вычисления внутри действительно тяжёлые. Я довольно подробно тестировал это много лет назад, когда вопрос был куда актуальнее: сигналов ещё не было, а change detection по умолчанию был Eager. Любые операции со строками, математика, булева логика и даже небольшие циклы настолько быстрые, что вы никогда не заметите какого-либо ощутимого падения производительности просто из-за использования функций в шаблонах. Особенно если использовать OnPush и нормально разбивать приложение на вьюхи, чтобы при клике где-нибудь не проверялось на изменения вообще всё приложение.
Тут есть один важный момент — не создавайте внутри таких функций новые массивы, объекты или экземпляры классов. Это не только гораздо более тяжёлая операция, но ещё и фактически новое значение при каждом вызове. А значит, будут триггериться инпуты, перезапускаться циклы и целые куски DOM могут перестраиваться заново.
На первый взгляд этот вариант мало чем отличается от предыдущего, но здесь появляется одно очень важное отличие — контекст.
У нас есть функция представления stringify, которая получает пользователя аргументом. Значит, этот подход одинаково работает с любым пользователем, а результат определяется контекстом — конкретным объектом User.
И это подводит нас к следующему шагу.
Шаблон
Ещё один способ отобразить пользователя — и тот, которым придётся воспользоваться, если нам нужно что-то сложнее примитивов, — ng-template.
Теперь контекст — уже не просто абстрактная идея, а вполне официальный термин Angular. Вот как будет выглядеть наш простой пример с использованием шаблона:
<ng-container
ngTemplateOutlet="template"
ngTemplateOutletContext="{ $implicit: user }"
/>
<ng-template #template let-user>
{{ user.name }} {{ user.surname }}
</ng-template>Именно этим мы воспользуемся, если захотим, например, показать рядом с именем пользователя аватар или добавить какие-нибудь другие Angular-компоненты и директивы.
Иногда можно просто привязаться к [innerHTML], если нужен элементарный HTML вроде жирного текста, но для чего-нибудь хотя бы немного сложного шаблоны — стандартное решение.
Компонент
На самом дальнем конце шкалы сложности динамического контента находятся компоненты.
Шаблоны — это здорово, но что, если мы хотим инкапсулировать какую-то логику и переиспользовать этот блок в совершенно другой части приложения? Здесь на помощь приходят динамические компоненты.
Они создаются через директиву ngComponentOutlet, по аналогии с шаблонами, а входные значения можно передавать через словарь ngComponentOutletInputs.
Но до появления инпутов был другой способ, который лучше вписывается в ментальную модель, которую мы здесь строим, — dependency injection.
Вот как тот же пример выглядел бы с компонентами:
const CONTEXT = new InjectionToken();
@Component({
template: '{{ user.name }} {{ user.surname }}',
})
class UserComponent {
protected readonly user = inject(CONTEXT, { self: true });
}@Component({
template: `
<ng-container
[ngComponentOutlet]="component"
[ngComponentOutletInjector]="injector"
/>
`,
})
class App {
protected readonly component = UserComponent;
protected readonly injector = Injector.create({
providers: [{
provide: CONTEXT,
useValue: { name: 'Alex', surname: 'Inkin' },
}],
parent: inject(INJECTOR),
});
}Здесь dependency injection выступает в роли контекста, в котором создаётся компонент: мы заводим специальный токен CONTEXT, содержащий значения, относящиеся к этому компоненту.
На этом с обзором закончим и наконец перейдём к главной теме.
Универсальный outlet
Наверняка вы уже заметили, что качественной разницы между всеми перечисленными подходами на самом деле нет.
Каждый из них берёт некоторый контент и создаёт его в некотором контексте — просто у базовой интерполяции контекст всегда пустой.
Но в обычном Angular нам приходится обращаться с ними по-разному. Когда мы определяем инпут компонента, каждый раз приходится решать: нам точно хватит строки или потом всё-таки захочется поддержать TemplateRef? Приходится проверять тип внутри @if и разветвлять логику шаблона. Где-то на заднем плане мы постоянно держим в голове этот выбор.
Но что, если относиться к этому примерно так же, как мы относимся к дженерикам в TypeScript?
Что, если бы у нас был тип Content<Context>, который в любой ситуации работал одинаково независимо от того, какой именно контент в него передали?
Так появился Polymorpheus.
В низкоуровневых компонентах нужно быть максимально гибким как в потреблении данных, так и в их представлении. Taiga UI здесь не исключение. Мы довольно быстро поняли: если хотим, чтобы наши API было легко использовать и при этом они давали полную визуальную свободу, нам нужен универсальный outlet.
Поэтому мы выпустили его отдельной библиотекой ещё до того, как открыли исходники самого Taiga UI.
Использование очень похоже на обычные шаблоны, только на вход можно передать любой контент следующего типа:
type PolymorpheusContent<C> =
| Type<unknown>
| TemplateRef<C>
| ((context: C) => number | string)
| number | string;Это, конечно, немного упрощённая версия. В реальности нам нужно как-то отличать Type — то есть конструктор — от обычной функции. Поэтому, чтобы иметь возможность воспользоваться instanceof, нам также нужен класс PolymorpheusComponent, который хранит сам конструктор и опциональный Injector с дополнительными провайдерами, если они вам понадобятся.
Вот как с помощью Polymorpheus можно решить нашу задачу с отображением пользователя:
<ng-container *polymorpheusOutlet="content as primitive; context: { $implicit: user }">
{{ primitive }}
</ng-container>Обратите внимание на ключ
$implicit— это официальное соглашение Angular для контекста шаблонов. Любой ключ контекста можно получить через<ng-template let-key="key">, но если написать просто<ng-template let-user>, будет использован именно дефолтный ключ$implicit.
Теперь, если наш компонент получает content в качестве инпута, пользователь может передать туда что угодно: функцию, шаблон, компонент или просто примитивное значение. И каждый раз это работает одинаково.
Ещё несколько фишек
В большинстве случаев на Polymorpheus можно смотреть просто как на switch case поверх встроенных в Angular способов отображения контента. Но в библиотеке есть ещё несколько дополнительных возможностей.
Дефолтный шаблон
Вы видели, что строки нам пришлось объявить через as primitive, чтобы затем вывести их на страницу с помощью {{ primitive }}.
Причина в том, что Angular не позволяет просто так добавлять произвольный контент в DOM. Всё является ViewRef, прикреплённым к ViewContainerRef.
Поэтому, если нам нужно обработать примитив — неважно, передали его напрямую или его вернула переданная функция, — нам всё равно понадобится шаблон. И именно этот шаблон вы видели внутри *polymorpheusOutlet.
А это значит, что мы можем разветвлять логику в зависимости от того, какой именно контент нам передали.
Рассмотрим такой пример:
<tui-icon *polymorpheusOutlet="content as icon; context: { $implicit }" [icon]="icon" />Здесь дефолтным шаблоном выступает компонент <tui-icon />, а примитивный контент передаётся ему в [icon].
По сути, это позволяет пользователю просто передать имя иконки в инпут. Но если ему захочется — вместо этого он может передать целый шаблон с аватаркой, кнопкой или, к примеру, бейджем.
Получается, что в ситуациях, где примитивный контент имеет какое-то очевидное значение — а такое, на удивление, встречается довольно часто, — мы можем обработать его особым образом.
Например, в диалогах Taiga UI, если просто передать строку, вы получите HTML-параграф плюс кнопку «OK», закрывающую диалог. То есть если вы просто хотите сообщить пользователю что-нибудь, например с жирным или курсивным текстом, и попросить подтвердить прочтение — никакие шаблоны вам вообще не нужны.
PolymorpheusTemplate
Если начнёте пользоваться библиотекой, можете наткнуться на директиву PolymorpheusTemplate. У неё есть два преимущества перед старым добрым TemplateRef:
Она позволяет типизировать контекст, передав его тип в качестве инпута — распространённый workaround для давней проблемы Angular.
Она запоминает
ChangeDetectorRefисходного шаблона.
Шаблоны в Angular следуют change detection вью, в котором они были объявлены, а не вью, в котором они были созданы.
Из-за этого можно попасть в ситуацию, когда вы что-то изменили и ожидаете запуска change detection, а он не происходит. Сейчас, когда практически всё стало сигналами, это уже гораздо менее актуально, но раньше проблема была довольно существенной.
Чтобы с этим разобраться, вместо TemplateRef можно было использовать PolymorpheusTemplate, и PolymorpheusOutlet сам запускал change detection тогда, когда это было необходимо.
PolymorpheusComponent
Как я уже говорил выше, это специальный класс, который нужен нам, чтобы отличать обычные функции от конструкторов компонентов. Заодно через него можно передать кастомный Injector.
Поскольку PolymorpheusOutlet скрывает от нас непосредственное создание Injector, чтобы иметь возможность добавить туда токен контекста, иногда может понадобиться передать другой инжектор вместо того, который находится в месте создания компонента.
Для этого достаточно написать:
new PolymorpheusComponent(component, injector)Внутри динамических компонентов, созданных через PolymorpheusOutlet, получить доступ к контексту можно двумя способами:
Запросить его через DI с помощью хелпера
injectContext<T>()или токенаPOLYMORPHEUS_CONTEXT.Завести инпуты с теми же именами, что и ключи контекста.
В обоих случаях изменение значений внутри контекста запустит change detection без пересоздания компонента.
Что в итоге
На первый взгляд всё это может показаться не таким уж впечатляющим. Но стоит начать использовать библиотеку везде, где вы принимаете кастомный контент, и довольно быстро замечаешь, насколько приятнее становится жить, когда из головы исчезает лишняя когнитивная нагрузка и начинаешь мыслить просто двумя понятиями — контент и контекст.
Это заметно улучшает DX и для вас как автора компонента, и для других разработчиков, которые потом этим компонентом пользуются.
Вот несколько типичных ситуаций, где это особенно хорошо заметно:
Модальные окна. Иногда вам нужен просто быстрый диалог с несколькими HTML-блоками — тут отлично подойдёт шаблон. А иногда хочется переиспользовать один и тот же диалог на разных страницах, например универсальное окно подтверждения. Тогда понадобится компонент. Но вашей инфраструктуре модалок вообще не должно быть до этого дела.
Тултипы. В большинстве случаев нужен просто текст — такой красивый аналог нативного атрибута
title. Но в какой-то момент вам может понадобиться ссылка или кнопка внутри тултипа. Значит, одних строк уже недостаточно и придётся поддерживатьTemplateRef. С Polymorpheus не нужно переделывать API, когда этот момент наступит.Ошибки. Ошибки Angular — это объекты, где ключи соответствуют валидаторам, а значения содержат информацию о найденной проблеме. Когда мы показываем ошибки валидации в форме, удобный вариант — предоставить через DI словарь с понятными пользователю строками. Но что, если мы хотим показать какие-нибудь детали от валидатора, например максимально допустимую длину из
Validators.maxLength? Здесь функция выглядит оптимально:
({requiredLength}) => `Максимальная длина — <b>${requiredLength}</b>`Уверен, вы сможете придумать ещё кучу примеров.
Суть в том, что во многих ситуациях обычной строки вполне достаточно. Когда появляется контекст — строка превращается в функцию. Когда нужны целые блоки DOM — в шаблон. Когда нужна переиспользуемость — в компонент.
Хватит каждый раз забивать себе голову этим выбором. Переходите на следующий уровень абстракции и используйте Polymorpheus.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.