На этой неделе команда Angular отметила значимый юбилей в истории развития своего фреймворка — 20-ю мажорную версию! Лучше повода не найти, чтобы удариться в ностальгические воспоминания про путь развития Angular за последние 5 лет — за десять последних мажорных версий.

Предлагаю нестандартный подход к изучению темы. Возьмем непопулярную точку зрения: мой многолетний опыт разработки огромной коллекции библиотек с компонентами под Angular — продукт под названием Taiga UI. В статье мы опустим многие заезженные фичи каждой мажорной версии Angular и сфокусируемся на кажущихся мелочах, которые стали значимыми шагами в истории развития нашего семейства библиотек. Я постараюсь на время статьи дать примерить шкуру разработчика Angular UI Kit!

Немного вводных и важных дисклеймеров

Работу над проектом Taiga UI я начал в июне 2021 год. Тогда наши библиотеки имели вторую мажорную версию, а их билд происходил с использованием Angular 9 (на легаси View-Engine-движке). С того момента Taiga UI бурно развивалась, а некоторые ее компоненты успели пережить несколько волн масштабных рефакторингов.

В статье я намеренно не буду останавливаться на некоторых крупных фичах. Например, на типизированных формах, а также SSR, гидратации и тому подобном. Все неупомянутые фичи крайне значимы для разработчика приложений, но могли иметь не столь заметное влияние на библиотеки компонентов. 

И наоборот, то, что для разработчика продуктовых приложений — жалкая мелочь, которую используешь пару раз в год, для мейнтейнеров Taiga UI — крайне важное улучшение, значительно повышающее их DX или открывающее простор для новых архитектурных решений!

Расскажу, чем больше всего запомнилась каждая мажорная версия Angular с точки зрения именно мейнтейнера Angular-библиотек.

Angular 10

Внедрение strict-режима в разработке на Angular. Если в настоящее время уже сложно представить написание кода TypeScript без этого включенного режима, на момент релиза фичи меня еще терзали сомнения: «А точно ли все это надо?» Особенно если взять флаг noImplicitAny, моментально порицающий за попытку схалтурить и начать писать обычный JavaScript вместо TypeScript для каких-нибудь очень коротеньких функций. 

Но, оглядываясь назад, есть четкое понимание, что если ловить баги в духе hello, undefined! в приложении с многомиллионной аудиторией стыдно, то ловить такие же баги в библиотеке, используемой в десятках приложений с многомиллионной аудиторией, — непозволительная роскошь.

Стоит еще вспомнить, что strict-режим включал не только расширенный мониторинг проблем внутри .ts-файлов, но и подключал усиленную проверку типов в шаблонах. 

Строгий режим — однозначно главная киллер-фича десятого релиза!

Angular 11

Анонсирование Hot Module Replacement. Возможность менять что-то в коде и не ждать десятки секунд пересборки Angular-приложения — то, чего хочет каждый из нас. А за возможность моментально увидеть последние изменения кода без полной перезагрузки страницы разработчики готовы продать душу.

В одиннадцатой версии заявилась поддержка HMR. Возможно,  сейчас я удивлю часть читателей, но первые активные разговоры об этом велись не в 2024 году с приходом Angular 19, а четырьмя годами ранее. Чтобы в полной мере ощутить настроения Angular-сообщества по этому вопросу, просто взгляните на комментарии к фича-реквесту. Я насчитал свыше 10 залайканных сообщений с содержанием в духе „We need this feature ASAP“, и там даже нашлось место для угроз типа „I am leaving Angular ASAP!“ ?

Но, к сожалению, первые шаги команды Angular в этом направлении оказались не столь успешными: пользователи сообщали о проблемах с сохранением состояния и полной перезагрузкой приложения. И команда Angular вынуждена была отложить вопрос до лучших времен — только начиная с Angular 17, в дорожной карте фреймворка вновь появляются планы по добавлению HMR.

Не каждая мажорка Angular запомнилась только позитивом. Вот эта, например, сумела оставить неприятный осадок.

Поддержка Webpack 5 — то хорошее, что было в 11-м релизе. Это могло значить добавление долгожданной поддержки Module Federation для микрофронтов на Angular. А для Taiga UI это было добавление встроенной поддержки загрузки содержимого файлов в строковом формате через Assets Modules без необходимости использования сторонних библиотек, таких как Raw‑loader и тому подобных.

Angular 12

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

С релизом Angular 12 объявили слоган Moving Closer to Ivy Everywhere, который сопровождался новостью о депрекейте старого движка View Engine. Стало окончательно понятно, что Ivy — новый стандарт Angular-мира. 

