Оптимизация производительности Angular
Оптимизация Angular-приложений: OnPush change detection, trackBy, deferrable views, virtual scrolling, lazy loading, tree-shaking и профилирование с Angular DevTools.
Почему Angular может тормозить
Angular по умолчанию проверяет все компоненты при каждом событии (клик, ввод, таймер, HTTP-ответ). В большом приложении это тысячи проверок за один цикл. Если компоненты тяжёлые или их много, приложение начинает тормозить.
Оптимизация сводится к трём стратегиям:
- Меньше проверок — OnPush, trackBy
- Меньше DOM-элементов — virtual scrolling, lazy loading
- Меньше кода в бандле — tree-shaking, code splitting
OnPush Change Detection
По умолчанию Angular использует стратегию Default: при каждом событии проверяет весь дерево компонентов. Стратегия OnPush говорит Angular проверять компонент только когда:
- Изменился
@Input()(новая ссылка) - Сработало событие самого компонента (клик, инпут)
- Сработал
AsyncPipe - Вручную вызван
ChangeDetectorRef.markForCheck()
Включение OnPush
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'app-task-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ task().title }}</h3>
<span>{{ task().difficulty }}</span>
</div>
`
})
export class TaskCardComponent {
task = input.required<Task>()
}
Когда OnPush работает автоматически
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Input изменился — Angular проверит -->
<app-user-card [user]="currentUser" />
<!-- AsyncPipe обновил значение — Angular проверит -->
<p>{{ title$ | async }}</p>
<!-- Сигнал изменился — Angular проверит (v17+) -->
<p>{{ count() }}</p>
<!-- Клик внутри компонента — Angular проверит -->
<button (click)="doSomething()">Click</button>
`
})
Когда нужно markForCheck
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ data }}</p>`
})
export class DataComponent implements OnInit {
data: string = ''
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
// setTimeout не триггерит OnPush автоматически в некоторых случаях
setTimeout(() => {
this.data = 'Загружено'
this.cdr.markForCheck()
}, 1000)
}
}
Но если используете Signals — markForCheck не нужен:
// С Signals OnPush работает автоматически
data = signal('')
ngOnInit() {
setTimeout(() => {
this.data.set('Загружено') // Angular сам обновит представление
}, 1000)
}
trackBy и @for track
Когда Angular перерисовывает список, он уничтожает и создаёт все элементы DOM заново. track помогает Angular понять, какие элементы уже существуют:
@for (Angular 17+)
<!-- track обязателен в @for — Angular использует его для оптимизации -->
@for (task of tasks(); track task.id) {
<app-task-card [task]="task" />
} @empty {
<p>Нет задач</p>
}
*ngFor с trackBy (старый синтаксис)
@Component({
template: `
<div *ngFor="let task of tasks; trackBy: trackById">
{{ task.title }}
</div>
`
})
export class TaskListComponent {
tasks: Task[] = []
trackById(index: number, task: Task): string {
return task.id
}
}
Без trackBy при обновлении списка Angular уничтожит и пересоздаст все DOM-элементы. С trackBy — обновит только изменившиеся.
Deferrable Views (@defer)
Angular 17.3+ позволяет отложить загрузку и рендеринг части шаблона:
Загрузка при попадании в viewport
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-placeholder">Загрузка графика...</div>
} @loading (minimum 500ms) {
<mat-spinner />
}
Загрузка по условию
@defer (when showComments()) {
<app-comments [taskId]="taskId()" />
} @placeholder {
<p>Комментарии скрыты</p>
}
Загрузка при hover
@defer (on hover) {
<app-user-profile [userId]="userId()" />
} @placeholder {
<div class="avatar-placeholder" />
}
Загрузка при взаимодействии
@defer (on interaction) {
<app-video-player [src]="videoUrl()" />
} @placeholder {
<div class="video-thumbnail">
<span>Нажмите для воспроизведения</span>
</div>
}
Prefetch — предварительная загрузка
<!-- Загрузить при hover, показать при клике -->
<div (click)="show = true">
@defer (on interaction; prefetch on hover) {
<app-heavy-module />
} @placeholder {
<p>Наведите для предзагрузки</p>
}
</div>
@defer создаёт отдельный чанк — код подгружается только когда нужен. Это уменьшает начальный бандл.
Virtual Scrolling
Виртуальный скроллинг рендерит только видимые элементы списка. Если у вас 10 000 задач — в DOM будут только те, что видны на экране (плюс буфер):
import { ScrollingModule } from '@angular/cdk/scrolling'
@Component({
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="72" class="viewport">
<div *cdkVirtualFor="let task of tasks" class="item">
<span>{{ task.title }}</span>
<span>{{ task.difficulty }}</span>
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport {
height: 600px;
}
.item {
height: 72px;
display: flex;
align-items: center;
}
`]
})
export class TaskListComponent {
tasks: Task[] = [] // Может быть 10 000+
}
itemSize — высота одного элемента в пикселях. CDK использует это для вычисления, какие элементы видны.
С динамической высотой
<cdk-virtual-scroll-viewport [minBufferSize]="10">
<div *cdkVirtualFor="let task of tasks; autoSize">
<p>{{ task.title }}</p>
<p>{{ task.description }}</p>
</div>
</cdk-virtual-scroll-viewport>
Pure Pipes
Pure Pipes пересчитываются только при изменении входных данных. Impure Pipes — при каждой проверке change detection:
// Pure (по умолчанию) — пересчитывается только при изменении аргументов
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 50): string {
return value.length > limit ? value.slice(0, limit) + '...' : value
}
}
// Impure — пересчитывается при КАЖДОМ change detection
// ⚠️ Используйте только если действительно нужно
@Pipe({ name: 'filterTasks', standalone: true, pure: false })
export class FilterTasksPipe implements PipeTransform {
transform(tasks: Task[], filter: string): Task[] {
return tasks.filter(t => t.difficulty === filter)
}
}
Лучше использовать computed сигналы вместо impure pipes:
// Вместо impure pipe
filteredTasks = computed(() =>
this.tasks().filter(t => t.difficulty === this.filter())
)
Lazy Loading модулей и компонентов
Lazy loading маршрутов
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'tasks',
loadChildren: () => import('./features/tasks/task.routes')
.then(m => m.TASK_ROUTES)
},
{
path: 'admin',
loadComponent: () => import('./features/admin/admin.component')
.then(m => m.AdminComponent)
},
]
Preloading стратегия
Загрузить lazy-модули заранее, пока пользователь на главной:
import { PreloadAllModules, provideRouter } from '@angular/router'
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, PreloadAllModules)
]
}
Кастомная стратегия — загружать только определённые модули:
import { PreloadingStrategy, Route } from '@angular/router'
import { Observable, of } from 'rxjs'
export class SelectivePreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (route.data?.['preload']) {
return load()
}
return of(null)
}
}
// В маршрутах
{
path: 'tasks',
loadChildren: () => import('./features/tasks/task.routes').then(m => m.TASK_ROUTES),
data: { preload: true }
}
// В app.config
provideRouter(routes, SelectivePreloadingStrategy)
Оптимизация бандла
Tree-shaking
Angular CLI (esbuild) автоматически удаляет неиспользуемый код. Но нужно帮他:
// ❌ Плохо — импортирует всю библиотеку
import _ from 'lodash'
// ✅ Хорошо — импортирует только нужное
import debounce from 'lodash/debounce'
Анализ бандла
# Сборка с source map
ng build --source-map
# Анализ
npx source-map-explorer dist/my-app/browser/*.js
Откроется визуализация — видно, что занимает место в бандле.
Budgets — лимиты размера
В angular.json:
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
]
Если бандл превысит лимит — сборка упадёт с ошибкой.
Профилирование с Angular DevTools
Angular DevTools — расширение для Chrome:
- Установите Angular DevTools
- Откройте DevTools → вкладка «Angular»
- Profiler — записывает циклы change detection, показывает какие компоненты проверялись и сколько времени это заняло
Как использовать:
- Начать запись
- Выполнить действие (клик, ввод)
- Остановить запись
- Посмотреть, какие компоненты проверялись и почему
Если компонент проверяется без изменений — это кандидат на OnPush.
Практический чеклист оптимизации
| Проблема | Решение |
|---|---|
| Тормозит список | @for track, virtual scrolling |
| Компонент перерисовывается без изменений | OnPush |
| Большой начальный бандл | Lazy loading, @defer |
| Тяжёлый компонент не сразу нужен | @defer on viewport |
| Большие зависимости (lodash, moment) | Tree-shaking, альтернативы (date-fns) |
| Медленный change detection | Angular DevTools Profiler → найти горячие компоненты |
| Повторные HTTP-запросы | Кэширование (интерцептор или Transfer State) |
| Impure pipe | Переписать на computed |
| Нет preload статики | Service Worker + @angular/pwa |
Итог
Оптимизация Angular — это не магия, а набор конкретных техник:
- OnPush — включайте везде, где используются Signals или immutable Inputs
- track в
@for— обязательно, без исключений - @defer — откладывайте тяжёлые компоненты до момента, когда они нужны
- Virtual scrolling — для списков длиннее 100 элементов
- Lazy loading — для маршрутов, которые пользователь может не посетить
- Budgets — установите лимиты и следите за размером бандла
Начинайте с OnPush + trackBy — это даёт 80% эффекта за 20% усилий. Angular DevTools подскажет, где проблема.