发布于 2026-01-06 3 阅读
0

Help Angular to make your application faster DEV's Worldwide Show and Tell Challenge Presented by Mux: Pitch Your Projects!

帮助 Angular 加快您的应用程序运行速度

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

请在 Twitter 上关注我:@tim_deschryver | 原文发表于timdeschryver.dev


简单介绍一下背景,我们公司正在开发一款应用程序,用于安排护理人员的日常轮班。
该程序以一周日历的形式显示多位护理人员的轮班安排,通常同时安排20到50位护理人员的轮班。

在日历视图中,每行代表一位照护者,每列代表一周中的每一天。
如果全部加载完毕,日历上总共可以显示超过 1500 个项目。
除了日历之外,还有几个侧边栏,提供便捷的实用功能视图,例如,显示本周仍需安排的项目或冲突的预约。

从技术上讲,这是一个 Angular 应用,并且使用了 NgRx。
主日历视图采用增量加载的方式,其中包含不同的日历项(主要包括预约和缺勤),它们都是并行获取的。
一旦最重要的数据加载完毕,侧边栏就会加载,视图也会随之更新。
我们还会加载下一周的日程安排,以实现平滑的周过渡。
日历视图使用一个 NgRx 选择器来组合多个状态切片,因此当数据发生变化时,整个视图都会更新。这使得开发过程非常愉快,推送架构万岁!

在开发后期,当所有不同的组件都加载完毕后,我们开始发现性能问题。总体来说问题不大,但会出现一些小故障,这些故障在制定计划时是可以理解的。例如,鼠标会有些延迟,弹出窗口打开也很慢。

在本文中,我们将探讨我们为保持视图流畅性所做的更改。

根本原因

在主组件的生命周期钩子中执行了一些console.log语句后,我们注意到大多数组件的渲染次数过多。这产生了连锁反应,导致一些负载较高的函数执行次数也过多。我们的主要任务是大幅减少变更检测周期。OnChanges

我们已经将ChangeDetectionStrategy所有组件都配置为纯管道ChangeDetectionStrategy.OnPush,并且在应用程序的多个地方都使用了纯管道。
这些良好的实践让我们取得了长足的进步,但在开发后期阶段却远远不够。

解决方案

@hostlistener运行一个新的变更检测周期

这个我之前不知道。
日历组件支持不同的快捷键,我们使用@HostListener装饰器来响应keydown事件。
当装饰器发出新事件时,它会运行组件的变更检测周期。
即使按下的键没有被处理,组件的状态也没有被修改。

为了解决这个问题,我们改用 RxJSfromEvent方法来检测何时按下按键。

处理后的事件会被分发到 NgRx store 以修改状态。
相比于每个keydown事件都触发更新,这种改变使得视图仅在 NgRx store 中的状态发生变化时才更新。

@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
    const events = {
      'ArrowLeft': this.previousWeek,
      'ArrowRight': this.nextWeek,
    }
    const event = events[event.key]
    if (event) {
      event();
    }
}
Enter fullscreen mode Exit fullscreen mode
ngAfterViewInit() {
  fromEvent(document, 'keydown')
    .pipe(
      map((event: KeyboardEvent) => {
        const events = {
          'ArrowLeft': this.previousWeek,
          'ArrowRight': this.nextWeek
        }
        return events[event.key]
      }),
      filter(Boolean),
      tap(evt => evt()),
      takeUntil(this.destroy)
    )
    .subscribe();
}
Enter fullscreen mode Exit fullscreen mode

前期做好繁重的工作(而且只需一次)

初始的 NgRx 选择器返回了一个护理人员列表和一个预约列表。
日历组件中有一个遍历护理人员列表的循环。在这个循环内部,我们又有一个遍历当前周的循环。为了获取给定日期护理人员的预约,我们使用了相应的getCaregiverSchedule方法。该方法会筛选出当前员工在当天的预约。

<div class="row" *ngFor="let caregiver of calendar.caregivers">
  <caregiver-detail [caregiver]="caregiver"></caregiver-detail>
  <caregiver-day-appointments
    *ngFor="let day of days"
    [scheduleItems]="getCaregiverSchedule(caregiver.id, day)"
  ></caregiver-day-appointments>
</div>
Enter fullscreen mode Exit fullscreen mode
getCaregiverSchedule(caregiverId: number, date: Date) {
  return this.calendar.scheduleItems.filter(
    item => item.caregiverId === caregiverId && dateEquals(item.date, date)
  );
}
Enter fullscreen mode Exit fullscreen mode

对于一位护理人员,该getCaregiverSchedule方法被调用了 7 次。如果屏幕上有 20 位护理人员,则该方法被执行了 140 次。

正是这种方法遇到了问题,因为它包含了所有护理人员的所有预约列表,需要遍历每个护理人员每天的预约列表。乍一看,这似乎没什么大不了的。但是……由于输入内容发生了变化,这会触发子组件的变更检测周期。更糟糕的是,每当该组件的 Angular 变更检测周期运行时,这种情况都会重复发生。

我们注意到,这个方法在几秒钟内很容易被调用近 2000 次,而且是反复调用。这也是我们修改HostListener 的
主要原因,因为每次按键都会执行此方法,这并没有改善性能。

为了解决这个问题,我们将筛选逻辑移到了 NgRx 选择器中,这才是它应该在的地方。
我们没有使用两个独立的列表,而是对数据进行建模以服务于视图。
我们移除了预约列表,并将其作为属性移到了护理人员身上。
这样一来,护理人员的筛选逻辑只会在选择器发出新输出时执行一次。
由于护理人员及其预约的引用保持不变,因此caregiver-day-appointments组件不会运行变更检测。

现在HTML视图如下所示。

<div class="row" *ngFor="let caregiver of calendar.caregivers">
  <caregiver-detail [caregiver]="caregiver"></caregiver-detail>
  <caregiver-day-appointments
    *ngFor="let day of days"
    [scheduleItems]="caregiver.scheduleItems"
    [day]="day"
  ></caregiver-day-appointments>
</div>
Enter fullscreen mode Exit fullscreen mode

对我来说,这一改变也使代码更易读、更易于操作。

纯管道以防止方法调用

上次修改之后,我们又犯了同样的错误。
我们已经将预约按护理人员分组,但仍然需要按日期筛选预约。
为此,我们创建了一个新方法来筛选指定日期的预约。
虽然情况没有之前那么糟糕,但仍然运行了很多次,几乎所有运行都是不必要的。

为了解决这个问题,我们没有重新构建状态,因为我们不想将预约按星期几拆分。
这样做会使处理护理人员的预约变得更加困难,而我们仍然希望能够轻松访问预约数组以进行计算。

这就是为什么我们选择使用Pure Pipe 的原因。

Angular 仅在检测到输入值发生纯粹变化时才会执行纯管道。纯粹变化指的是原始输入值(字符串、数字、布尔值、符号)的改变,或是对象引用(日期、数组、函数、对象)的改变。Angular
会忽略(复合)对象内部的变化。如果您更改输入月份、向输入数组添加元素或更新输入对象属性,Angular 都不会调用纯管道。
这看似限制较多,但速度很快。对象引用检查速度很快——远比深度差异检查快得多——因此 Angular 可以快速判断是否可以跳过管道执行和视图更新。
正因如此,如果您可以接受这种变更检测策略,则最好使用纯管道。如果不能接受,则可以使用非纯管道。

管道仅在检测到输入值发生变化时才会执行。与策略
一样,当值的引用发生变化时,就会检测到变化。OnPush

由于我们之前已经重构了状态,因此可以确保对预约的引用保持不变。
这样一来,管道只会执行一次,caregiver-day组件的变更检测也只会运行一次。

<div class="row" *ngFor="let caregiver of calendar.caregivers">
  <caregiver-detail [caregiver]="caregiver"></caregiver-detail>
  <caregiver-day-appointments
    *ngFor="let day of days"
    [scheduleItems]="caregiver.scheduleItems | filterAppointmentsByDate: day"
    [day]="day"
  ></caregiver-day-appointments>