Пользователи Taiga UI в третьей мажорной версии продукта наконец-то перестали уходить на длительные перекуры, чтобы дождаться, пока компилятор ngcc закончит свою работу. Мелочь, а все же забота о здоровье наших пользователей!

В кодовой базе Taiga UI наконец-то стали пропадать магические комментарии @dynamic (а их было свыше сотни во всем проекте!) и такой рудимент, как entryComponents.

Angular 13

Конец поддержки Internet Explorer для Angular-мира. В этом может быть мало заслуги самой команды фреймворка, для них цена вопроса — «решиться на это и удалить легаси полифилы». 

Но для меня это все равно самое важное событие 13-го релиза, ведь такие решения крупных гигантов (как Microsoft, Google и тому подобные) стали отправной точкой для отказа поддержки этого браузера и в моей компании. А Taiga UI очень похорошела без IE: в ее кодовой базе существовало немало костылей, посвященных этому крайне устаревшему браузеру.

Angular 14

Standalone API — новый прекрасный мир, в котором мы можем использовать директивы, компоненты и пайпы без посредников в лице модулей.

Несмотря на то что Standalone API принесло для всех одни плюсы, в новом подходе есть ложка дегтя, с которой мы столкнулись. В Taiga UI мы очень любим декомпозировать все, что может быть быть декомпозировано. Поэтому у нас есть много примеров, где внешне какой-нибудь монолитный компонент на деле оказывается композицией 2—3 компонент или директив. И если в случае старого модульного API это композиция пряталась от наших пользователей библиотек просто внутрь только одного импорта модуля, то с переходом на standalone-компоненты это стало серьезным вопросом.

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

Когда существовали только модули, для пользователя нашей Angular-библиотеки все было просто: импортируешь TuiSliderModule и даже не знаешь про все эти сущности и их композицию. Angular заберет из этого модуля то, что нужно, а о том, что из него так и не использовалось, позаботится tree-shake-механизм. 

Мы начали переводить схожие примеры декомпозированных сущностей на Standalone и стали переживать за DX-пользователей нашего UI Kit. Тот же WebStorm в шаблоне будет подсвечивать предложения импортнуть каждую из этих standalone-сущностей, но такое количество импортов стало бы сильно раздражать пользователя. В общем, проблема, конечно, не столь критичная, но она присутствовала.

Нашлось решение. Мы обнаружили, что, если создать массив с as const (TypeScript-фича const assertion) и перечислить там все нужные нам компоненты и директивы декомпозированной сущности, IDE будет выдавать пользователям подсказку с предложением импорта такого созданного массива. На примере слайдера это выглядело так:

export const TuiSlider = [
   TuiSliderComponent,
   TuiSliderThumbLabel,
   TuiSliderKeyStepsBase,
   TuiSliderKeySteps,
   TuiSliderReadonly,
] as const;

Пользователю оставалось добавить TuiSlider в массив imports своего standalone-компонента. Чтобы избежать возможной путаницы среди пользователей (что стоит импортировать, а что часть декомпозированной сущности), мы также ввели новый Naming Convention: всегда импортить сущности без постфикса Component или Directive.

Так мы получили хороший обходной путь в решении мелкой проблемы, полностью открывший двери в мир Standalone для нашего UI Kit.

Protected-методы стало можно использовать в шаблонах. Возможно, это было незаметное улучшение для разработки приложений, но очень аппетитная фича для разработчиков библиотек. 

У тайговых компонентов есть публичный и внутренний API. Разумеется, нам крайне не хотелось бы, чтобы пользователи наших библиотек лезли во внутреннее API. Нам нужна возможность его активно менять при рефакторинге, не боясь ничего сломать у пользователей. 

Суровая реальность такова, что мы были вынуждены раскрывать часть внутреннего API, ведь использование методов компонента в шаблоне было возможным только при их публичном модификаторе доступа. И к сожалению, пользователи библиотек активно цеплялись за это в своих юнит-тестах и имели высокие риски словить сломанный билд в рамках обновления минорных версий Taiga UI. Все это вставляло нам значительные палки в колеса при рефакторинге, и мы были вынуждены стараться соблюдать обратную совместимость даже для непубличного API. К счастью, после повышения версии Angular в одном из мажорных релизов Taiga UI мы наконец-то скрыли очень много лишнего публичного API через protected-методы.

Optional Injectors in Embedded Views. Возможность передавать свой инжектор при инстанциации TemplateRef — важное улучшение для дропдаунов и прочих «портальных» сущностей в Taiga UI.

