Давайте начистоту. Большинство Angular-приложений пишутся по инерции. Мы используем паттерны, которые выучили на заре второй версии, и продолжаем тащить их за собой, игнорируя всё, что фреймворк предложил за последние годы. 

Фреймворк постоянно обновляется и дополняется, и уследить за всеми лучшими практиками почти нереально. В итоге даже опытные разработчики продолжают делать ошибки, которые когда-то не считались ошибками. В небольшом проекте это не страшно, но в крупном такие просчеты накапливаются и превращают код в трудноподдерживаемый легаси. Проблема в том, что фреймворк меняется, а привычки остаются.

В этой статье мы не будем говорить о базовых синтаксических ошибках или разбирать, где вы точку с запятой забыли. Речь пойдёт о более глубоком уровне, об архитектурных просчётах и антипаттернах, которые тиражируются из проекта в проект. Многие из этих привычек были допустимы в прошлом, но с приходом новых версий, вроде Angular 20, от них пора избавляться.

1. Не отписываются от подписок на observables

Частой ошибкой является то, что разработчики не отписываются от RxJS-подписок при удалении компонента. Это может вызвать утечки памяти, поскольку активные подписки продолжают функционировать в фоновом режиме.

В масштабных приложениях это приводит к увеличению потребления памяти, замедлению работы интерфейса и потенциальным сбоям в работе. В Angular 20 есть сигналы, которые позволяют обойтись без подписок, тем самым повышая производительность.

Плохой пример:

@Component({
  selector: 'app-example',
  template: '<p>{{ data }}</p>'
})
export class ExampleComponent implements OnInit {
  data: any;
  constructor(private service: DataService) {}
  ngOnInit() {
    // Кажется, что всё просто и работает... пока компонент не начал множиться
    this.service.data$.subscribe((value) =&gt;; {
      this.data = value;
    });
  }
}

Подписка остаётся активной после уничтожения компонента.

Хороший пример:

private destroyRef = inject(DestroyRef);

ngAfterViewInit() {
  // Работа с DOM после полной инициализации представления
  this.button = this.elementRef.nativeElement.querySelector('.btn');
}

ngOnInit() {
  const interval = setInterval(() =>; {}, 1000);
  
  //Автоотписка при уничтожении компонента
  this.destroyRef.onDestroy(() =>; {
    clearInterval(interval);
  });
}

Альтернатива с сигналами в Angular 20:

@Component({
  selector: 'app-example',
  template: '<p>{{ data() }}</p>'
})
export class ExampleComponent {
  data = signal(initialValue); // Используем сигнал

  constructor(private service: DataService) {
    // effect автоматически отслеживает зависимости и управляет подпиской
    effect(() =>; {
      const newData = this.service.getData();
      this.data.set(newData);
    });
  }
}

Рекомендация:

Всегда используйте механизмы отписки от Observable, такие как takeUntilasync pipe или destroy$. В новых версиях Angular лучше использовать сигналы, которые автоматически управляют подписками и предотвращают утечки памяти.

2. Слишком часто полагаются на any

Используешь any часто — ошибка! Используешь any везде — критическая ошибка! Соблазн использовать any велик — кажется, что так код пишется быстрее, и компилятор отстанет со своими ошибками. Но на деле это просто попытка выключить мозги у TypeScript. Ошибки, которые можно было бы обнаружить сразу, начинают всплывать только когда приложение уже запущено.

От этой привычки код становится нечитаемым, его страшно менять и сложно понимать. Хорошая новость в том, что в Angular 20 строгий режим типизации по умолчанию стал ещё строже и теперь активно ругается на чрезмерное увлечение any, помогая поддерживать код в чистоте с самого начала. Старайтесь использовать более конкретные типы или дженерики — ваши будущие коллеги (и вы сами) скажете себе спасибо.

Плохой пример:

constructor() {
  effect((onCleanup) =>; {
    const subscription = this.data$.subscribe();

  //Функция вызовется автоматически, когда компонент умрёт
    onCleanup(() =>; {
      subscription.unsubscribe();
    });
  });
}

Хороший пример:

interface ApiResponse { // Определяем чёткий интерфейс
  id: number;
  name: string;
  isActive: boolean;
}

getData(id: number): Observable { // Теперь всё прозрачно
  return this.http.get(`/api/data/${id}`);
}

Если тип действительно неизвестен, лучше использовать unknown, чем any. Это заставит вас проверить тип перед использованием, что безопаснее.

handleData(data: unknown) {
  if (typeof data === 'string') {
    // Только здесь TypeScript "знает", что data — это строка
    console.log(data.toUpperCase());
  }
}

Рекомендация:

Не ленитесь описывать интерфейсы. Включайте "strict": true в sconfig.json и относитесь к предупреждениям компилятора как к бесплатным советам от опытного коллеги.

3. Используют любимый, но старый *ngFor

Многие разработчики по старой привычке продолжают использовать *ngFor для циклов, хотя в Angular уже появилась более современная и эффективная альтернатива. Новые контрольные структуры @for и @if — это не просто другой синтаксис, а серьезное улучшение производительности и читаемости кода.

Старый подход (так делать не стоит):

<div> 
  <div>
    {{ item.name }}
  </div>
</div>

Современное решение:

@for (item of items; track item.id) {
  @if (item.isActive) {
    <div>{{ item.name }}</div>
  }
}

Рекомендация:

Новый синтаксис работает быстрее, особенно с большими списками, и избавляет от необходимости в лишних обёртках. Код становится чище и понятнее. В конструкции @for есть обязательный параметр track, который помогает Angular точнее отслеживать изменения элементов.

4. Игнорируют ChangeDetectionStrategy.OnPush

Это, пожалуй, самый популярный способ выстрелить себе в ногу, даже не заметив этого. Многие просто оставляют стратегию по умолчанию, потому что... ну, она же по умолчанию, значит, так надо. Это приводит к постоянной проверке всего дерева компонентов на предмет изменений вместо точечного обновления только тех частей, где данные действительно изменились.

Чем опасна стандартная стратегия:

При обычном подходе Angular проверяет компонент при любом изменении в приложении. Любой клик, любой таймер, любой чих в приложении заставляет его бежать и проверять всё дерево компонентов. В сложных интерфейсах это создаёт каскадные проверки десятков компонентов без реальной необходимости.

@Component({
  selector: 'app-dashboard',
  template: `
    
    
  `
})
export class DashboardComponent {
  // Любое событие в любом компоненте вызовет проверку ВСЕХ компонентов
}

Преимущества OnPush:

Стратегия OnPush работает по принципу «проверяй только когда нужно». Компонент обновляется только в случаях:

  1. Если ему на вход (@Input или input()) прилетела новая ссылка на данные.

  2. Внутри него самого или его детей произошло событие (например, клик).

  3. В его шаблоне отработал async пайп.

