惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
aimingoo的专栏
aimingoo的专栏
F
Fortinet All Blogs
Blog — PlanetScale
Blog — PlanetScale
GbyAI
GbyAI
MongoDB | Blog
MongoDB | Blog
月光博客
月光博客
The Cloudflare Blog
量子位
T
Tailwind CSS Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog
MyScale Blog
MyScale Blog
T
The Blog of Author Tim Ferriss
The GitHub Blog
The GitHub Blog
G
Google Developers Blog
D
DataBreaches.Net
V
Visual Studio Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
U
Unit 42
博客园 - 聂微东
有赞技术团队
有赞技术团队
A
About on SuperTechFans

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Как Angular v22 решил проблему дублирующихся host directives
Святослав Зайцев · 2026-06-19 · via Все публикации подряд на Хабре

Средний

7 мин

22

Привет, Хабр! Я Святослав Зайцев, занимаюсь разработкой Angular-приложений и внутренних библиотек компонентов в Т-Банке.

Недавно в Angular v22 появилось изменение, которое прошло почти незаметно на фоне более громких новинок. Тем не менее оно снимает одно из ключевых ограничений Directive Composition API.

Если вы используете hostDirectives для переиспользования поведения между директивами и компонентами, то могли сталкиваться с ситуациями, когда вполне логичная композиция внезапно заканчивалась ошибкой NG0309.

В статье разберем, почему так происходило раньше, что изменилось в Angular v22 и как новый механизм дедупликации host directives влияет на архитектуру компонентов и дизайн-систем.

Как host directives работали до Angular v22

Directive Composition API позволяет директивам и компонентам подключать другие директивы к своему host-элементу через hostDirectives. Например:

@Directive({
  host: {
    '[class.interactive]': 'true',
  },
})
export class AppearanceDirective {}

@Directive({
  selector: '[appButton]',
  hostDirectives: [AppearanceDirective],
})
export class ButtonDirective {}

@Directive({
  selector: '[appDropdownItem]',
  hostDirectives: [AppearanceDirective],
})
export class DropdownItemDirective {}

Пока все выглядит вполне логично. AppearanceDirective содержит общий визуальный функционал, а ButtonDirective и DropdownItemDirective его переиспользуют. Но реальные UI-примитивы часто комбинируются. Один и тот же элемент вполне может одновременно быть и кнопкой, и пунктом выпадающего меню:

<div appButton appDropdownItem>
  Settings
</div>

До Angular v22 фреймворк обходил обе цепочки директив и находил AppearanceDirective дважды на одном элементе:

appButton
  └─ AppearanceDirective
appDropdownItem
  └─ AppearanceDirective

В результате появлялась ошибка:

NG0309: Directive AppearanceDirective matches multiple times on the same element.

Directives can only match an element once.

Проблема заключалась не в том, что разработчик явно добавил одну и ту же директиву дважды. Дубликат возникал косвенно — как следствие переиспользуемой композиции. Из-за этого hostDirectives становились хрупким инструментом. Авторам библиотек приходилось заранее учитывать все возможные комбинации директив. Общая базовая директива могла внезапно стать непригодной для использования, если две более высокоуровневые директивы оказывались на одном элементе.

Обычно проблему решали одним из трех способов:

  • убирали общую host-директиву из одной из веток;

  • дублировали логику;

  • создавали специальные версии директив для отдельных случаев.

Все эти варианты усложняли поддержку библиотек. Это ограничение обсуждалось в Angular Issue #57846, а реализация нового поведения появилась в PR #67996.

Что изменилось в механизме обработки host directives в Angular v22

На мой взгляд, самое важное изменение состоит из двух частей.

Приоритет получает директива из шаблона. Если Angular находит одну и ту же директиву одновременно через template и через hostDirectives, приоритет получает директива, явно указанная в шаблоне.

Рассмотрим пример:

@Directive({
  selector: '[appTooltip]',
})
export class TooltipDirective {
  text = input('');
}

@Directive({
  selector: '[appIconButton]',
  hostDirectives: [
    {
      directive: TooltipDirective,
      inputs: ['text: tooltipText'],
    },
  ],
})
export class IconButtonDirective {}

Теперь повесим обе директивы на один элемент:

<button
  appIconButton
  appTooltip
  text="Delete item"
>
  🗑️
</button>

Angular обнаружит TooltipDirective дважды:

Template match:
  appTooltip → TooltipDirective

Host directive match:
  appIconButton → IconButtonDirective → TooltipDirective

С новым алгоритмом Angular оставляет директиву, найденную через шаблон, и отбрасывает host directive match. В результате остается исходный API директивы, а не алиас из конфигурации host directive:

<button
  appIconButton
  appTooltip
  text="Delete item <!-- ✓ доступно -->" 
  tooltipText="Delete item <!-- ✗ недоступно -->"