viewContainer.createEmbeddedView(templateRef, context, {
   injector: injector, // <-- новый параметр в Angular 14
})

Теперь ActiveZone (DI-сущность для отслеживания взаимодействий пользователя со страницей) наконец-то наследуется для вложенных даталистов и дропдаунов автоматически, хотя раньше нашим пользователем приходилось осуществлять лишние телодвижения по разметке этой логики дополнительными директивами (в чем они частенько ошибались).

Самая сложная директива Taiga UI
Часто необходимо знать, с какой областью страницы взаимодействует пользователь. Например, если вы со...
habr.com

Новая утилита inject сразу полюбилась нам как альтернативный способ внедрения зависимости без использования конструктора. Нам открылась отличная возможность использовать DI в абстрактных классах без пропс-дриллинга в конструкторе дочернего класса. Также эта утилитка существенно улучшила DX, позволив создавать всевозможные хэлперы, внутри которых заложена работа с Dependency Injection.

Angular 15

Directive Composition API (хост-директивы) настоящий бриллиант при разработке Angular UI Kit!

Занимательно, что даже сама команда Angular до сих пор не осознала, насколько мощный инструмент декомпозиции они создали: только 3 случая использования этой фичи на весь репозиторий Angular Material. Для сравнения: в репозитории Taiga UI — свыше 90 случаев.

Сценарии использования в нашем продукте настолько широки, что этому даже пришлось посвятить целую отдельную статью. Обязательно загляните: уверен, некоторые приемы вы найдете полезными и для своих рабочих проектов.

Хост директивы: ключ к декомпозиции
В Angular 15 появилась новая фича, которой не уделяют должного внимания, — Directive Composition API...
habr.com

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

  • Нет встроенного управления. Трудно контролировать входные данные хост‑директив изнутри хоста. Это нам пришлось дорабатывать уже вручную через утилиту tuiDirectiveBinding.

  • Необходимость явного проброса input/output-пропертей. Как показала практика использования хост-директив, в подавляющем большинстве случаев ожидается, что ВСЕ входные данные хост-директив должны торчать наружу. Команда Angular по умолчанию, наоборот, все скрыла, но на деле, возможно, стоило оставлять все входные проперти открытыми по умолчанию и при желании перечислять в массиве те, что требовали скрытия.

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

Angular 16

Сигналы. Асинхронный HostBinding. Благодаря новой механике с превращанием observable в сигналы (через утилиту toSignal из пакета @angular/core/rxjs‑interop) мы наконец-то можем делать асинхронный хост‑байндинг. Раньше для этого использовали собственные надстройки из ng‑event‑plugins, которые были слегка костыльны и неочевидны в использовании. Теперь все очень просто и прозрачно:

import {toSignal} from '@angular/core/rxjs-interop';

@Directive({
   host: {
       '[style.--tui-css-variable]': 'hostBindingValue()',
   },
})
export class SomeDirective {
   anyObservable$!: Observable<any>;
   hostBindingValue = toSignal(this.anyObservable$);
}

Упрощение логики внутри паттерна-контроллеров. Ушли в прошлое сложные штуки вокруг Change Detection для паттерна контроллеров в Taiga UI. Теперь сигналы сами подпинывают проверку изменений при новом значении входного параметра контроллера.

Обязательные инпуты — @Input({ required: true }). Хотя и звучит полезно для UI-Kit-компонентов, но в Taiga UI обязательность инпута не нашла почетного места: только три случая использования на всю кодовую базу. Так уж у нас исторически заведено, что почти на каждую инпут-пропсу всегда находится свое осмысленное дефолтное значение, что нет необходимости делать какой-то входный параметр обязательным.

Трансформеры для инпут-пропертей — приятная мелочь, чтобы применять директиву с булевым значением в таком кратком виде:

<input tuiAutoFocus />

В то время как внутри директивы содержатся следующие строки:

import {Directive, Input} from '@angular/core';
import {
   type BooleanInput,
   coerceBooleanProperty
} from '@angular/cdk/coercion';


@Directive({selector: '[tuiAutoFocus]'})
export class TuiAutoFocus {
   @Input({
       alias: 'tuiAutoFocus',
       transform: coerceBooleanProperty,
   })
   public autoFocus: BooleanInput;
  
   // [...]
}

Чем-то прорывным не стало, но очень удобная возможность имитировать поведение нативных булевых атрибутов.

DestroyRef & takeUntilDestroyed. Пока-пока TuiDestroyService ?