@Component({
  selector: 'app-user-card',
  template: `<div>{{ user().name }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush // Меняем
})
export class UserCardComponent {
  user = input(); // Обновление только при изменении user
}

Рекомендация:

Короче — используйте OnPush почаще, а лучше везде. Разницу в производительности на сложных страницах заметите сразу. Пропадут те самые микрофризы.

5. Не обрабатывают ошибки в HTTP-запросах

Игнорирование ошибок при сетевых запросах — распространённая оплошность. Если просто написать subscribe без catchError, и запрос упадёт, вы увидите сообщения об ошибке в консоли, а пользователь «молчит»: кнопка не работает, данные не появились, и вы сели в лужу.

Рискованный код:

this.http.get('/api/data').subscribe((data) =>; {
  this.data = data; // Что если запрос не вернётся?
});

Когда возникнет ошибка (сервер недоступен, 404 и т.п.), .subscribe() не получит данные, приложение не сообщит об этом и поведение станет непредсказуемым. 

Правильный подход (с RxJS и сигналами):

readonly data = signal([]);
readonly error = signal(null);

loadData() {
  this.http.get('/api/data').pipe(
    tap(() =>; this.error.set(null)), // Сбрасываем предыдущую ошибку перед загрузкой
    catchError((err) =>; {
      this.error.set('Не удалось загрузить данные');
      return of([]); // Возвращаем пустой массив, чтобы поток не прерывался
    })
  ).subscribe((result) =>; {
    this.data.set(result);
  });
}

Рекомендация: 

Всегда обрабатывайте возможные ошибки и уведомляйте пользователя — любой запрос к сети может упасть. Можно добавить сигналы состояния загрузки (например, isLoading = signal(false)) и показывать спиннер или сообщение об ошибке при необходимости. Никогда не полагайтесь на то, что запрос всегда сработает.

6. Вызывают методы в шаблоне, что приводит к частым перерисовкам

Прямой вызов методов (или геттеров) в шаблоне — частая причина лишних вычислений. Angular будет выполнять метод при каждой проверке изменений, даже если входные данные не менялись. Это может привести к падению производительности, особенно если метод что-то считает или обрабатывает большой массив данных.

Антипаттерн:

<div>{{ calculateTotal() }}</div>

Здесь calculateTotal() вызовется при каждом обновлении любого компонента в приложении. Если этот метод делает сложную операцию (например, суммирует значения большого массива), приложение неизбежно начнёт тормозить.

Эффективное решение:

total = computed(() =>; {
  return this.items().reduce((sum, item) =>; sum + item.price, 0);
});
<div>{{ total() }}</div> 

Мы оборачиваем вычисление в computed(): Angular вычислит total() только когда меняется само this.items(). После этого шаблон получает число напрямую. Метод calculateTotal() при этом не вызывается в шаблоне вообще. 

Это гораздо надёжнее, чем ручное кэширование. Автовычисление через computed экономит ресурсы и упрощает код.

7. Злоупотребляют вычисляемыми значениями в шаблонах (Геттеры)

Похожая проблема — геттеры, которые делают дорогие вычисления. Например, вы можете хранить в компоненте массив users и захотеть фильтровать активных пользователей:

Проблемный геттер:

get activeUsers() {
  // Фильтрация при каждой проверке изменений
  return this.users.filter(user =>; user.isActive); 
}

Если шаблон использует activeUsers, каждый раз при любом обновлении Angular заново вызовет фильтрацию всего массива. При большом количестве пользователей это чревато просадкой производительности.

Оптимизированные варианты:

Уже догадались, да? Снова computed:

activeUsers = computed(() =>; 
  this.users().filter(user =>; user.isActive)
);

Теперь activeUsers() пересчитается только при изменении this.users().

С ручным кэшированием:

private _activeUsers: User[] = [];
private _usersVersion = 0;

get activeUsers(): User[] {
  if (this._usersVersion !== this.usersVersion) {
    this._activeUsers = this.users.filter(user =>; user.isActive);
    this._usersVersion = this.usersVersion;
  }
  return this._activeUsers;
}

Здесь мы завели переменную версии списка. При изменении данных пересчитываем activeUsers, в противном случае возвращаем сохранённый результат. Такой подход сложнее в поддержке, чем вариант с computed. Мы вынуждены вручную отслеживать все места, где меняются зависимые данные, и не забывать увеличивать версию, что легко приводит к трудноуловимым багам при расширении приложения.

Рекомендация: 

Не делайте тяжёлую логику в геттерах/методах, которые вызываются из шаблона. Используйте computed, пайпы или предварительно вычисляйте результат – так Angular не будет выполнять повторные тяжёлые операции без необходимости.

8. Используют неправильную архитектуру модулей

Если весь ваш проект собран в едином монолитном модуле, то у вас проблема. Раздувая этот модуль, вы обречены на медленную загрузку. Аналогично, чрезмерно дробя функции, можно потерять в структуре и начать путаться, где что лежит. 

В Angular принято выделять крупные фичи в отдельные модули и использовать ленивую загрузку (lazy loading).

Проблема «Божественного модуля»:

@NgModule({
  declarations: [
    HeaderComponent,
    FooterComponent,
    UserListComponent,
    ProductGridComponent,
    /* и ещё 100500 компонентов в одном модуле */
  ],
  // ...
})
export class AppModule {}

Когда в одном модуле десятки компонентов, приложение долго загружается, а мелкое изменение вызывает пересборку всего проекта. Какой-нибудь Петя с мобильного интернета в посёлке городского типа может и не дождаться загрузки вашего сайта. Он останется недовольным, расскажет друзьям, какой ваш сервис галимый, и в перспективе вы теряете не одного клиента, а весь Петин двор.

Современная модульная структура (Lazy Loading):

// app.routes.ts
export const routes: Routes = [
  { 
    path: 'users', 
    loadChildren: () =>; import('./users/users.routes') 
  },
  { 
    path: 'products', 
    loadComponent: () =>; import('./products/product-shell.component') 
  }
];

// users.routes.ts
export default [
  { path: '', component: UserListComponent },
  { path: ':id', component: UserDetailComponent }
] satisfies Routes;

Мы разделили приложение на маршруты. Для пути 'users' используем loadChildren, чтобы загрузить отдельный модуль (или конфигурацию маршрутов пользователей) только по запросу. Для 'products' демонстрируем loadComponent, загружая главный компонент продуктов.

Пользователь зашёл на /users — загрузился код для юзеров. Зашёл на /products — подтянулся код для товаров. Начальная загрузка приложения становится в разы быстрее, а структура проекта — кристально понятной.

9. Плодят сервисы, как кроликов

Непонимание иерархии инжекторов Angular часто приводит к тому, что сервисы создаются в неправильном месте. Самая частая ловушка — объявить сервис в providers компонента без необходимости. Это означает, что для каждого экземпляра компонента Angular создаст свой объект сервиса вместо одного глобального.

Типичная ошибка:

@Component({
  // Новый экземпляр для каждого компонента
  providers: [UserService] 
})
export class UserComponent {
  constructor(private userService: UserService) {}
}

В этом примере у каждого UserComponent будет свой UserService. Обычно же сервисы предназначены быть singleton (один на всё приложение).

Правильные подходы:

Синглтон в корне приложения:

// Сервис сам говорит, что он один на всех.
@Injectable({ providedIn: 'root' })
export class UserService { }

Это самый распространённый способ: сервис автоматически регистрируется в корневом инжекторе и один его экземпляр живёт на всю жизнь приложения.

С ограниченной областью видимости:

@Injectable()
export class UserService { }

@Component({
  providers: [UserService] // Осознанное создание отдельного экземпляра
})
export class UserComponent { }

Такой подход используется, если вам действительно нужно несколько инстансов сервиса, например, каждый компонент с разными настройками. Обычно так делают реже и специально.

Использование inject функции:

@Component({})
export class UserComponent {
  private userService = inject(UserService);
  private config = inject(APP_CONFIG);
}

Вместо конструкции constructor(private userService: UserService) можно прямо внутри тела компонента получить сервис с помощью функции inject(). Это удобно в standalone-компонентах или когда не хочется объявлять конструктор. Такой подход делает код чище и более последовательным, особенно при использовании вместе с другими composition API, такими как effect() или viewChild().

Рекомендация: 

Обычно объявляйте сервисы с providedIn: 'root'. Если вам нужна тестовая или отдельная версия сервиса, тогда явно указывайте providers: [Service] на компоненте или модуле. И помните про иерархию инжекторов: служба, предоставленная в корне, не пересоздастся для дочернего модуля, а сервис из providers компонента не будет доступен глобально.

10. Неэффективно используют хуки жизненного цикла

Размещение логики в неподходящих хуках приводит к ошибкам инициализации и утечкам памяти.

Распространённая ошибка:

ngOnInit() {
  //ElementRef ещё не инициализирован
  this.elementRef.nativeElement.querySelector('.btn'); 
}

В ngOnInit в компоненте ещё не построено DOM-представление, шаблон не вставлен полностью. Для любых манипуляций с DOM (найти элемент, измерить его ширину, повесить сложный EventListener) есть специальный хук — ngAfterViewInit. Он гарантированно вызывается после того, как Angular построил и вставил в страницу весь вид вашего компонента и его детей.

Правильное использование:

private destroyRef = inject(DestroyRef);

ngAfterViewInit() {
  // Работа с DOM после полной инициализации представления
  this.button = this.elementRef.nativeElement.querySelector('.btn');
}

ngOnInit() {
  const interval = setInterval(() =>; {}, 1000);
  
  //Автоотписка при уничтожении компонента
  this.destroyRef.onDestroy(() =>; {
    clearInterval(interval);
  });
}

Тут onDestroy вызывается перед уничтожением компонента, а интервал очищается без утечек. Если вы не отпишетесь, этот код продолжит жить своей жизнью даже после того, как компонент будет уничтожен и пользователь уйдёт со страницы. Сами понимаете, мёртвые души положительно на ресурсы процессора и память не влияют.

Современная альтернатива с эффектами:

constructor() {
  effect((onCleanup) =>; {
    const subscription = this.data$.subscribe();

  //Функция вызовется автоматически, когда компонент умрёт
    onCleanup(() =>; {
      subscription.unsubscribe();
    });
  });
}

При использовании сигналов можно создавать эффекты. Внутри эффекта мы запускаем подписку, а затем указываем функцию onCleanup, которая сработает автоматически, когда компонент разрушится. Это удобнее, чем вручную отслеживать момент уничтожения. 

Рекомендация: 

Размещайте код в правильных хуках: для работы с представлением используйте ngAfterViewInit, а для очистки — ngOnDestroy (или DestroyRef). При любых асинхронных операциях не забывайте очищать подписки/таймеры, чтобы избежать утечек памяти.


Так что в итоге? Путь к чистому и производительному коду на Angular — это не про зазубривание правил и шаблонов. Опытный разработчик сначала думает, а потом делает. Большинство перечисленных ошибок проистекают из желания сделать побыстрее, сэкономить пять минут на описании интерфейса или продумывании структуры. Но, как показывает практика, эти сэкономленные минуты оборачиваются часами, а то и днями отладки и рефакторинга в будущем.

Команда Angular не от скуки всё это придумала. Сигналы, OnPush-стратегии и новый синтаксис контрольных структур — это целенаправленные шаги команды фреймворка, чтобы помочь нам писать код, который лучше справляется с современными требованиями к веб-приложениям. К вопросу архитектуры нужного всегда подходить вдумчиво. Не стремитесь исправить всё и сразу, но начните с самого больного места в вашем проекте и сразу почувствуете разницу. 

Удачи в написании качественного кода! ଲ(ⓛ ω ⓛ)ଲ

© 2025 ООО «МТ ФИНАНС»

Комментарии (1)


  1. pavel_shabalin
    28.11.2025 13:44

    Странно как-то:

    interface ApiResponse { // Определяем чёткий интерфейс
      id: number;
      name: string;
      isActive: boolean;
    }
    
    getData(id: number): Observable { // Теперь всё прозрачно
      return this.http.get(`/api/data/${id}`);
    }

    Определяем чёткий интерфейс и... не используем?