Более быстрый Angular за счёт меньшего объёма работы: десять техник помимо OnPush и trackBy
Сокращение ненужной работы сети, реактивности, DOM и процессора в современном Angular благодаря @defer, поэтапной загрузке ресурсов, функции resource(), вычисляемым сигналам, фазам отрисовки и веб-рабочим процессам.
Источники: 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 />
Оборачивание тяжелого компонента в блок с задержкой позволяет Angular загружать его код только тогда, когда происходит соответствующее событие, например, когда заменитель прокручивается в область отображения:
@defer (on viewport) {
<app-heavy-chart />
}
Избегайте использования отложенных компонентов в импортах типа barrel
Есть нюанс: @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, а также помнить, что отмена запроса — это не механизм дебаунсинга. Каждое нажатие клавиши всё равно запускает запрос, который затем прерывается; поэтому, если вы хотите избежать их отправки, необходимо сочетать использование ресурса с сигналом запроса, обработанным через механизм дебаунсинга. Самым быстрым запросом является тот, который прерывается сразу же, как только он становится нерелевантным, и такой подход эффективен для поиска, автодополнения, фильтрации, динамических панелей управления, быстро меняющихся идентификаторов и последовательностей взаимозависимых вызовов.
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
Каждое чтение после записи может заставить браузер синхронно пересчитывать макет, что называется проблемой частых пересчетов макета. 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 может создать структуру для Web Worker и настроить его конфигурацию:
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
Измерьте, определите потраченные зря ресурсы, устраните их, а затем снова измерьте.