Angular 17

Сигнальные инпуты. Если Angular 16 только приоткрыл щелку в мир сигналов, то с Angular 17 дверь туда широко распахнулась. С появлением сигнальных инпутов сигналами стало по-настоящему удобно пользоваться. Их отсутствие заставляло нас плодить в коде компонентов избыточные сеттеры для инпут-пропертей, когда мы хотим использовать в коде компонента сигналы.

Меньше полагаемся на хуки жизненного цикла компонентов. В тайговых компонентах мы частенько прибегаем к компонентам @ViewChild(‑ren) и @ContentChild(‑ren). Доступ к этим элементам появляется только в хуках жизненного цикла AfterViewInit и AfterContentInit. По итогу в нашей кодовой базе могли появляться подобного рода конструкции:

@Component(...)
export class AnyComponent {
   private contentReady$ = new ReplaySubject<boolean>(1);

   children$ = this.contentReady$.pipe(
       switchMap(() => this.childrenQuery.changes),
   )

   @ContentChildren('ref')
   childrenQuery!: QueryList<any>;

   ngAfterContentInit() {
       this.contentReady$.next(true);
   }
}

Поток contentReady$ оповещает всех своих наблюдателей о готовности продолжить исполнение кода. Хитрости с созданием такого потока делают код чуть более декларативным в сравнении с тем, чтобы просто внутри хука манипулировать всеми наблюдателями напрямую. Но все равно часть бойлерплейта это порождает.

Сигнальные инпуты и viewChild и contentChild значительно меняют правила игры. Можно начать гораздо реже использовать ngOnChanges, ведь сигнальные инпуты и так оповестят всех своих подписчиков — вот она главная прелесть реактивности. А еще можно будет почти забыть и о хуках AfterViewInit и AfterContentInit, ведь созданные через viewChild и contentChild сигналы сами знают, когда нужно обновиться, и все их подписчики автоматически реактивно пересчитаются. Для немногочисленных случаев, когда нам нужно провзаимодействовать с готовым DOM (например, вставить в какой-то из контейнеров целый график, используя какую-ту из либ), Angular уже создал хук afterNextRender.

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

Angular 18

Fallback для <ng-content />. Проекция контента — один из основных способов обеспечить гибкость любого Angular UI-Kit-компонента. Благо команда Angular с умом подошла к его проектированию и снабдила полезными фичами select и ngProjectAs. Подробнее о всей мощи инструмента читайте в статье моего коллеги.

Компоненты-конструкторы: мощь ng-content в Angular
Проекция контента — одна из базовых возможностей Angular, о которой слышали почти все. А с недавних ...
habr.com

Эволюция проекции контента на этом не закончилась, и в 18-й мажорной версии фреймворка появилась возможность задавать ей дефолтное значение, если пользователь компонента так и не сложил ничего внутрь.

События форм контроля. Все классы реактивных форм, наследованные от AbstractControl, получили новое свойство events, которое позволяет подписаться на поток всевозможных событий для данного элемента управления формой.

import {FormControl, Validators} from '@angular/forms';

const control = new FormControl<string>('', Validators.required);
control.events.subscribe(event => {
   // ValueChangeEvent | PristineChangeEvent | TouchedChangeEvent | ...
});

Наконец-то появилась возможность получить уведомление о том, что форм-контрол получил состояние touched. Забавный факт, что мы уже очень давно отслеживаем эту возможность, ведь всю свою историю кодовая база Taiga UI строилась на OnPush‑стратегии проверки изменений. И было только одно исключение — FieldError. Именно из-за отсутствия возможности отследить состояние touched нам и приходилось оставлять этот компонент в Default-стратегии.

Вот что писал тимлид Taiga UI в комментариях к этому фича‑реквесту в 2018 году:

Вся наша библиотека UI построена с использованием исключительно OnPush, и только подлый компонент FieldErrorпортит нам весь праздник.

Синтаксис @let. Как заявляет команда Angular, «новый синтаксис @let решает один из самых залайканных Angular-сообществом issue».

До появления нового синтаксиса команда Taiga UI поставляла структурную директиву *tuiLet, которая позволяла делать объявление локальной переменной в темплейте. И как показывает кодовая база нашей компании, количество использований этой директивы перевалило за 6 тысяч. Подход c объявлением локальной переменной был крайне популярен.

Теперь тайговая версия *tuiLet уходит на почетную пенсию, уступив место молодому встроенному решению, которое бонусом не требует никаких импортов!

Angular 19

