Швидший Angular шляхом меншої кількості роботи: десять технік поза межами OnPush та trackBy
Зменште марну витрату мережевих ресурсів, реактивної обробки, DOM та CPU у сучасному Angular за допомогою @defer, поступової ініціалізації, методу resource(), обчислюваних сигналів, фаз відображення та Web Workers.
Джерела: How to Make Angular Extremely Fast: 10 Performance Tricks Most Developers Miss
Робочий час Angular рідко є причиною повільної роботи додатку на цій платформі. Набагато частіше додаток завантажує, ініціалізує, перераховує та відображає те, про що ніхто не просив. Звичні рішення, такі як OnPush, „ліниві“ маршрути, trackBy, компіляція AOT, створення версій для продакшну та скорочення кількості HTTP-запитів, все ще допомагають, але останні версії Angular надають значно більший контроль: коли завантажується код, коли компоненти стають інтерактивними, коли скасовуються запити, що призводить до недійсності стану, коли виконується робота з DOM та на якій потоці відбуваються важкі обчислення. У цьому посібнику розглядаються десять технік, заснованих на цьому контролі, як їх поєднувати та в якому порядку застосовувати після того, як ви визначите, куди насправді йде ваш час.
Основна ідея проста: замість того, щоб робити Angular швидшим під час виконання непотрібних операцій, організуйте ситуацію так, щоб ці операції взагалі не відбувалися.
Де витрачає час Angular-додаток
Перш ніж приступати до окремих API, корисно уявити три ресурси, які використовує кожен Angular-додаток, та те, що зазвичай споживає кожен з них.
Angular Application
│
┌────────────────┼────────────────┐
│ │ │
Network CPU DOM
│ │ │
JS downloads Change detection Layout
API requests Computation Paint
Dependencies Parsing Reflow
│ │ │
└────────────────┼────────────────┘
│
User Experience
Ці витрати взаємодіють між собою. Налаштування механізму виявлення змін мало допомагає, якщо під час першого завантаження сторінки завантажується 5 МБ JavaScript, а невеликий пакет не допоможе, якщо кожна взаємодія запускає дорогий обчислювальний процес. Метою має бути: завантажувати менше, ініціалізувати менше, обчислювати менше, відображати менше та менше блокувати основну схему виконання. Десять наведених нижче технік спрямовані на досягнення одного або кількох з цих кроків.
1. Змусити @defer створювати справді окремі частини коду
Блок @defer є одним із найпростіших способів скоротити обсяг JavaScript, необхідний під час запуску. Уявіть панель керування з діаграмами, аналітикою, звітами, картами, плеєрами відео та інструментами адміністратора. Лише деякі з цих елементів мають бути присутніми відразу після відкриття сторінки, проте звичайний шаблон завантажує їх усі одночасно:
<app-heavy-chart />
<app-map />
<app-report />
Обгортання важкого компонента у блок з використанням @defer повідомляє Angular отримувати його код лише тоді, коли виникне певний подія — у цьому випадку, коли замінник прокручується у поле зору:
@defer (on viewport) {
<app-heavy-chart />
}
Уникайте використання компонентів з @defer у імпортах типу barrel
Є одна нюанс: @defer може використовуватися лише для розділення коду, який ще не доступний через звичайний імпорт. Якщо головний компонент імпортує компонент з @defer через файл типу barrel, цей файл включає до загальної структури всі інші експорти та їх залежності. Тож замість імпорту з файлу index:
import { HeavyComponent } from './index';
Імпортувати компонент з його власного файлу:
import { HeavyComponent } from './heavy.component';
Мета полягає у тому, щоб граф залежностей за кожним блоком з відкладеною обробкою залишався якомога ізольованішим. Перша форма, яку слід уникати, позначена так:
BAD
і вона виглядає ось так, де один «барель» пов’язує кожен віджет із панеллю керування:
Dashboard
│
└── index.ts
├── Chart
├── Map
├── Reports
├── Video
└── Analytics
Бажана форма позначена так:
GOOD
і це забезпечує прямий імпорт важкого графіка, а отже, й його власний блок даних:
Dashboard
│
└── HeavyChart
│
└── heavy-chart.chunk.js
Імпорти через «барелі» є однією з найпоширеніших прихованих причин, чому блок @defer не допомагає скоротити початковий пакет. Найпростіший спосіб перевірити успіх — це подивитися результати збірки на наявність нового лендж-блоку після додавання блока.
Користь посилюється: менший початковий пакет означає, що потрібно завантажувати, аналізувати, компілювати та виконувати менше даних, і вся ця економія відбувається ще до того, як Angular відобразить будь-що.
2. Завантажуйте лише те, що користувачам потрібно спочатку
При серверному рендерингу другий бутлекейг з’являється після надходження HTML. Сервер може швидко створити маркап, але браузер потім мусить зробити всю сторінку інтерактивною, що означає завантаження та виконання коду для кожного компонента на ній. Розгляньмо типову структуру панелі керування:
Dashboard
│
├── Header
├── Navigation
├── KPI Cards
├── Huge Chart
├── Map
├── Analytics Table
├── Video Player
└── Recommendations
Не всі ці розділи потребують негайної відповіді на вхідні дані. Класичне повне завантаження розглядає сторінку як єдину одиницю:
SSR HTML
↓
Hydrate EVERYTHING
↓
Interactive
Інкрементальне завантаження дозволяє розділити цей процес на етапи, тож сторінка стає видимою відразу, а розділи поступово стають інтерактивними:
SSR HTML
↓
Show page immediately
↓
Hydrate only what is needed
↓
Hydrate other sections later
Це реалізується за допомогою тих самих блоків @defer, доповнених тригерами гідратації. У наступному прикладі сервер все ще генерує HTML діаграми, клієнт гідратує її, коли браузер не активний, а під час повністю клієнтського відображення блок завантажується після взаємодії користувача:
@defer (on interaction; hydrate on idle) {
<app-heavy-chart />
}
Архітектурною перевагою є те, що генерація HTML та зроблення її інтерактивною більше не є єдиним кроком, який має бути виконаним повністю або зовсім. Необхідно увімкнути поступову гідратацію у налаштуваннях гідратації; перевірте посібник з SSR для вашої версії Angular, щоб дізнатися точний варіант налаштування.
3. Скасувати застарілі запити за допомогою resource()
Функція пошуку під час введення тексту — це класичний приклад марної роботи мережі. Коли користувач вводить текст, дані проходять крізь усі проміжні значення:
a
an
ang
angu
angul
angula
angular
Наївна реалізація може надсилати запит для кожного з цих значень:
Request 1 → "a"
Request 2 → "an"
Request 3 → "ang"
Request 4 → "angu"
Request 5 → "angul"
Request 6 → "angular"
Лише остання відповідь має значення, тож решта п’ять є марними. Сучасний Angular пропонує resource() та httpResource() для реактивного асинхронного завантаження. Ви починаєте з сигналу, який містить запит:
const query = signal('');
Потім ви оголошуєте ресурс, чия функція params читає цей сигнал, а чий loader виконує запит. У наведеному нижче прикладі loader розміщений у тому ж рядку, що й закриваюча дужка функції params; це дві окремі властивості об’єкта опцій:
const results = resource({
params: () => ({
q: query()
}), loader: ({ params, abortSignal }) =>
fetch(
`/api/search?q=${params.q}`,
{ signal: abortSignal }
).then(res => res.json())
});
Кожного разу, коли параметри змінюються під час тривання завантаження, Angular припиняє попереднє. Це можливо лише тому, що loader передає наданий сигнал до функції fetch, на що потрібно звернути увагу:
{ signal: abortSignal }
Якщо цього не зробити, старе запитування продовжуватиме виконуватися до кінця. У реальному коді варто додати два покращення: обгорнути запит у encodeURIComponent перед вставкою його до URL, а також пам’ятати, що скасування не є механізмом дебаунсингу. Кожен натиск клавіші все одно ініціює запит, який потім припиняється, тому, якщо ви також хочете уникнути їх надсилання, поєднайте цей ресурс із сигналом запиту з механізмом дебаунсингу. Найшвидшим є той запит, який ви зупиняєте як тільки він стає непотрібним, і цей підхід є ефективним для пошуку, автодоповнення, фільтрації, живих панелей керування, швидко змінюваних ID та послідовностей залежних викликів.
4. Зберігайте сигнали маленькими та зосередженими
Сигнали є ефективними, але їх все одно можна використовувати так, що ця ефективність буде втрачена. Поширеним паттерном є використання одного сигналу для зберігання стану всієї сторінки:
const pageState = signal({
user: {},
filters: {},
notifications: [],
products: [],
statistics: {},
settings: {}
});
Якщо змінюються лише фільтри, про це повідомляється кожен споживач pageState, оскільки змінилася цілком величина сигналу. Розділення незалежних частин стану на окремі сигнали зменшує радіус впливу. Перший стає таким:
const user = signal(...);
а решта дотримуються тієї ж схеми (тут показано разом у одному рядку; у справжньому коді кожен з них знаходиться на окремому рядку):
const filters = signal(...);const notifications = signal(...);const products = signal(...);const statistics = signal(...);const settings = signal(...);
Тепер кожен споживач залежить лише від стану, який він фактично читає. Електронна таблиця є гарною ментальною моделлю. Коли змінюється одна клітинка:
A1 changes
ви не хочете, щоб перераховувалася вся таблиця, а лише ланцюжок елементів, які від неї залежать:
A1
↓
Dependent cells
↓
Only affected UI
Сигнал великого об’єкта призводить до недійснення даних для тих споживачів, яким потрібен лише частина цього об’єкта; малі сигнали забезпечують вузький діапазон недійснення даних. Замість того, щоб запитувати, чи вся інформація про стан може бути представлена одним сигналом, потрібно визначити, що саме має реагувати на зміну певного значення. Якщо вам справді потрібен складний об’єкт, створіть його за допомогою computed() на основі малих сигналів, а не зберігайте його як основне джерело інформації.
5. Виведіть дорогі обчислення за межі шаблонів
Виклик функції з шаблону — один із найпростіших способів створення повторної роботи:
<div>
{{ calculateTotal(items()) }}
</div>
Це здається безневинним, але Angular переоцінює цей вираз щоразу, коли перевіряє виджет, тож дороге обчислення calculateTotal() виконується знову та знову, навіть якщо items не змінився. Рішенням є використання сигнала computed(). Оголосіть джерело стану:
const items = signal<Item[]>([]);
Отримайте загальну суму з цього:
const total = computed(() =>
items().reduce(
(sum, item) => sum + item.price,
0
)
);
і прочитайте отриманий сигнал у шаблоні:
<div>
{{ total() }}
</div>
computed() зберігає свій результат у пам’яті та перераховує його лише тоді, коли змінився сигнал, який він читає, а не при кожній оцінці шаблону. Це робить його ідеальним інструментом для фільтрації, сортування, агрегації, статистики, показників дашборду та будь-якого іншого похідного стану інтерфейсу. Список продуктів після фільтрації має таку саму структуру, починаючись від сигналу з пошукового запиту:
const searchTerm = signal('');
та похідний список, який залежить від продуктів та запиту:
const filteredProducts = computed(() =>
products().filter(product =>
product.name.includes(searchTerm())
)
);
Це замінює виклики шаблонів, як-от наведені нижче, переносячи обробку до реактивної схеми:
{{ filterProducts(products(), searchTerm()) }}
Одна деталь: функція includes чутлива до регістру, тому під час справжнього пошуку зазвичай спочатку нормалізують обидві рядки. Розглядайте computed() як кеш для похідного стану, який правильно скасовує свою дію.
6. Завантажуйте важкі сервіси з запізненням, а не лише компоненти
Завантаження з запізненням зазвичай обговорюється на двох рівнях:
Lazy route
Lazy component
Однак сервіси можуть мати власні складні дерева залежностей. Хорошим прикладом є сервіс попереднього перегляду у форматі Markdown:
MarkdownService
│
├── marked
├── highlight.js
├── syntax definitions
└── other dependencies
Якщо сервіс використовується лише тоді, коли хтось натискає кнопку „Попередній перегляд Markdown“, немає причин включати парсер, підсвічування синтаксису та відповідні граматики до початкового пакету. Техніка полягає у зверненні до сервісу через динамічне імпортування у момент його необхідності. Концептуально це виглядає так, з використанням допоміжної функції injectAsync():
const markdownService = injectAsync(
() =>
import('../markdown.service')
.then(m => m.MarkdownService)
);
Потім компонент чекає на сервіс у обробнику, який його потребує:
async preview(content: string) {
const service = await this.markdownService();
і використовує його для відображення контенту:
this.html = service.render(content);
}
Розглядайте injectAsync() як шаблон, а не як гарантований основний API: перевірте, чи ваша версія Angular або бібліотека, яку ви використовуєте, його підтримують. Той самий ефект можна досягти за допомогою простого await import('../markdown.service') у обробнику, після чого розрішувати клас через інжектор. У будь-якому разі сервіс та його важкі залежності потрапляють у власний чанк. Загальне правило полягає у тому, щоб завантажувати дорогі залежності там, де вони стають корисними, а не лише на межах маршрутів.
7. Розглядайте читання та запис у DOM як дорогий API
Іноді вузьке місце зовсім не є JavaScript, а двигун формування лейауту браузера. Це трапляється, коли код поєднує вимірювання та зміни:
read DOM
write DOM
read DOM
write DOM
read DOM
write DOM
Кожне зчитування після запису може змусити браузер синхронно перерахувати макет, що є явищем під назвою layout thrashing. Angular пропонує хуки, які враховують процес рендерингу для цього. Перший з них:
afterNextRender()
А другий, який підтримує чіткі фази, — це:
afterRenderEffect()
За допомогою afterRenderEffect() ви розміщуєте операції вимірювання у фазі earlyRead:
afterRenderEffect({
earlyRead: () => {
return element.getBoundingClientRect();
},
А зміни — у фазі write, яка отримує значення, повернуте під час зчитування (цей фрагмент є продовженням тієї самої виклику TypeScript, а не CSS):
write: height => {
element.style.height = `${height}px`;
}
});
Angular виконує фази зчитування всіх компонентів перед їхніми фазами запису, тож браузер бачить роботу, груповану ось так:
READ
↓
READ
↓
READ
↓
WRITE
↓
WRITE
↓
WRITE
Замість постійної зміни порядку:
READ → WRITE → READ → WRITE
Ця координація, заснована на фазах, має найбільше значення для діаграм, карт, анімацій, обробки зміни розміру та прокрутки, бібліотек користувацького інтерфейсу від сторонніх розробників та складних панелей керування, де багато компонентів змінюють DOM після кожного відображення.
8. Виявлення пакетних змін за допомогою об’єднання подій та виконань
У додатках, заснованих на зонах, кілька тригерів можуть спрацювати один за одним унаслідок логічно однієї дії користувача:
click
↓
event bubbles
↓
parent handler
↓
ngZone.run()
↓
another update
Без об’єднання кожен крок може планувати власний процес виявлення змін. Angular може об’єднати їх, якщо увімкнути обидві опції в provideZoneChangeDetection:
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection({
eventCoalescing: true,
runCoalescing: true
})
]
});
Замість одного проходу на кожну подію:
Event A → Change Detection
Event B → Change Detection
Event C → Change Detection
Перекриваючіся операції об’єднуються в один прохід:
Event A ─┐
Event B ─┼──→ One Change Detection
Event C ─┘
Це найбільш корисно в додатках із багатьма обробниками подій, активними потоками WebSocket, частими викликами ngZone.run() або дуже інтерактивними панелями керування. Пам’ятайте, що ці опції стосуються виявлення змін на основі Zone.js; новіші версії Angular віддають перевагу виявленню змін без Zone, де планування вже здійснюється за допомогою сигналів та явних сповіщень, тож перевірте, який режим використовує ваш додаток, перш ніж їх застосовувати.
9. Знайдіть залежності, які ви не хотіли додавати
Деякі з найбільших проблем з продуктивністю — це не проблеми Angular, а проблеми імпорту. Вам може знадобитися лише одна функція з бібліотеки, проте ваша структура імпортів може завантажувати набагато більше:
Application
│
└── Library
│
├── Feature A
├── Feature B
├── Feature C
├── Feature D
└── Feature E
коли насправді ви використовували лише:
Feature A
Ось чому аналіз бандлу є невід’ємною частиною кожного дослідження продуктивності. Створіть статистику проекту для виробничої версії:
ng build --configuration=production --stats-json
Потім перевірте результат, щоб з’ясувати, які файли та бібліотеки становлять основну частину навантаження. Інструменти на кшталт source-map-explorer працюють з картами джерел, тому увімкніть їх під час збірки, яку ви аналізуєте; формат файлу зі статистикою залежить від інструменту збірки, який використовує ваш проект, тож перевірте, який аналізатор він підтримує. Важливе запитання полягає не в тому, чи додаток працює повільно, а саме в тому, що ви надсилаєте до браузера. Як тільки це стане видимим, більшість оптимізацій стануть очевидними. Щоб уникнути регресій пізніше, розгляньте можливість застосування бюджетів продуктивності в CI.
10. Перенесіть важкі обчислення з основної смуги
Іноді проблема не у фреймворку, а у самому алгоритмі. Припустимо, панель керування отримує набір даних такого розміру:
100,000 records
і виконує статистичні обчислення, обробку зображень, геометричні операції, складні трансформації чи агрегацію даних прямо у браузері. На основному потоці ці операції конкурують з усіма іншими завданнями:
Main Thread
│
├── Angular
├── Rendering
├── User input
├── Animations
└── 100K-record calculation
У результаті виникають проблеми: заморожений інтерфейс, переривчасті анімації та затримка відображення контенту. Web Worker переміщує обчислення на окремий потік та надсилає назад лише результат:
Main Thread Worker
│ │
│──── large dataset ─────>│
│ │
│ Calculate
│ Transform
│ Analyze
│ │
│<──── result ─────────────│
│
Render UI
Angular CLI може створити такого працівника та налаштувати його конфігурацію:
ng generate web-worker app
а компонент створює його за допомогою URL, відносного до модуля, щоб бандлер міг його знайти та скомпілювати:
const worker =
new Worker(
new URL('./app.worker', import.meta.url)
);
Робочі процеси не роблять власну роботу Angular дешевшою. Вони лише запобігають тому, щоб дорога логіка додатку блокувала відображення контенту та взаємодію користувача. Використовуйте робочі процеси лише тоді, коли обчислення справді складні, адже дані, які передаються між потоками, копіюються за допомогою структурованого клонування, і ця передача також має свою вартість. Щоб отримати нейтральний з точки зору фреймворку погляд на це рішення, перегляньте коли варто перемістити роботу CPU у Web Worker.
Поєднання технік у одному панелі керування
Це не десять ізольованих хитрощів; вони посилюють один одного. Неоптимізована панель аналітики виконує все заздалегідь:
Browser
│
├── Download huge JS bundle
├── Initialize everything
├── Hydrate everything
├── Start every API request
├── Recalculate large state
├── Execute expensive template functions
├── Perform DOM reads/writes
└── Calculate large datasets on main thread
З застосуванням цих технік кожен шар виконує лише те, що потрібно у дану мить:
Browser
│
├── Smaller initial bundle
│ └── @defer
│
├── Partial interactivity
│ └── Incremental hydration
│
├── Only latest API request
│ └── resource/httpResource
│
├── Narrow reactive updates
│ └── Fine-grained signals
│
├── Cached derived values
│ └── computed()
│
├── Heavy services loaded later
│ └── injectAsync()
│
├── Coordinated DOM work
│ └── afterRenderEffect()
│
├── Fewer redundant CD cycles
│ └── Event coalescing
│
├── Smaller dependencies
│ └── Bundle analysis
│
└── Heavy computation elsewhere
└── Web Worker
Додаток не просто працює швидше, а виконує значно менше операцій.
Компактна формула продуктивності
Один із способів описати цей підхід — це як суму скорочень:
Performance
=
Less JavaScript
+
Less Network Work
+
Less Reactive Work
+
Less DOM Work
+
Less Main-Thread Work
Ще коротше: швидкий Angular означає виконання меншої кількості операцій, їх виконання пізніше та лише тоді, коли це необхідно.
Практичний порядок оптимізації
Не застосовуйте все одночасно. Спочатку вимірюйте, а потім працюйте через різні рівні приблизно в такому порядку, адже ранні кроки зазвичай мають найбільший вплив на час запуску.
Спочатку скоротіть початковий JavaScript:
@defer
injectAsync()
bundle analysis
Друге — скоротіть початкову роботу з інтеграцією даних:
Incremental Hydration
Третє — усуньте зайву роботу в мережі:
resource()
httpResource()
Четверте — обмежте непотрібну інвалідацію реактивності:
Fine-grained signals
computed()
П’яте — скоротіть навантаження на відображення:
afterRenderEffect()
afterNextRender()
Шосте, видаліть зайвий моніторинг змін:
eventCoalescing
runCoalescing
Сьоме, усуньте обмеження продуктивності процесора:
Web Workers
Вимірюйте перед оптимізацією
Найважливіше правило — уникати сліпої оптимізації. Розміщення цих заходів скрізь не є стратегією:
OnPush
Так само, як і впровадження будь-яких з цих заходів без пізнання причини обмежень продуктивності:
memoization
workers
RxJS operators
defer blocks
Використовуйте Chrome DevTools, Lighthouse, Angular DevTools, аналіз бандлу у продакшені та панель продуктивності браузера для створення профілю. Потім поставте основне запитання:
What is actually slow?
І звузьте відповідь до конкретної категорії:
Network?
JavaScript?
Change detection?
Rendering?
Layout?
CPU?
Лише після того, як ви дізнаєтесь, який з цих факторів є основним, слід обрати відповідну техніку. Після кожної зміни знову вимірюйте, щоб підтвердити реальне покращення.
Старі поради щодо продуктивності Angular здебільшого стосувалися одного питання:
"How can I make change detection faster?"
Сучасний Angular пропонує більш корисне питання:
"Why am I running this work at all?"
Це друге питання є універсальним для кожного рівня. Чому завантажувати цей компонент саме зараз? Чому ініціалізувати цю частину саме зараз? Чому дочекуватися завершення HTTP-запиту, коли користувач вже змінив параметри? Чому анулювати всю структуру даних через одне поле? Чому перераховувати значення, якщо вхідні дані не змінилися? Чому змінювати DOM до завершення відображення? Чому виконувати обтяжені обчислення на потоці користувацького інтерфейсу? Кожна зайва операція — це можливість для покращення.
Основні висновки
Ці десять технік орієнтовані на різні рівні та кожна з них усуває певний вид марнотратства:
@deferу поєднанні з прямими імпортами зменшує обсяг початкового JavaScript-коду.- Інкрементальна ініціалізація зменшує обсяг роботи під час першої ініціалізації.
resource() із сигналом припинення видаляє застарілу мережеву роботу.computed() запобігає повторним обчисленням.afterRenderEffect() запобігають проблемам з макетуванням.Найшвидший додаток Angular — це не той, який виконує JavaScript найшвидше; це той, який виконує найменшу кількість зайвого JavaScript. Уся стратегія може бути описана одним реченням:
MAKE ANGULAR FAST
│
┌──────────┴──────────┐
│ │
DO LESS DO LATER
│ │
┌─────┼─────┐ ┌─────┼─────┐
│ │ │ │ │ │
Less Less Less Defer Lazy Hydrate
JS CPU DOM code data later
│ │ │ │ │ │
└─────┴─────┴─────────┴─────┴─────┘
│
▼
FASTER EXPERIENCE
Вимірюйте, знаходьте марну роботу, видаляйте її та знову вимірюйте.