</div>
Enter fullscreen mode Exit fullscreen mode
@Pipe({ name: 'filterAppointmentsByDate' })
export class FilterAppointmentsByDatePipe implements PipeTransform {
  transform(appointments: Appointment[], date: Date) {
    return appointments.filter(appointment =>
      dateEquals(appointment.date, date),
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

trackBy 用于减少 DOM 突变的数量

我们知道在 HTML 视图中调用方法会影响性能。但出乎意料的是, `create` 方法
并没有按预期工作 我们原本以为,由于使用了 ` create` 方法,模板中的方法只会执行一次。 但事实并非如此。`create`方法仅用于创建或删除 DOM 节点。trackBy
trackByngFor
trackBy

此函数定义了如何跟踪可迭代对象中项的更改。
当可迭代对象中添加、移动或删除项时,指令必须重新渲染相应的 DOM 节点。为了最大限度地减少 DOM 的频繁更新,仅重新渲染已更改的节点。

我并不是说这种trackBy方法没用,它确实有用。它能帮助 Angular 判断何时需要重新渲染 DOM 节点,何时不需要。它确保只有受影响的节点才会被修改。我们需要做的越少越好。

大型列表的虚拟滚动

由于护理人员列表可能很长,因此会创建大量的组件实例及其对应的 DOM 节点。
这些组件内部的逻辑也会被执行,状态会被存储,订阅会被建立,变更检测周期也会运行。这会给我们的设备带来不必要的负担。这就是我们添加虚拟滚动的原因。

虚拟滚动只会创建视图中可见的组件实例。
为此,我们使用了Angular Material 的滚动 CDK 。

更改后,只会创建可见的照护者行。
最坏情况下,目前这会将 50 个照护者组件实例减少到 10 个。
此更改也具有前瞻性,因为以后可能会添加更多照护者。

从组件层面来看,这意味着将不会创建 40 个照护者组件,也不会创建所有子组件。
如果每位照护者每天有 10 个预约,那么就会有 400 个子组件无法创建。这还不包括更深层级的子组件。

对我们开发者来说,最棒的是这只是一个很小的改动。只需要五分钟就能完成,大部分时间都花在了查阅文档上。

要实现这一点,只需将你的组件包裹在一个cdk-virtual-scroll-viewport组件中,设置它的属性itemSize,然后将*ngFor指令替换为*cdkVirtualFor指令即可。这两个指令共享相同的 API。就这么简单!

<cdk-virtual-scroll-viewport itemSize="160" style="height:100%">
  <div
    class="row"
    *cdkVirtualFor="let caregiver of calendar.caregivers; trackBy: trackBycaregiver"
  >
    <caregiver-detail [caregiver]="caregiver"></caregiver-detail>
    <caregiver-day-appointments
      *ngFor="let day of days; trackBy: trackByDay"
      [scheduleItems]="caregiver.scheduleItems | filterAppointmentsByDate: day"
      [day]="day"
    ></caregiver-day-appointments>
  </div>
</cdk-virtual-scroll-viewport>
Enter fullscreen mode Exit fullscreen mode

参考检查(NgRx)

另一个罪魁祸首是 NgRx 的主选择器,它返回照护者列表及其日程安排。
该选择器执行了过多的操作。每次日程安排更改后,该选择器都会执行并返回一个带有新引用的新结果。

为了加快应用程序在周跳转时的速度,我们在加载当前周数据时同时加载下一周的数据。
我们使用相同的 API 调用来加载下一周和当前周的数据。这也意味着每次收到 API 响应时,我们都会修改应用程序的状态。

当状态发生改变时,选择器会接收到新的输入并执行。由于我们使用了多个 API 调用,这意味着每次 API 响应后,用于构建视图的选择器都会重复执行。每次执行时,选择器都会向组件发送一个新值,从而触发 Angular 的变更检测。

但为什么选择器会认为它接收到了一个新值呢?
选择器会在接收到不同输入时执行,它使用相等性检查===来判断输入是否发生了变化。
这种检查开销很小,执行速度很快。这在大多数情况下都适用。

在我们的例子中,有一个主selectCurrentWeekView选择器来构建视图。它使用了不同的选择器,每个选择器负责从状态中读取数据并筛选出本周的项目。由于我们使用了该Array.prototype.filter()方法,它总是会创建一个新的引用,因此相等性检查会失败。因为所有“子选择器”都会创建新的引用,所以主选择器会在每次更改时执行一次。

export const selectCurrentWeekView = createSelector((selectCaregivers, selectItemsA, selectItemsB, selectItemsC), (caregivers, a, b, c) => ...)
Enter fullscreen mode Exit fullscreen mode

为了解决这个问题,我们可以使用 RxJSdistinctUntilChanged操作符,并验证新输出是否与当前输出不同。虽然简单的JSON.stringify检查就能判断输出是否相同,但我们首先快速检查长度是否相同,因为在这种情况下速度更快。

distinctUntilChanged:返回一个 Observable,该 Observable 发出源 Observable 发出的所有与前一个项不同的项。

与对整个组件树运行 Angular 变更检测相比,这种额外的检查速度更快。

calendar = this.store.pipe(
  select(selectCurrentWeekView),
  distinctUntilChanged(
    (prev, current) =>
      prev.caregivers === current.caregivers &&
      prev.caregivers.length === current.caregivers.length &&
      prev.caregivers.reduce((a, b) => a.concat(b.scheduleItems), []).length ===
        current.caregivers.reduce((a, b) => a.concat(b.scheduleItems), [])
          .length &&
      JSON.stringify(prev) === JSON.stringify(current),
  ),
)
Enter fullscreen mode Exit fullscreen mode

虽然这个方案有效,但它并不能阻止在数据保持不变的情况下执行选择器。
如果我们想要限制选择器的执行次数,我们可以更进一步,修改 NgRx 选择器的自定义行为。

默认选择器 createSelector使用选择器工厂函数创建。
默认情况下,选择器出于性能考虑使用记忆化技术。在执行投影函数之前,记忆化函数依赖于某个isEqualCheck方法来判断输入是否发生变化。如果输入发生变化,则会调用选择器的投影函数。投影函数执行完毕后,还会将结果与之前的输入进行比较isEqualCheck,以避免发出新的值。

NgRx 代码库中的代码如下所示。

export function defaultMemoize(
  projectionFn: AnyFn,
  isArgumentsEqual = isEqualCheck,
  isResultEqual = isEqualCheck,
): MemoizedProjection {
  let lastArguments: null | IArguments = null
  let lastResult: any = null

  function reset() {
    lastArguments = null
    lastResult = null
  }

  function memoized(): any {
    if (!lastArguments) {
      lastResult = projectionFn.apply(null, arguments as any)
      lastArguments = arguments
      return lastResult
    }

    if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
      return lastResult
    }

    const newResult = projectionFn.apply(null, arguments as any)
    lastArguments = arguments

    if (isResultEqual(lastResult, newResult)) {
      return lastResult
    }

    lastResult = newResult

    return newResult
  }

  return { memoized, reset }
}

export function isEqualCheck(a: any, b: any): boolean {
  return a === b
}

function isArgumentsChanged(
  args: IArguments,
  lastArguments: IArguments,
  comparator: ComparatorFn,
) {
  for (let i = 0; i < args.length; i++) {
    if (!comparator(args[i], lastArguments[i])) {
      return true
    }
  }
  return false
}
Enter fullscreen mode Exit fullscreen mode

但和之前一样,使用 RxJS 方法还不够。
我们的数据虽然相同,但子选择器创建了新的引用,因此相等性检查会认为它接收到了新的输入。

为了防止在输入数据相同时执行选择器,我们可以使用该createSelectorFactory函数创建自己的选择器,并添加自定义的相等性检查。
该函数defaultMemoize有一个isArgumentsEqual用于比较输入的参数,在这里我们将提供自定义的比较器方法。与之前一样,比较器也会使用JSON.stringify检查来比较先前的输入和当前的输入。

export const selectCurrentWeekView = createSelectorFactory(projection =>
  defaultMemoize(projection, argumentsStringifyComparer()),
)((selectCaregivers, selectItemsA, selectItemsB, selectItemsC), (caregivers, a, b ,c) => ...)

function argumentsStringifyComparer() {
  let currentJson = ''
  return (incoming, current) => {
    if (incoming === current) {
      return true
    }

    const incomingJson = JSON.stringify(incoming)
    if (currentJson !== incomingJson) {
      currentJson = incomingJson
      return false
    }

    return true
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,当其中一个子选择器发出新值时,我们的argumentsStringifyComparer方法用于检查是否selectCurrentWeekView应该执行该选择器的投影函数。

当加载本周数据时,每次响应的数据都会不同,但选择器仍然会执行。
当加载下一周的数据时,状态会更新,但子选择器仍然返回与本周相同的数据。由于这一变化,选择器现在无法检测到这种变化,因此不会执行。

这样可以确保组件仅在数据内容发生变化时才接收新值。由于我们首先检查选择器的参数,因此也能防止执行选择器的投影函数。对于较重的选择器,这也能提升性能。

阻止选择器执行(NgRx)

当前方案会导致选择器在周视图数据每次发生变化时都会触发。该视图的数据是通过多次 API 调用部分加载的,这意味着每次调用都会执行一次选择器。如果所有调用都紧接着发生,这种做法就毫无意义了。

我们可以使用 RxJSauditTime操作符来减少选择器执行次数,从而减少变更检测周期。

auditTime:忽略持续时间为毫秒的源值,然后发出源 Observable 的最新值,然后重复此过程。

calendar = this.store.pipe(
  auditTime(500),
  select(selectCurrentWeekView),
  startWith({ werknemers: [] }),
)

// or

calendar = this.store.pipe(
  auditTime(0, animationFrameScheduler),
  select(selectCurrentWeekView),
  startWith({ werknemers: [] }),
)
Enter fullscreen mode Exit fullscreen mode

此项更改确保选择器在给定时间内只会调用一次,而不是在本周的每次状态更改时都调用一次。

别忘了使用 RxJSstartWith操作undefined符来设置初始状态。否则,组件初始化时,由于选择器尚未执行,组件会接收到一个空值。

startWith: 返回一个 Observable,它会在开始发出源 Observable 发出的项目之前,发出你指定的参数项。

将组件从变更检测中分离出来

我们在应用一些已讨论过的解决方案之前,曾尝试过这种方法。
之后,我们撤销了这一更改,因为它存在一些缺点。
尽管如此,在某些情况下,它仍然有用。

可以将组件及其子组件从 Angular 变更检测周期中分离出来。
为此,我们可以使用该ChangeDetectorRef.detach()方法

更改之后,你会发现组件的功能基本消失了。
要运行组件的变更检测,我们需要ChangeDetectorRef.detectChanges()在需要重新渲染组件时手动调用相关函数。

在本例中,我们分离了照护者组件,并且仅在照护者数据或其他属性发生更改时才运行变更检测。为了检查照护者数据是否发生更改,我们JSON.stringify再次使用了该方法。

import { ChangeDetectorRef } from '@angular/core'

export class CaregiverScheduleComponent implements OnChanges {
  @Input() otherProperty
  @Input() caregiver

  constructor(private cdr: ChangeDetectorRef) {
    cdr.detach()
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.otherProperty) {
      this.cdr.detectChanges()
      return
    }

    if (changes.caregiver) {
      if (changes.caregiver.isFirstChange()) {
        this.cdr.detectChanges()
        return
      }

      if (
        changes.caregiver.previousValue.scheduleItems.length !==
          changes.caregiver.currentValue.scheduleItems.length ||
        JSON.stringify(changes.caregiver.previousValue.scheduleItems) !==
          JSON.stringify(changes.caregiver.currentValue.scheduleItems)
      ) {
        this.cdr.detectChanges()
        return
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This doesn't seem too bad, but it doesn't stop here.
We also had to call detectChanges in the child components.
For example, we were using a material menu and the menu didn't open when we clicked on the trigger.
To open the menu, we had to call detectChanges on the click event.
This is just one example, but we had to do this at multiple places.

This isn't straightforward.
If you're not aware that a component detached itself, it leads to frustration and minutes of debugging.

Conclusion

The biggest improvement we can make is to reduce the number of change detection cycles.
This will lower the number of function calls, and the number of re-renders.

The first step towards this is to work with immutable data.
When you're working with data that is immutable Angular and NgRx can make use of the === equality check to know if it has to do something. When the usage of JavaScript functions creates a new reference of an array (for example filter and map), we can override the equality checks. This can be done with RxJS or by creating a custom NgRx selector creator.

Every piece of logic that does not have to be run is a big win for the performance of an application. Therefore, limit the amount of work that has to be done with techniques like virtual scrolling to restrict the number of active components.
Make use of the trackBy directive to let Angular know if something needs to be re-rendered.

Do not use methods in the HTML view, as these will be executed on every change detection cycle.
To resolve this, precalculate state wherever possible. When this is impossible, go for a pure pipe because it will be run frwer times in comparison to methods. When you're using a pipe it's (again) important to use immutable data, as the pipe will only execute when the input is changed.

Be aware of what triggers the change detection. If an input property of a component changes, or when it fires an event, it will trigger the Angular change detection.

Remember the quote "premature optimization is the root of all evil".
Most of these tips are only needed when the application doesn't feel snappy anymore.

Useful resources


Follow me on Twitter at @tim_deschryver | Originally published on timdeschryver.dev.

文章来源:https://dev.to/angular/help-angular-to-make-your-application-faster-dk6