>
  🗑️
</button>

Такое поведение сделано намеренно. Директива, найденная через шаблон, считается полноценным экземпляром директивы со всем ее API. В свою очередь, host directive может публиковать только часть API или переименовывать входные и выходные параметры через алиасы.

Поэтому, если Angular приходится выбирать между двумя вариантами, выигрывает явное использование директивы в шаблоне. Другими словами, если разработчик специально добавил директиву в шаблон, Angular предполагает, что он хочет работать с ее оригинальным API, а не с оберткой поверх нее.

Дубликаты host directives объединяются. Если дубликат возникает только через hostDirectives, Angular создает единственный экземпляр директивы.

@Directive()
export class SharedTriggerState {
  id = trigger-${crypto.randomUUID()};
}

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [SharedTriggerState],
})
export class PopoverTriggerDirective {
  state = inject(SharedTriggerState);
}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [SharedTriggerState],
})
export class DropdownTriggerDirective {
  state = inject(SharedTriggerState);
}

Использование двух директив, которые подключают одну и ту же host directive:

<button appPopoverTrigger appDropdownTrigger>
  Actions
</button>

До Angular v22:

NG0309: SharedTriggerState matches multiple times

Начиная с Angular v22: SharedTriggerState создается один раз, PopoverTriggerDirective получает этот экземпляр через inject(), а  DropdownTriggerDirective получает тот же самый экземпляр.

Именно дедупликация host directive и создание одного общего экземпляра является главным улучшением. Теперь Angular рассматривает композицию директив как граф, в котором разные ветки могут сходиться в одной общей директиве, а не как ошибочную ситуацию с дубликатами.

Inputs и Outputs тоже объединяются

Есть еще один важный нюанс нового механизма — объединение публичного API дублирующихся host directives. По умолчанию host directives не публикуют свои inputs и outputs. Это нужно делать явно.

@Directive()
export class SharedTriggerState {
  triggerId = input.required<string>();
}

Две разные директивы могут публиковать один и тот же input общей host-директивы.

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId'],
    },
  ],
})
export class PopoverTriggerDirective {}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId'],
    },
  ],
})
export class DropdownTriggerDirective {}

Теперь есть возможность использовать обе директивы на одном элементе:

<button
  appPopoverTrigger
  appDropdownTrigger
  triggerId="main-actions"
>
  Actions
</button>

Angular объединяет дублирующиеся host directives и оставляет единственный mapping для triggerId.

Конфликтующие алиасы по-прежнему приводят к ошибке

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

Код считается некорректным, потому что две разные цепочки host directives публикуют один и тот же input triggerId под разными публичными именами:

@Directive({
  selector: '[appPopoverTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId: popoverTriggerId'],
    },
  ],
})
export class PopoverTriggerDirective {}

@Directive({
  selector: '[appDropdownTrigger]',
  hostDirectives: [
    {
      directive: SharedTriggerState,
      inputs: ['triggerId: dropdownTriggerId'],
    },
  ],
})
export class DropdownTriggerDirective {}

При использовании двух таких директив на одном элементе <button appPopoverTrigger appDropdownTrigger></button>, Angular не может безопасно представить один и тот же input одновременно как popoverTriggerId и dropdownTriggerId. В таком случае ошибка останется.

Исправить ситуацию можно двумя способами:

  • использовать одинаковый алиас в обеих ветках;

  • публиковать input только через одну из цепочек композиции.

Что дедупликация host directives означает для Angular-разработчиков

Для большинства разработчиков приложений дедупликация, скорее всего, будет проявляться просто как исчезновение некоторых ошибок NG0309. Для авторов библиотек и дизайн-систем влияние гораздо серьезнее. Теперь можно свободнее строить композицию поведения из небольших специализированных директив, не опасаясь, что через несколько уровней hostDirectives Angular внезапно обнаружит дубликат.

Directive Composition API стала ближе к тому, какой ее изначально представляли разработчики: механизмом сборки сложного поведения из небольших независимых блоков. Это не громкая новая фича и не очередной синтаксический сахар для шаблонов. Но для дизайн-систем, библиотек компонентов и сложных UI-примитивов это изменение снимает серьезное архитектурное ограничение.

Итоги

До Angular v22 одна и та же директива не могла попасть на элемент через несколько путей композиции. Даже если разработчик явно не добавлял ее дважды, Angular завершал работу с ошибкой NG0309.

Теперь фреймворк умеет дедуплицировать такие директивы, объединять их публичный API и создавать единый экземпляр там, где это безопасно.

Используете ли вы hostDirectives в своих проектах? Сталкивались ли с ошибкой NG0309 или похожими ограничениями композиции? Будет интересно обсудить это в комментариях.