Linked signals — новый сигнальный примитив. Продуктовый (и DevRel) лид команды Angular Минько Гечев дает хорошее краткое объяснение новому виду сигналов: «Рассматривай его как writable computed». 

Действительно, при разработке Taiga UI я стремлюсь сохранять декларативный стиль при написании сигналов (чему синтаксис computed очень способствует). Но уже не раз столкнулся с ситуацией, что, создав некий computed-сигнал и прописав все его сигналы-зависимости, я покрыл 99% всех возможных случаев. И есть тот гадкий один процент — исключение, когда нашему computed тоже стоило бы обновиться, так и еще с особым ветвлением логики. Не создавать же под этот кейс-исключение еще один сигнал, от которого наш computed будет зависеть?! Тут linked-сигналы спешат нам на помощь!

Ошибка билда при наличии импортов неиспользованных компонентов. Очень приятная мелочь. После рефакторингов компонента постоянно повисают эти неиспользованные импорты. Да, tree shaking с этим, понятное дело, справится. Но меньше мусора в коде — проще его поддерживать.

Hot Module Replacement. Спустя 4 года и 8 мажорных версий фреймворка наконец-то появилась хорошая поддержка фичи для стилей и шаблонов! ?

Angular 20

Сложно давать оценочные суждения тому, что не успел в полной мере попробовать. Дальнейшая стабилизация ранее экспериментальных фичей, zoneless и так далее — это все, конечно, хорошо. Но я тщательно прошелестил весь changelog последней мажорки и нашел свой алмаз.

Типизация для host bindings. В документации Angular длительное время висит следующее предупреждение:

Всегда отдавайте предпочтение проперте host, а не декораторам @HostBinding и @HostListener. Эти декораторы существуют исключительно для обратной совместимости.

Мотивация команды Angular понятна: по максимуму избавиться от декораторов. @Input, @Output(), @ViewChild, @ContentChild, @HostBinding, @HostListener и тому подобные — все они уже давно имеют современные альтернативы. Раз нужно, значит, будем следовать этим рекомендациям.

Но в сравнении с @HostBinding и @HostListener пропертя host обладает очень плохой проверкой на типизацию (что становилось совсем неравнозначной альтернативой для нас). И на практике нам уже не раз пришлось чинить разные баги, которые не возникли бы при корректно работающей типизации этого свойства.

Благо с Angular 20 это перестанет быть проблемой: починили!

Angular: что нас ждет в следующих версиях

Что же такого еще может произойти в ближайшее время, что опять способно внести суету в обычные серые будни Angular UI-Kit-разработчика? Отказ от Angular-анимаций!

Официальная документация фреймворка уже содержит в себе страницу под названием Migrating away from Angular's Animations package со следующим содержанием:

Почти все функции, поддерживаемые @angular/animations, имеют более простые альтернативы через CSS-анимации. Подумайте об удалении пакета @angular/animations из вашего приложения, поскольку он может занимать около 60 килобайт в вашем пакете JavaScript. Нативные CSS-анимации обладают более высокой производительностью.

Команда Taiga UI оперативно отреагировала на данный тренд (хотя он и не был еще массово анонсирован) и уже превентивно приняла меры по движению в сторону отказа от Angular-анимаций в пользу нативных CSS-анимаций. На практике действительно оказалось, что нативные CSS-анимации очень лаконично и просто решают все те же задачи, что раньше решалось силами Angular. 

Самой трудной задачей оказалось нативно реализовать :leave. На текущий момент не существует удобной альтернативы сделать это без таймеров и бойлерплейта кода. Но мой коллега нашел элегантное решение и этой проблемы. Препятствий к полному отказу от Angular-анимаций у нас не осталось!

Стоит ли ждать в ближайших нескольких мажорных версиях фреймворка депрекейт Angular-анимаций? Не знаю, но очень даже вероятно.

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

Признаюсь, эта статья — один большой эксперимент для меня. Мне было любопытно напрячь свою память и постараться вспомнить, к каким последствиям для нашей разработки UI Kit приводилили те или решения команды Angular. 

На ум сразу приходит цитата из одного хорошего старого фильма: «Жизнь похожа на коробку конфет. Никогда не знаешь, что получишь». Не всегда можно сразу отличить верное от лишнего. Но, обернувшись назад, мы получаем шанс осмыслить пройденный путь — и чуть точнее выбрать дорогу вперед. Надеюсь, что пройденный вместе путь пришелся по душе и вам!

А какое изменение за последние 10 мажорных версий Angular стало самой большой киллер-фичей для ваших проектов?

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