Розбираємо standalone-компоненти у Angular
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами досвідом використання standalone-компонентів в Angular-проєктах. Це все ще досить нова фіча, яка постійно розвивається і просувається розробниками цього фреймворку. Тому я хочу розповісти про неї більш детально і описати особливості та проблеми при міграції проєктів на standalone-варіант. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче дізнатися більше про нові фічі в Angular і особливості їхнього використання.
Що таке-standalone компоненти
Standalone-компоненти (також pipes та директиви) — це не нова функціональність у Angular. Вони з’явилися ще в Angular 14 як developer preview фіча і були призначені для створення компонентів, не прив’язаних до Angular-модулів. З того часу розробники Angular вклали багато зусиль, щоб ця фіча стала стабільною. Більш того, починаючи з Angular 19, стандартні компоненти є standalone. Чому ж розробники фреймворку прагнуть запровадити такі компоненти?
З перших версій Angular модулі були природними контейнерами для компонентів, pipes, директив та сервісів. Більш того, Angular був єдиним сучасним вебфреймворком, в якому одиницею використання був не компонент, а модуль. Навіть для створення найпростішого застосунку з одного компонента потрібно було обов’язково створювати для нього модуль. Це ускладнювало дизайн застосунків, SPA (lazy-loading), читабельність коду та багато іншого. Однак в останні роки розробники Angular виступали за поступове уникнення модулів і використання їхніх елементів безпосередньо. Це було по’язано з різними причинами, наприклад, модулі неможливо було включити у процес tree-shaking. Тобто якщо якийсь модуль імпортувався, але не використовувався, він все одно потрапляв у bundle (хоча його елементи не потрапляли). Також це спрощувало розробку та тестування таких компонентів.
Були й інші причини. Angular спочатку створювався як фреймворк для створення застосунків, а не бібліотек компонентів, але поява нових більш перспективних конкурентів призвела до того, що його розробники задумалися над спрощенням свого API в плані роботи з компонентами. Ще в Angular 6 з’явилися Angular Elements (зараз вони називаються Custom Elements), які дозволяли створювати компоненти в Angular, а потім перетворювати їх на native web компоненти, які можна було використовувати будь-де.
👇 А ви вже чули, що 21 червня DOU Mobile Day?
Ще одна причина створення таких компонентів — це спроба розробників Angular розпаровувати процес збирання шляхом розбиття її на локальну збірку кожного юніту (наприклад, компонента). Так давно робиться в інших вебпроєктах (Vue, Svelte), але досі неможливо в Angular в силу монолітності всього застосунку. У той же час сучасні засоби складання (esbuild, Vite) підтримують розпаралелювання, тому відхід від модулів має допомогти у цьому вдосконаленні.
З іншого боку, модулі мають таку корисну функцію як encapsulation. Ви можете вказати в самому модулі, які елементи ви експортуєте назовні для зовнішнього використання, які є внутрішніми для самого модуля. Модулі дозволяють зробити layout елементів природним чином — розташувати їх у тих модулях, до яких вони прив’язані. Тоді як standalone-компоненти можуть створити хаос у тому випадку, коли їх сотні і тисячі в одному проєкті.
Єдиний спосіб переконатися в користі або навпаки, в марності нової фічі — це застосувати її на практиці.
Використовуємо standalone-компоненти
Для міграції ми вибрали один з невеличких проєктів, який в даний момент використовує Angular 19. У ньому кілька модулів і використовується lazy-loading для них на основі SPA. Відразу скажу, що я не описуватиму міграцію кожного компонента та елемента, оскільки це зайняло б занадто багато часу, тому виберу спрощений підхід, але розповім про типові завдання, проблеми та шляхи їх вирішення. Існує автоматична міграція, про яку я поговорю трохи пізніше, але вона не конвертує 100% коду, не застрахована від помилок, а ручна міграція дозволить пояснити відмінності standalone-елементів і різні варіанти їх використання.
Почнемо з першого модуля — UserModule:
@NgModule({ declarations: [SettingsComponent], imports: [ CommonModule, RouterModule.forChild(routes), MatButtonModule, MatSnackBarModule, TranslateModule.forChild({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient] } })] }) export class UserModule { }
...де знаходиться елемент SettingsComponent, в якому атрибут standalone встановлений в false (це робиться автоматично при переході на Angular 19):
@Component({ selector: ’app-settings’, templateUrl: ’./settings.component.html’, standalone: false }) export class SettingsComponent {
Спробуємо перетворити цей компонент на standalone. Почнемо з першого кроку, видаливши атрибут standalone (за умовчанням він true):
@Component({ selector: ’app-settings’, templateUrl: ’./settings.component.html’, }) export class SettingsComponent {
IDE відразу ж лається на оголошення цього компонента в модулі:
@NgModule({ declarations: [SettingsComponent],
Component SettingsComponent is standalone, and cannot be declared in an Angular module. Did you mean to import it instead?
Тому видаляємо атрибут declarations, щоб позбавитися цієї помилки. Але справа в тому, що модуль UserModule нами використовувався не тільки як контейнер для компонентів, але і для такої фічі, як lazy-loading modules у SPA-маршрутах:
export const routes: Routes = [ {path: ’settings’, loadChildren: () => import(’./user/user.module’).then(m => m.UserModule)},
Як же тепер бути? Можна використовувати інший атрибут component, але тоді більше не буде lazy-loading:
export const routes: Routes = [ {path: ’settings’, component: SettingsComponent},
На щастя, замість атрибуту loadChildren можна використовувати альтернативу — новий атрибут loadComponent, який дозволяє завантажувати компоненти on-demand:
export const routes: Routes = [ {path: ’settings’, loadComponent: () => import(’./user/settings/settings.component’).then(c => c.SettingsComponent)},
Спробуємо перевірити роботу застосунку, запустивши його. Відразу отримуємо помилку:
[ERROR] NG8004: No pipe found with name ’translate’. [plugin angular-compiler]
src/app/user/settings/settings.component.html:2:113:
2 │ ...abled]="authenticated()">{{ ’settings.login’ | translate}}</button>
Справді, раніше ми використовували модулі для імпорту інших модулів (зокрема і TranslateModule для локалізації). А вже TranslateModule імпортував свої компоненти та pipes (включаючи TranslatePIpe). Тепер ми можемо імпортувати модулі прямо в компонентах (якщо вони standalone), тому перенесемо імпортовані модулі з UserModule в SettingsComponent:
@Component({ selector: ’app-settings’, templateUrl: ’./settings.component.html’, imports: [ CommonModule, MatButtonModule, MatSnackBarModule, TranslateModule.forChild({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient] } })] }) export class SettingsComponent {
Але не для всіх модулів це можливо. IDE лається на те, що саме з TranslateModule цей фокус не пройде:
TS2322: Type ModuleWithProviders<TranslateModule> is not assignable to type readonly any[] | Type$1<any>
Проблема в тому, що функція TranslateModule.forChild не повертає модуль з компонентами, директивами та іншими елементами (наприклад, як MatButtonModule), а налаштовує конфігурацію для DI даного модуля. Тому використовувати її в атрибуті imports неможливо. Потрібно просто проімпортувати сам модуль:
imports: [ CommonModule, MatButtonModule, MatSnackBarModule, TranslateModule ]
Запустимо застосунок. Виходячи з логів можна помітити, що у нас тепер є окремі chunks не тільки для модулів, але і для нового standalone-компонента:
Lazy chunk files | Names | Raw size payment.module-KUXIIGHW.js | payment-module | 15.39 kB | settings.component-USOBUZ37.js | settings-component | 5.80 kB |
При цьому якщо раніше ми обов’язково додавали компоненти до атрибута declarations, то тепер це робити не потрібно і фактично ми лише один раз описуємо використання SettingsComponent (у декларації маршрутів в атрибуті loadComponent).
Спробуємо зібрати застосунок та перевірити, чи змінився розмір bundle. Тепер він складає 1088 кб (а був 1097 кб). Таким чином відмова від модуля зекономила нам 9 кілобайт. Подивимося, наскільки можна оптимізувати наш компонент. Зараз у нас в imports для SettingsComponent вказані модулі, елементи яких ми використовуємо:
imports: [ CommonModule, MatButtonModule, MatSnackBarModule, TranslateModule ]
Фактично ці модулі будуть імпортуватися в багатьох інших standalone-компонентах. Чи можна уникнути дублювання? Можна створити так званий shared-модуль:
@NgModule({ declarations: [], imports: [ CommonModule, TranslateModule ], exports: [CommonModule, TranslateModule] }) export class SharedModule { }
Його єдина функція — це експорт інших модулів. Тому тепер ми можемо зібрати в ньому найбільш використовувані модулі та замінити їх на SharedModule:
imports: [ MatButtonModule, MatSnackBarModule, SharedModule ]
На розмірі bundle це ніяк не позначилося. Але такий підхід не дуже вписується в нову ідеологію Angular, тобто ми замість видалення модулів створюємо нові модулі. Тому відмовляємося від SharedModule на користь імпорту модулів безпосередньо. Але ми можемо імпортувати моделі, а можемо і компоненти (директиви та pipes), якщо знаємо їх назви:
imports: [ MatButton, TranslatePipe ]
На розмірі bundle це не позначилося. Але тут у багатьох може виникнути сумнів про ефективність такого підходу. Раніше ми декларували елементи на рівні модулів, а потім імпортували їх з модуля. Тепер ми повинні імпортувати їх у кожному standalone-компоненті, який їх використовує. Як це працюватиме на практиці?
- Скільки разів буде створюватись, наприклад, pipes, які ми використовуємо (TranslatePipe)?
- Чи включатиметься код standalone pipe в кожен standalone-компонент?
Перевіримо це практично. Створимо нову (поки що не standalone):
@Pipe({ name: ’quote’, standalone: false }) export class QuotePipe implements PipeTransform { text = ’STANDALONE_TEST’ constructor() { console.log(’QuotePipe created’); } transform(value: string): string { return ’""’ + value + ’"’; } }
Щоразу при своєму створенні вона виводитиме повідомлення в консоль. Також я додав нове поле, яке не буде мініфікуватися для того, щоб перевірити згенерований код цієї pipe (воно дозволить шукати код pipe).
Якщо ми не використовуємо standalone-елементи, то pipe створюється один раз і її код включається один раз в bundle.
Тепер переробимо все на standalone-варіант, використовуємо цю pipe в StatusComponent і двічі включимо його в темплейт:
<app-status></app-status> <app-status></app-status>
Запускаємо застосунок. QuotePipe створюється двічі, але її код включається в bundle один раз. Тепер використовуємо QuotePipe в різних standalone-компонентах, як і раніше QuotePipe створюється для кожного використання, але включається один раз в bundle.
Ускладнимо завдання. Тепер будемо динамічно (loadComponent) завантажувати standalone-компоненти. Все залишається, як і раніше. Таким чином можна констатувати, що використання standalone-елементів призводить до створення нових об’єктів щоразу при їх використанні. Але сам код елементів включається в bundle лише один раз.
Особливості використання таблиць
Переходимо до модуля PaymentModule. Візьмемо один із компонентів PaymentsComponent, темплейт якого складніший і містить таблиці з Angular Material. Якщо ми спробуємо імпортувати кожен елемент окремо, то зіткнемося з проблемою, пов’язаною з директивами. Дуже легко пропустити директиву, а потім отримати таку помилку в runtime:
NG0303: Can’t bind to ’matRowDefColumns’ since it isn’t a known property of ’tr’ (used in the ’_PaymentsComponent’ component template).
Тут не одразу й не скажеш, що ми забули вказати. І лише детальне дослідження показує, що є директива MatRowDef, в якій вхідним параметром буде matRowDefColumns:
@Directive({ selector: ’[matRowDef]’, providers: [{provide: CdkRowDef, useExisting: MatRowDef}], inputs: [ {name: ’columns’, alias: ’matRowDefColumns’}, {name: ’when’, alias: ’matRowDefWhen’}, ], }) export class MatRowDef<T> extends CdkRowDef<T> {}
Наступна помилка ще менш інформативна:
ERROR TypeError: column.headerCell is undefined
І лише після нової ітерації аналізу коду з’ясовується, що бракує імпорту для директиви MatHeaderCellDef. На жаль, не завжди Angular-компілятор здатний відстежувати подібні помилки, і є навіть тикет на це. В результаті список імпортів виходить досить великим:
imports: [MatTableMatColumnDef, MatHeaderCell, MatCell, MatHeaderRow, MatRow, MatHeaderRowDef, MatCellDef, MatRowDef, MatHeaderCellDef]
Для покращення читабельності та уникнення випадкових помилок простіше замінити його на один модуль, де знаходяться всі ці компоненти та директиви:
imports: [MatTableModule]
А так виглядатиме декларація цього компоненту:
@Component({ selector: ’app-payments’, templateUrl: ’./payments.component.html’, imports: [MatTableModule, AsyncPipe, TranslatePipe, DatePipe, NgIf] }) export class PaymentsComponent implements OnInit {
Як ви бачите, потрібно явно імпортувати всі pipes та директиви, навіть такі як NgIf з Com-monModule. З іншого боку, це ще одна нагода перейти на control flow (@if). Розробники Angular планували автоматично імпортувати всі елементи із CommonModule, але потім відмовилися від цієї ідеї. Збираємо проєкт. Розмір bundle 1088 кб.
Робота з формами
Переходимо до TicketModule. Тут новою проблемою під час міграції стала наявність форм у компоненті CreateTicketComponent:
<form [formGroup]="ticketForm«>
Якщо ми спробуємо імпортувати директиву FormGroupDirective, то отримаємо помилку:
Directive FormGroupDirective is not standalone and cannot be imported directly. It must be imported via an NgModule.
На це є тикет, який поки що завис у повітрі. Тому іншого виходу немає, потрібно імпортувати тільки весь модуль ReactiveFormsModule повністю.
Ось як виглядає декларація CreateTicketComponent:
@Component({ selector: ’app-create-ticket’, templateUrl: ’./create-ticket.component.html’, imports: [TranslatePipe, MatSelect, MatFormField, MatOption, MatLabel, MatButton, MatDialogActions, MatDialogClose, ReactiveFormsModule, MatDialogTitle, MatDialogContent], providers: [PaymentProviderService] }) export class CreateTicketComponent {
Далі переходимо до компонента CreatedOrderComponent:
@Component({ selector: ’app-create-order’, templateUrl: ’./create-order.component.html’, imports: [ReactiveFormsModule, MatButton, MatFormField, MatDatepickerModule, TranslatePipe, MatDialogModule, MatError, MatInputModule] }) export class CreateOrderComponent {
Оскільки ми його створюємо динамічно:
openOrderDialog(routeId: string): void { const dialogRef = this._dialog.open(CreateOrderComponent);
То його ні декларувати, ні імпортувати ніде не треба. Ось як виглядає декларація TripsComponent:
@Component({ selector: ’app-trips’, templateUrl: ’./trips.component.html’, imports: [MatTableModule, MatButton, ReactiveFormsModule, TranslatePipe, AsyncPipe, CitySelectionComponent, NgIf], providers: [RouteService] }) export class TripsComponent implements OnInit {
Зверніть увагу, що ми вперше використовували атрибут providers, вказавши сервіс RouteService, який використовується тільки в цьому компоненті. Також ми вперше проімпортували власний компонент (CitySelectionComponent), який використовується всередині поточного компонента. Раніше він лише оголошувався у поточному модулі (атрибут declarations).
Ну і насамкінець переробляємо головний компонент AppComponent:
@Component({ selector: ’app-root’, templateUrl: ’./app.component.html’, imports: [HeaderComponent, RouterOutlet, MatTabsModule, TranslatePipe, RouterLink, RouterLinkActive] }) export class AppComponent {
Але тепер наш застосунок перестає запускатися, виводячи помилку:
Component AppComponent is standalone and cannot be used in the @NgModule.bootstrap array. Use the bootstrapApplication function for bootstrap instead
Тобто використовувати звичний механізм запуску, заснований на модулях, вже не вдасться:
platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.log(err));
Конфігурація запуску застосунку
Замість функції platformBrowserDynamic було додано нову функцію bootstrapApplication. Але поки що ми її використати не можемо. У нас є модуль CoreModule, в якому оголошуються багато хто з providers нашого застосунку:
@NgModule({ imports: [ CommonModule ] }) export class CoreModule { static forRoot(inMemory: boolean): ModuleWithProviders<CoreModule> { return { ngModule: CoreModule, providers: AuthenticationService, {provide: HTTP_INTERCEPTORS, useClass: SecurityInterceptor, multi: true}, OrderService, PaymentService, LocalizationService] }; } }
І тут дуже наочно видно подвійну природу Angular-модулів. З одного боку це контейнер для UI-елементів, з іншого боку це контейнер/конфігурація для DI-сервісів/провайдерів. UI елементи ми тепер вказуємо безпосередньо в компонентах. А ось providers потрібно тепер вказувати під час bootstrapping програми. Оскільки ми твердо вирішили позбутися всіх модулів, що робити з CoreModule? Просто замінимо його константою coreProviders:
const coreProviders = [AuthenticationService, {provide: HTTP_INTERCEPTORS, useClass: SecurityInterceptor, multi: true}, OrderService, PaymentService, LocalizationService];
Далі у нас у AppModule йде імпорт JwtModule, який потрібен для роботи з JWT-токенами:
JwtModule.forRoot({ config: { tokenGetter: jwtLoader } }),
Бібліотека @auth0/angular-jwt давно не оновлювалася та не підтримує новий moduleless-підхід. Тому в таких випадках для імпорту провайдерів є нова функція importProvidersFrom, яка таки призначена для роботи з legacy-кодом:
importProvidersFrom(JwtModule.forRoot({ config: { tokenGetter: jwtLoader } })),
А ось бібліотека ngx-translate якраз почала підтримувати застосунки без модулів, тому тут є вбудована функція provideTranslateService для імпорту провайдерів:
provideTranslateService({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient], } }),
Модулі можуть в окремих випадках використовуватися для ініціалізації сервісів. Зазвичай сервіси ініціалізуються під час DI в компоненти (або інші елементи). Але є сервіси, які не прив’язані до UI або їх потрібно ініціалізувати під час завантаження застосунку. У нас це сервіс для роботи з HATEOAS:
export class AppModule { constructor(hateoasConfig: NgxHateoasClientConfigurationService) { hateoasConfig.configure({ http: { rootUrl: `${BASE_API_URL} ` }, useTypes: {resources: [Order]} }); } }
Чим замінити код, якщо модулів більше немає? У Angular 14 додали спеціальну конструкцію для такого випадку:
providers: [ { provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => inject(LocaleService).load() }
... яка в цьому випадку налаштовує LocaleService. Але потім розробники Angular вирішили відмовитися від такої ідеї, оголосили попередній підхід застарілим (deprecated) і додали спеціальну функцію provideEnvironmentInitializer:
provideEnvironmentInitializer(() => { const hateoasConfig = inject(NgxHateoasClientConfigurationService); hateoasConfig.configure({ http: { rootUrl: `${BASE_API_URL} ` }, useTypes: {resources: [Order]} }); })
Всі providers потрібно вказувати в новому типі ApplicationConfig, який у нашому випадку цілком виглядає так (включаючи з константою coreProviders):
export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAnimationsAsync(), provideHttpClient(withInterceptorsFromDi()), ...coreProviders, importProvidersFrom(JwtModule.forRoot({ config: { tokenGetter: jwtLoader } })), provideTranslateService({ loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, deps: [HttpClient], } }), provideEnvironmentInitializer(() => { const hateoasConfig = inject(NgxHateoasClientConfigurationService); hateoasConfig.configure({ http: { rootUrl: `${BASE_API_URL} ` }, useTypes: {resources: [Order]} }); }) ] }; export function HttpLoaderFactory(http: HttpClient) { return new TranslateHttpLoader(http); }
А ось так виглядає завантаження застосунку:
bootstrapApplication(AppComponent, appConfig) .catch(err => console.error(err));
Збираємо застосунок, жодних помилок не зафіксовано. Вимірюємо продуктивність:
- Час production збирання — 5.1 сек.
- Розмір bundle — 1077 кб
- Час локального запуску — 2.0 сек.
Таким чином ми скоротили bundle на 20 кб, час збирання на 10%, час локального запуску на 15%, хоча покращення продуктивності ніколи не прив’язувалося до такої фічі, як standalone-компоненти.
Ось тепер можна видалити всі модулі.
Обмеження та налаштування
У новій концепції розробки компонентів не все так гладко і насправді тут все ще є низка обмежень:
- Неможливо вказати кілька компонентів для процесу bootstrapping.
- Якщо ви вже маєте готові standalone-компоненти, які ви відкомпілювали у версіях <Angular 19, то цілком можливо, що ви не зможете їх використовувати в Angular 19.
Крім того, для standalone-компонентів була додана нова опція unusedStandaloneImports в налаштуваннях діагностики:
{ «angularCompilerOptions»: { «extendedDiagnostics»: { «checks»: { «unusedStandaloneImports»: «suppress» } } } }
Вона дозволяє визначити ті компоненти, які ви імпортуєте, але не використовуєте у темплейтах. На жаль, але вона не завжди працює правильно.
І ще одна проблема, яка специфічна для нової функціональності — тепер ви не можете використовувати standalone-компоненти, якщо вони циклічно посилаються один на одного. Такої проблеми не було з модулями, але, на щастя, є workaround — функція forwardRef:
imports: [ forwardRef(() => MyStandaloneComponent)) ]
Автоматична міграція
Поговоримо про автоматичну міграцію на standalone-елементи. Як я й казав, вона не може конвертувати 100% вашого коду (особливо це стосується директив), але може стати помічником у міграції, якщо у вас у проєкті сотні компонентів та ручна міграція практично неможлива. Також можливі випадки, коли вона просто не працюватиме.
Отже, для автоматичної міграції є команда ng generate @angular/core:standalone. Коли ви її запускаєте, то вона вам пропонує вибрати тип (стадію міграції):
? Choose the type of migration (Use arrow keys) > Convert all components, directives and pipes to standalone Remove unnecessary NgModule classes Bootstrap the application using standalone APIs
Тобто спочатку ви конвертуєте ваші елементи, потім видаляєте модулі і в кінці змінюєте тип завантаження застосунку. У документації дається порада не робити це з усім проєктом відразу, а розбити на безліч етапів, після кожного з яких ви перевіряєте працездатність застосунку. Це теоретично можна зробити, вибравши шлях, яким слід розпочати міграцію (за умовчанням це весь проєкт):
? Which path in your project should be migrated? ./
На жаль, але мені так і не вдалося вибрати якусь частину проєкту, я завжди отримував помилку:
Could not find any files to migrate under the path src/app/about/. Cannot run the standalone migration.
Тому в цьому випадку я мав можливість лише конвертувати весь проєкт. Але без гарного покриття коду тестами ви це робитимете на свій страх і ризик.
Висновки
Чи варто переводити всі свої компоненти в категорію standalone? Насамперед це залежить від того, чи будуть у майбутньому підтримуватися звичайні компоненти і модулі. Ще у 2023 році всі елементи з Angular Material/CDK були конвертовані у standalone.
Більше того, є деякі фічі, де можна використовувати тільки standalone-компоненти:
- deferrable views.
- не можна використовувати не-standalone директиви як host directives у компонентах.
В офіційній документації по Angular ніде не говориться, що модулі deprecated, тут використовуються більш завуальовані тези: модулі опціональні, рекомендується перейти на standalone-компоненти. Припинення підтримки модулів зараз неможливе, оскільки є величезна кількість open-source бібліотек і проєктів, які широко використовують модулі і навряд чи хтось їх моментально переписуватиме.
11 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів