Як перенести проєкти на Angular 17
Усім привіт. Мене звати Сергій Моренець, розробник, викладач, спікер і технічний письменник, я хочу поділитися з вами досвідом міграції фронтенд-застосунків на Angular 17. Розробники Angular уже звикли до того, що ця технологія розвивається еволюційно, з мінімумом breaking changes. У Angular 17 з’явилася дуже велика кількість істотних змін, адаптувати які не так просто. Оскільки дизайн і синтаксис цих змін часто змінювався, я захотів розповісти про те, що є актуальним наразі та наскільки легко перевести Angular-проєкти на останню версію. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче дізнатися більше про нові фічі в Angular та особливості міграції.
Control flow
Отже, нещодавно вийшла нова версія Angular, яка запропонувала нові, цілком революційні (як для Angular) принципи розробки застосунків:
- новий control flow;
- Deferrable views;
- поліпшення для server-side rendering;
- зміни у рендерингу сторінок;
- використання esbuild+Vite для збiрки.
Але про все по порядку. Перша фіча (control flow) стосується структурних директив, як-от ngIf, ngFor та ngSwitch. Такі директиви застосовуються для динамічної зміни структури компонента за деякою умовою і залежить від елемента ng-template. Цей синтаксис є не найзручнішим у темплейтах, доводиться вбудовувати control flow за допомогою HTML-атрибутів, тому розробники Angular вирішили впровадити новий синтаксис, який використовується в Svelte чи mustache та розділяє HTML та control flow.
До Angular 17 синтаксис був такий:
<div *ngIf="logged; else anonymous">
Current user is {{userName}}
</div>
<ng-template #anonymous>
User is not authenticated
</ng-template>
Тут доводилося використовувати елемент ng-template для блоку, який спочатку був невидимий. Початкова ідея нового control flow лежала у використанні фігурних дужок та ґрат для декларації іменованих блоків:
{#if logged}
Current user is {{userName}}
{:else}
User is not authenticated
{/if}
{#for name of names; track name.id}
{{name.id}}
{:empty}
List is empty
{/for}
Водночас формат таких блоків був запропонований для дискусії, головне, щоб варіанти, що пропонуються, були валідними для HTML-конструкцій. У якості альтернативи решітки та фігурних дужках пропонувалися квадратні дужки. Головна ідея — максимальна схожість нового синтаксису з тими можливостями, які пропонував JavaScript та підтримка tree-shaking. Тому ось така конструкція була відкинута відразу:
<ng:if>
</ng:if>
Під час публічної дискусії було справедливо помічено, що мови розмітки Razor / Blade пропонують зручніший синтаксис, тому що використовують не дужки, а символ @. Зрештою цей варіант і був обраний:
@if (logged) {
Current user is {{userName}}
} @else {
User is not authenticated
}
@for (name of names; track name.id) {
{{name.id}}
} @empty {
List is empty
}
Benchmarks показують, що новий блок @for
може дати до 90% приросту продуктивності, якщо порівнювати його з ngFor:
Ще одна перевага нового control flow — вони будуть базуватись на сигналах і не використовувати Zone.js. Про це ми поговоримо трохи далі. Водночас можна мігрувати наявні директиви в новий синтаксис або використовувати їх нарівні з control flow (хоча вони будуть показуватися як deprecated).
Deferrable views
Розробники Angular у кожній версії фреймворку намагалися поліпшити його продуктивність (Ivy View Renderer) або дати можливість програмістам контролю за завантаженням елементів. Тому в Angular свого часу з’явилася така фіча, як lazy loading для модулів, коли можна завантажувати на запит так звані feature-модулі. Це дозволяло зменшити розмір початкового bundle. Але якщо вам потрібно було зробити те саме для компонентів, доводилося використовувати await import і спеціальний API у вигляді ViewConainerRef. І ось зараз, з впровадженням нового control flow, з’явилася спеціальна конструкція (block group) @defer, котра в принципі позбавляє необхідності писати код для цього випадку:
@defer {
<app-status></app-status>
}
Тепер компонент StatusComponent (а також усі пов’язані директиви, компоненти та pipes) будуть завантажені асинхронно. Єдиний нюанс — для цього компонент повинен бути оголошений як standalone і не використовуватися (імпортуватися), як звичайні компоненти в поточному модулі. Якщо це буде звичайний компонент (оголошений за допомогою NgModules), то він буде доданий у bundle та завантажений як усі інші компоненти у проєкті. Але вже є запит на те, щоби це змінити. Якщо потрібно відобразити якийсь HTML на той момент, коли компонент ще не завантажений, для цього є блок @placeholder:
@defer {
<app-status></app-status>
} @placeholder {
<p>No status</p>
}
А блок @loading відображається під час завантаження компонента:
@defer {
<app-status></app-status>
} @loading {
<p>Status is loading ... </p>
}
Водночас важливо розуміти, що весь HTML, вказаний в @placeholder / @loading, буде завантажений відразу під час старту застосунку. Якщо немає жодних умов для рендерингу, то, по суті, така конструкція нічим не відрізняється від звичайного завантаження компонентів, тому зручніше використовувати так звані тригери, наприклад, завантаження за деякою логічною умовою:
@defer (when displayStatus){
<app-status></app-status>
}
Ще одна стандартна ситуація — відображати компонент, коли він потрапляє в зону видимості користувача (viewport), тут обов’язковий блок @placeholder:
@defer (on viewport){
<app-status></app-status>
} @placeholder {
<p>No status</p>
}
Можна завантажувати компонент після деякої затримки:
@defer (on timer(5s)){
<app-status></app-status>
} @placeholder {
<p>No status</p>
}
У deferrable views є і свої підводні камені, так, наприклад, не рекомендується робити їх вкладеними (хоча це не завжди можна уникнути), оскільки вкладені запити на сервер можуть призвести до проблем з продуктивністю. Поки що ця фіча перебуває в режимі developer preview, тож можуть бути деякі зміни в майбутніх релізах.
Сигнали
Третя велика фіча — сигнали (Signals). Зараз Angular повністю залежить від Zone.js, тому що використовує його для перехоплення всіх можливих подій (події від клавіатури, мережеві запити, використання таймера). Потім запускається цикл change detection, який перечитує дані з моделі та оновлює UI. Одна з проблем такого підходу — Angular не має уявлення, чи змінилося щось у моделі після генерації події, і якщо змінилося, то що саме. Що більше стає застосунок, то більше такий підхід позначається на продуктивності. Стратегія OnPush зменшує ці проблеми, але не усуває їх.
Тому розробники Angular вирішили уникнути Zone.js і перейти на реактивний підхід для роботи з моделлю і даними. Тут вони не здійснили революцію. Подібні нововведення вже знайомі тим, хто працював з такими технологіями як Preact, SolidJS, MobX або Vue.js. Що ж змінилося?
У нас з’явився тип Signal:
export declare type Signal<T> = (() => T) & {
[SIGNAL]: unknown;
};
який виконує дві функції:
- Дозволяє прочитати деякі дані.
- Дозволяє повідомити всіх, хто прочитав ці дані, що вони змінилися.
Для того, щоб змінити дані, є інтерфейс-спадкоємець WritableSignal
:
export declare interface WritableSignal<T> extends Signal<T> {
set(value: T): void;
update(updateFn: (value: T) => T): void;
asReadonly(): Signal<T>;
}
Поділ сигналів на read-only / writable зроблено для того, щоб ви у своїх компонентах могли змінити їхнє значення, а споживачам передавати read-only-варіант, який вони змінити не зможуть.
І якщо до Angular 17 ви просто оголошували ваші властивості в компонентах:
logged = false
Тепер є функція signal()
, яка повертає WritableSignal:
logged: WritableSignal<Boolean> = signal(false)
Водночас вміст темплейту змінюється з
{{logged}}
На
{{logged()}}
Для встановлення нового значення використовується функція set:
this.logged.set(true);
а для зміни з урахуванням поточного значення — update
. І найважливіша відмінність сигналів від Zone.js — change detection — у разі сигналів починається тільки тоді, коли ви змінили значення сигналу, і до того ж значення сигналу було використано (прочитано) у вашому темплейті.
Якщо потрібно обчислити значення сигналу на основі даних іншого сигналу, тобто функція computed:
status = computed(() => this.logged()? 'logged' : 'unanimous')
І ще одна цікава функція effect, яка виконується як мінімум один раз, запам’ятовує значення використовуваних сигналів, і якщо хоча б одне значення сигналу зміниться, то вона виконається ще раз:
effect(() => {
console.log(`The logged status is: ${this.logged()}`);
});
Але якщо ви спробуєте її використовувати в якійсь довільній функції (а не конструкторі), то відразу отримаєте помилку:
NG0203: effect() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Якщо ви стежите за змінами в Angular, то можете помітити відсутність у WritableSignal функції mutate(),
яка просто змінювала вміст об’єкта-значення. Angular для виявлення змін у значеннях використовував функцію Object.is, яка повертала true
, якщо посилання на об’єкт не змінилася. Вміст об’єктів тут не порівнювався. Тому зміна вмісту об’єкта не призводить до циклу оновлення UI, і залишилася лише функція update()
, яка повинна повернути нове значення (у тому числі й новий об’єкт). Тобто ось такий код буде помилковим:
items = signal(['a']);
this.items.update(arr => {
arr.push('b');
return arr;
});
А правильніше буде так реалізувати оновлення:
this.items.update(arr => [...arr, 'b']);
Але що, якщо вам таки потрібно порівнювати вміст об’єктів у сигналах? Тоді потрібно перевантажити signal()
і вказати функцію для порівняння старого і нового значень, наприклад:
user = signal({}, {equal: (user1, user2) => JSON.stringify(user1) == JSON.stringify(user2)});
Якщо придивитися до Signals API, може виникнути питання. А чому просто не використовувати RxJS і Observable замість них? На жаль, Observable не дуже добре підходить для синхронізації даних між моделлю та темплейтом, оскільки може бути як синхронним, так і асинхронним, передбачає різні побічні ефекти і т. д. Тим не менш, RxJS можна і зараз використовувати для обміну даними між сервісами або компонентами. А сигнали можна використовувати не тільки в компонентах, а й у сервісах, директивах і будь-якому TS/JS коді. Більш того, у Angular з’явилася функція для конвертації Observable у сигнали:
const items$ = of(1, 2, 3);
const signal = toSignal(items$);
Потрібно лише пам’ятати, що toSignal повертає тип Signal і з нього можна буде тільки прочитати, але не записати (оскільки це не WritableSignal). Сигнали також перебувають у режимі de-veloper preview. І, наприклад, ще не готова така функціональність, як signal-based inputs. А про це говорять із 2015 року.
Esbuild + Vite
Ще одне покращення (цього разу інфраструктурне) — використання зв’язки Esbuild + Vite замість Webpack для збирання та завантаження застосунка. Ця можливість була додана ще в Angular 16, тепер вона почала використовуватися за замовчуванням (тобто визнана стабільною). Як стверджують розробники Angular, це скоротило час збирання на 67% у середньому, а для SSR-застосунків — на 87%. Так це чи ні — ми перевіримо на наших проєктах.
Webpack — збирач / завантажувач з величезною кількістю можливостей, але нові технології випереджають його в швидкості. Webpack написаний на JavaScript, esbuild написаний на Go, широко використовує розпаралелювання і за швидкістю випереджає конкурентів у
Якщо раніше у angular.json використовувався browser builder для production конфігурації:
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
То для нових проєктів уже application builder:
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
Чи варто автоматично переходити з Webpack на Vite? Наразі Vite використовується тільки для локальної (development) розробки. Якщо у вас у проєкті вже є своя конфігурація для Webpack, то може знадобитися час та зусилля, щоб перекласти це на Vite.
Міграція застосунків
Оскільки міграція проєктів на Angular 17 може ховати абсолютно невідомі небезпеки, ми спочатку спробували перекласти кілька внутрішніх проєктів, хоча б для того, щоб зрозуміти, а чи можливо це зробити (і наскільки складно). Для початку візьмемо досить простий додаток і заміряємо деякі показники продуктивності в Angular 16:
- Час production збирання — 16,1 с
- Розмір bundle — 964 КБ
- Час локального запуску — 9,8 с
Перейдемо з Angular 16 на Angular 17:
ng update @angular/cli @angular/core @angular/material —force
Внаслідок міграції оновилися залежності до Angular 17.0.4 і незначні зміни в angular.json. Тому поміняємо в ньому вручну builder на @angular-devkit/build-angular:application
, додамо новий пакет:
pnpm add @angular-devkit/build-angular
Потім потрібно вручну поміняти налаштування для builder з:
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
На:
"options": {
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": [
"src/polyfills.ts"
],
І видалити опції:
- vendorChunk;
- buildOptimizer;
Знову заміряємо продуктивність:
- Час production збирання — 6,0 с
- Розмір bundle — 961 КБ
- Час локального запуску — 2,63 с
Запускаємо застосунок. Вiн працює без будь-яких проблем. Так, перейшовши на Angular 17, ми отримали прискорення запуску / складання в три рази, не змінюючи код. Але не завжди такий перехід взагалі можливий. Наприклад, якщо ви використовуєте популярну бібліотеку PrimeNG, виявляється, вона поки що не підтримує Angular 17. Більше того, немає навіть бета-версій, у яких така підтримка була б. Адже Angular випустив поточну версію більше двох тижнів тому. PrimeNG обіцяє нову версію за два тижні, але це буде просто нова версія для сумісності. А коли вони перепишуть код і перейдуть на новий control flow / сигнали — не знає ніхто.
Заміряємо параметри продуктивності на ще одному проєкті, більшому:
- Час production збирання — 39,6 с
- Розмір bundle — 251КБ
- Час локального запуску — 23,4 с
Повторимо міграцію на Angular 17 і знову заміряємо продуктивність:
- Час production збирання — 12,42 с
- Розмір bundle — 2455 КБ
- Час локального запуску — 3,302 с
Тут показники покращали у
<tr
*ngFor="let event of events$ | async | orderBy: order : reverseSort | paginate: { itemsPerPage: pageSize, currentPage: page, totalItems: totalItems}">
І відразу отримуємо помилку:
[ERROR] NG5002: @for loop must have a «track» expression [plugin angular-compiler]
Це одна з ключових відмінностей @for від ngFor, де атрибут track є опціональним. З одного боку добре, що цей атрибут обов’язковий (менше випадкових помилок), з іншого боку це виключає автоматичну міграцію на @for. Тому перепишемо новий блок як:
@for (event of events$ | async | orderBy: order : reverseSort | paginate: {
itemsPerPage: pageSize,
currentPage: page,
totalItems: totalItems
}; track event.id) {
Зараз events$ — це Observable:
events$: Observable<Event[]>;
Чому б не спробувати використати замість нього сигнали:
events: Signal<Event[]>;
та ініціалізувати їх як:
this.events = toSignal(this.eventService.search());
Тут є один позитивний момент — функція toSignal повертає сигнал, який вміє сам відписуватись під час деактивації компонента, тому pipe async
більше не потрібна:
@for (event of events() | orderBy: order : reverseSort | paginate: {
Але якщо запустимо застосунок, відразу отримаємо відому помилку:
ERROR Error: NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext
Функцію toSignal() можна викликати в конструкторі, у нас вона викликається за подією переходу на іншу сторінку. Тут єдиний варіант — обернути все в функцію runInInjectionContext, але оскільки саме в цьому випадку сигнали особливого виграшу не дадуть, простіше залишити Obserable. Функція tosignal має ще одну особливість. Якщо Observable повертає дані підписувачам у силу їхнього надходження, сигнал відразу повертає поточне значення (тобто undefined). Це може призвести до непередбачуваних ефектів.
Висновки
Можна сказати, що розробники Angular прагнуть впровадити у свій фреймворк усі найсучасніші тенденції та підходи у світі фронтенду та інтегрувати передові технології. Це тішить і говорить про те, що світ Angular не стоїть на місці, він еволюціонує на краще. Нові фічі — досить великий виклик для розробників Angular, тому що їм доведеться підтримувати два control flow, два підходи для change detection (Zone.js і сигнали) і т. д. А перейти на новий синтаксис потрібно не тільки програмістам, але й авторам бібліотек тощо. Але якщо ви повністю перейдете на signal-based компоненти, то зможете видалити Zone.js, тому що вона вам більше не потрібна.
Новий control flow пропонує поділ вмісту темлпейту на власне HTML і block groups, що покращує читабельність коду. Сигнали дозволяють значно скоротити цикл change detection і час його роботи. Deferrable views дозволяють застосовувати lazy-loading на рівні компонентів, але вимагають, щоб ці компоненти були standalone. Водночас ці фічі все ще в режимі developer preview (ще немає signal-based inputs) і можуть змінитися в наступній версії, тому використовувати їх варто з обережністю.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів