
На этой неделе команда 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-сущность для отслеживания взаимодействий пользователя со страницей) наконец-то наследуется для вложенных даталистов и дропдаунов автоматически, хотя раньше нашим пользователем приходилось осуществлять лишние телодвижения по разметке этой логики дополнительными директивами (в чем они частенько ошибались).
Новая утилита inject сразу полюбилась нам как альтернативный способ внедрения зависимости без использования конструктора. Нам открылась отличная возможность использовать DI в абстрактных классах без пропс-дриллинга в конструкторе дочернего класса. Также эта утилитка существенно улучшила DX, позволив создавать всевозможные хэлперы, внутри которых заложена работа с Dependency Injection.
Angular 15
Directive Composition API (хост-директивы) — настоящий бриллиант при разработке Angular UI Kit!
Занимательно, что даже сама команда Angular до сих пор не осознала, насколько мощный инструмент декомпозиции они создали: только 3 случая использования этой фичи на весь репозиторий Angular Material. Для сравнения: в репозитории Taiga UI — свыше 90 случаев.
Сценарии использования в нашем продукте настолько широки, что этому даже пришлось посвятить целую отдельную статью. Обязательно загляните: уверен, некоторые приемы вы найдете полезными и для своих рабочих проектов.
Но и здесь не обошлось без ложки дегтя. Мы подметили, что местами новая фича вышла сыровата и не учитывает несколько важных аспектов.
Нет встроенного управления. Трудно контролировать входные данные хост‑директив изнутри хоста. Это нам пришлось дорабатывать уже вручную через утилиту 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
. Подробнее о всей мощи инструмента читайте в статье моего коллеги.
Эволюция проекции контента на этом не закончилась, и в 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 стало самой большой киллер-фичей для ваших проектов?