专业的编程技术博客社区

网站首页 > 博客文章 正文

Angular 模板内的函数调用是危险的!

baijin 2024-08-15 16:59:47 博客文章 17 ℃ 0 评论


有一天,我的一位同事在我们的应用程序中发现了一种奇怪的行为。 当他将 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 并根据 HTTP 请求的返回将 MenuItem 列表作为参数传递。 当我们的 HTTP 请求返回时,如果没有信息,我们将使用空字符串调用 getMenu 函数,如果不为空,则使用信息调用 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 运行新的更改检测周期,因此框架会重新检查每个绑定,从而导致新的函数调用。 这意味着 MainNavigationComponent 中的 getMenu 函数将被再次调用,并返回 MenuItems 的新实例。

但这还不是全部。

NavigationComponent 使用 NgFor 指令迭代数组。 当 MenuItem 的新实例作为输入传递到组件时,NgFor 会重新创建其列表。 NgFor 销毁列表中的所有 DOM 元素并重新创建它们。 这会导致 RouterLinkActive 实例的重新创建,从而导致另一轮更改检测,并且这将是无限的。

我们可以通过使用 NgFor 指令内的 trackBy 函数来避免这种情况。 该函数跟踪元素上的一个属性,并检查该属性是否仍然存在于新数组中。 如果属性不再存在或以前不存在,NgFor 只会销毁或创建元素。 在我们的例子中添加 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 将降低这种风险。 通过记忆“信号”,您将无需重新创建新实例。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表