На днях один из моих коллег обнаружил странное поведение внутри нашего приложения. Когда он добавил RouterLinkActive к ссылке, приложение перестало отображаться. Однако после удаления директивы приложение заработало корректно.

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

Чтобы лучше понять проблему, ниже приведено небольшое ее воспроизведение:

interface MenuItem {
  path: string;
  name: string;
}

@Component({
  selector: 'app-nav',
  standalone: true,
  imports: [RouterLink, NgFor, RouterLinkActive],
  template: `
    <ng-container *ngFor="let menu of menus">
      <a
        [routerLink]="menu.path"
        [routerLinkActive]="isSelected"
          >
        {{ menu.name }}
      </a>
    </ng-container>
  `,
})
export class NavigationComponent {
  @Input() menus!: MenuItem[];
}

@Component({
  standalone: true,
  imports: [NavigationComponent, NgIf, AsyncPipe],
  template: `
    <ng-container *ngIf="info$ | async as info">
      <ng-container *ngIf="info !== null; else noInfo">
        <app-nav [menus]="getMenu(info)" />
      </ng-container>
    </ng-container>

    <ng-template #noInfo>
      <app-nav [menus]="getMenu('')" />
    </ng-template>
  `,
})
export class MainNavigationComponent {
  private fakeBackend = inject(FakeServiceService);

  readonly info$ = this.fakeBackend.getInfoFromBackend();

  getMenu(prop: string) {
    return [
      { path: '/foo', name: `Foo ${prop}` },
      { path: '/bar', name: `Bar ${prop}` },
    ];
  }
}

MainNavigationComponent отобразит NavigationComponent и передаст список MenuItem в качестве аргумента в зависимости от возврата HTTP-запроса. Когда наш HTTP-запрос возвращается, мы вызываем функцию getMenu с пустой строкой, если информация отсутствует, или с информацией, если она не пуста.

NavigationComponent будет перебирать MenuItem и создавать ссылку для каждого элемента, используя RouterLink и RouterLinkActive для маршрутизации.

На первый взгляд этот код кажется правильным, но применение RouterLinkActive к каждой ссылке прерывает рендеринг без ошибок в консоли.

Что может произойти? 🤯

Чтобы лучше понять проблему, давайте разберем RouterLinkActive на код, вызывающий бесконечный цикл рендеринга:

import { Directive } from '@angular/core';

@Directive({
  selector: '[fake]',
  standalone: true,
})
export class FakeRouterLinkActiveDirective {
  constructor(private readonly cdr: ChangeDetectorRef) {
    queueMicrotask(() => {
      this.cdr.markForCheck();
    });
  }
}

Внутри RouterLinkActive мы вызываем this.cdr.markForCheck(), чтобы пометить наш компонент как грязный. Однако мы вызываем эту функцию в рамках другой микрозадачи. Как только наша текущая макрозадача завершится, Angular запустит новый цикл обнаружения изменений в следующей микрозадаче.

Имея эту информацию, можете ли вы обнаружить проблему сейчас?

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

Но это не все.

NavigationComponent выполняет итерацию по массиву, используя директиву NgFor. Когда новый экземпляр MenuItem передается компоненту как Input, NgFor воссоздает свой список. NgFor уничтожает все элементы DOM внутри списка и воссоздает их. Это приведет к воссозданию экземпляра RouterLinkActive, что приведет к следующему раунду обнаружения изменений, который будет бесконечным.

Мы можем избежать этого, используя функцию trackBy внутри директивы NgFor. Эта функция отслеживает одно свойство элемента и проверяет, существует ли это свойство в новом массиве. NgFor будет УНИЧТОЖАТЬ или СОЗДАТЬ элемент только в том случае, если свойство больше не существует или не существовало ранее. Добавление функции trackBy в нашем случае исправит проблему бесконечного повторного рендеринга.

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



Однако даже если функция trackBy устраняет эту ошибку, создание нового экземпляра MenuItem в каждом цикле обнаружения изменений является плохой практикой.

Одним из способов избежать этого было бы создать свойство класса menuItem, но это привело бы к созданию императивного кода и привело бы к спагетти-коду.

Лучший способ — применить более декларативный подход. Давайте посмотрим, как провести рефакторинг кода более декларативным способом:

@Component({
  standalone: true,
  imports: [NavigationComponent, AsyncPipe],
  template: ` <app-nav [menus]="(menus$ | async) ?? []" /> `,
})
export class MainNavigationComponent {
  private fakeBackend = inject(FakeServiceService);

  readonly menus$ = this.fakeBackend
    .getInfoFromBackend()
    .pipe(map((info) => this.getMenu(info ?? '')));

  getMenu(prop: string) {
    return [
      { path: '/foo', name: `Foo ${prop}` },
      { path: '/bar', name: `Bar ${prop}` },
    ];
  }
}

Наше свойство menus$ теперь определено в одном месте и будет обновляться при возврате getInfoFromBackend. menu$ не будет пересчитываться при каждом цикле обнаружения изменений, и за все время существования MainNavigationComponent будет создан только один экземпляр. Код выглядит проще, не так ли?

Вы, наверное, слышали, что вызов функций внутри шаблонов — плохая практика, и в большинстве случаев это так. Хотя вы можете вызвать функцию для доступа к вложенным свойствам объекта, это должно быть одним из немногих исключений. Старайтесь избегать вызова функций внутри привязок вашего шаблона или убедитесь, что вы действительно понимаете, что вы делаете, и все побочные эффекты, которые могут быть созданы этим вызовом функции. При попытке изменить данные посредством вызова функции в вашей голове должно появиться предупреждение. В большинстве случаев существует лучший декларативный подход к вашей проблеме. Декларативное программирование — это другое состояние ума, но к нему следует стремиться. Продолжайте, и ваш код станет понятнее и проще, и ваши коллеги и вы сами в будущем скажите вам за это спасибо.

Я надеюсь, что эта статья прольет свет на последствия вызова функций внутри привязок шаблона.

Примечания. В будущем Angular с «сигналом» снизит этот риск. Запоминание «сигнала» избавит вас от воссоздания новых экземпляров. 🔥

Вы можете найти меня на Medium, Twitter или Github. Не стесняйтесь обращаться ко мне, если у вас есть какие-либо вопросы.