Перейти к содержимому

64. Оптимизация производительности

Привет! Яша здесь. Производительность Angular-приложений — это не магия, а конкретные техники. Сегодня разберём всё: от OnPush до bundle analysis. После этого урока твои приложения будут летать 🚀


Angular по умолчанию использует Default стратегию change detection — она проверяет ВСЁ дерево компонентов при любом событии. Представь 1000 компонентов на странице — и каждый клик запускает 1000 проверок.

Default CD: каждое событие → проверить все компоненты сверху вниз
OnPush CD: только при изменении @Input или async pipe → намного меньше работы

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
// ✅ OnPush — компонент проверяется только когда:
// 1. Изменилась ссылка на @Input
// 2. Сработал EventEmitter (@Output)
// 3. async pipe получил новое значение
// 4. markForCheck() вызван вручную
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush, // ← КЛЮЧЕВАЯ СТРОКА
})
export class UserCardComponent {
@Input() user!: { name: string; email: string };
}
// ❌ Default — всегда проверяется
@Component({
selector: 'app-user-card',
template: `...`,
// changeDetection: ChangeDetectionStrategy.Default (по умолчанию)
})
export class SlowUserCardComponent {
@Input() user!: { name: string; email: string };
}

Правило: OnPush везде по умолчанию. Используй иммутабельные данные.


import { ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent implements OnInit {
data: DashboardData | null = null;
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
// Данные из WebSocket — Angular не знает об изменениях
this.websocket.on('data', (newData) => {
this.data = newData;
this.cdr.markForCheck(); // ← сообщаем Angular: "проверь меня!"
});
}
// detach/reattach — хирургический инструмент
pauseUpdates(): void {
this.cdr.detach(); // полностью отключить CD для компонента
}
resumeUpdates(): void {
this.cdr.reattach();
this.cdr.detectChanges(); // мгновенная проверка
}
}

@Component({
template: `
<!-- ❌ Без trackBy — каждый рендер пересоздаёт ВСЕ DOM-элементы -->
<div *ngFor="let item of items">{{ item.name }}</div>
<!-- ✅ С trackBy — пересоздаются только изменившиеся элементы -->
<div *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</div>
<!-- Современный синтаксис (Angular 17+) -->
@for (item of items; track item.id) {
<div>{{ item.name }}</div>
}
`
})
export class ListComponent {
items: Item[] = [];
trackById(index: number, item: Item): number {
return item.id; // ← уникальный идентификатор
}
}

// ✅ Pure pipe — пересчитывается только при изменении входных данных
@Pipe({
name: 'filterByStatus',
pure: true, // по умолчанию true
})
export class FilterByStatusPipe implements PipeTransform {
transform(items: Item[], status: string): Item[] {
console.log('Pipe called!'); // вызовется только при реальном изменении
return items.filter(item => item.status === status);
}
}
// ❌ Плохо — метод в шаблоне вызывается при КАЖДОЙ проверке!
@Component({
template: `
<!-- Вызывается при каждом CD — даже если items не изменились -->
<div *ngFor="let item of getFilteredItems()">{{ item.name }}</div>
<!-- ✅ Pipe кэшируется Angular -->
<div *ngFor="let item of items | filterByStatus:'active'">{{ item.name }}</div>
`
})
export class BadComponent {
getFilteredItems(): Item[] {
return this.items.filter(i => i.status === 'active'); // ← вызов при каждом CD!
}
}

app-routing.module.ts
const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.module')
.then(m => m.DashboardModule),
},
// Standalone компоненты (Angular 14+)
{
path: 'settings',
loadComponent: () => import('./settings/settings.component')
.then(c => c.SettingsComponent),
},
];

import { PreloadAllModules, RouterModule } from '@angular/router';
import { QuicklinkStrategy, QuicklinkModule } from 'ngx-quicklink';
// PreloadAllModules — загружает все модули после первой загрузки
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules,
})
// Кастомная стратегия — только помеченные маршруты
export class SelectivePreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
return route.data?.['preload'] ? load() : of(null);
}
}
const routes: Routes = [
{
path: 'popular-feature',
loadChildren: () => import('./feature/feature.module').then(m => m.FeatureModule),
data: { preload: true }, // ← предзагрузить
},
];

import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<!-- ✅ NgOptimizedImage: lazy loading, correct sizes, LCP optimization -->
<img ngSrc="assets/hero.jpg" width="800" height="400" priority>
<!-- ✅ С responsive srcset -->
<img ngSrc="https://cdn.example.com/photo.jpg"
width="400" height="300"
[ngSrcset]="'400w, 800w, 1200w'"
sizes="(max-width: 400px) 100vw, 50vw">
<!-- ❌ Обычный img без оптимизации -->
<img src="assets/hero.jpg">
`
})
export class HeroComponent {}

Окно терминала
npm install --save-dev webpack-bundle-analyzer
# 2. Собрать с генерацией статистики
ng build --stats-json
# 3. Запустить анализатор
npx webpack-bundle-analyzer dist/my-app/browser/stats.json
# Или добавить скрипт в package.json
# "analyze": "ng build --stats-json && npx webpack-bundle-analyzer dist/my-app/browser/stats.json"
// angular.json — настройка бюджетов сборки
{
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
]
}

import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<!-- Рендерит только видимые элементы — идеально для 10k+ items -->
<cdk-virtual-scroll-viewport itemSize="50" style="height: 400px">
<div *cdkVirtualFor="let item of items; trackBy: trackById"
style="height: 50px; display: flex; align-items: center;">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`
})
export class VirtualListComponent {
items = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: \`Item \${i}\` }));
}

// Улучшение LCP (Largest Contentful Paint)
// 1. Используй NgOptimizedImage с priority для hero-изображений
// 2. Предзагрузка критических шрифтов в index.html
// Улучшение FID/INP (Interaction to Next Paint)
// 1. Деферируй тяжёлые компоненты
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="chart-skeleton"></div>
}
// 2. Используй Web Workers для CPU-intensive задач
// 3. Избегай long tasks > 50ms в главном потоке
// Улучшение CLS (Cumulative Layout Shift)
// 1. Всегда указывай width/height для изображений
// 2. Резервируй место для асинхронного контента

// ✅ Архитектура
// - OnPush везде
// - Иммутабельные данные (spread, Object.assign)
// - trackBy для всех ngFor
// - Pure pipes вместо методов в шаблонах
// - Lazy loading всех feature-модулей
// ✅ Рендеринг
// - NgOptimizedImage для всех изображений
// - @defer для off-screen контента
// - CDK Virtual Scroll для больших списков
// - Избегать сложной логики в ngOnChanges
// ✅ Бандл
// - Анализ webpack-bundle-analyzer
// - Бюджеты в angular.json
// - Tree shaking (избегать side-effect imports)
// - esbuild вместо Webpack (Angular 17+)
// ✅ Загрузка
// - SSR/SSG для критических страниц
// - HTTP2 push для критических ресурсов
// - Service Worker для кэширования

Визуализация разницы Default vs OnPush change detection: