如何使用 Angular 信号和 OnPush 更改检测策略通过本地更改检测提高性能
很长一段时间以来,Angular 社区一直在见证许多改进和新功能被添加到他们心爱的框架中。几周前,Angular v17 发布,引入了大量高质量的改进和功能,信号 API(除了 effect())已经退出了开发者预览版。由于性能一直是社区中关于变化检测的讨论话题,稳定的信号 API 似乎使框架在变化检测🤯方面更强大、更智能。
您可以在官方博客文章中(https://blog.angular.io/introducing-angular-v17-4d7033312e4b)阅读有关 Angular v17 的更多信息。
前段时间,Angular团队开始研究如何在框架中引入 locality 意识(在检测更改时),以确定状态更改对应用程序的影响,从而不再依赖于Zone.js。
为此,在 Angular v17 中,符合当前基于 Zonejs 的自上而下的变化检测方法,对信号进行了微调,为局部感知提供了一个起点,从而引入了某种 Angular 社区称之为 本地变化检测 🚀 。
这是一项通过信号添加到框架中的强大功能,并有望提供性能提升,但这是社区一直在蓬勃发展的原因吗?这是否会减少更改检测周期中所需的检查量?它是否与变化检测策略有关?
在这篇文章中,我们将详细阐述和看看这种 locality 意识是如何实现的,有效和正确使用它的条件,并展示一些工作的例子。让我们开始🐱 🏍吧。
Change Detection Modes更改检测模式
在 Angular v17 之前,每当发生 Zone.js 正在修补的事件时,这可能会导致状态更改,Zone.js 会拾取此事件并通知 Angular 某些状态已更改(不是具体位置),并且它应该运行更改检测。由于 Angular 不知道更改来自哪里或更改发生在哪里,因此它开始遍历组件树并对所有组件进行脏检查。这种检测更改的方法称为全局模式。
对于信号🚦,这种方法将进行微调,因为不再需要对所有组件进行脏检查。信号可以跟踪它们被消耗的位置。对于绑定在组件模板上的信号,模板是一个消费者,每次信号值发生变化时,都需要拉出该新值(因此是实时消费者)。因此,当信号值发生变化时,将其消费者标记为脏就足够了,但对于祖先组件来说,不需要相同的内容(就像 v17 之前一样)。为此,最新版本中的一项新改进
(https://github.com/angular/angular/pull/52302) shipped in the latest version makes signals smart enough to mark dirty
使信号足够智能,以标记 dirtyonly 使用它的特定组件(实时消费者),并标- 记祖先组件以进行遍历(通过添加 HasChildViewsToRefresh 标志)。
(https://github.com/angular/angular/blob/d9348be79f61eca32dbb3643507135d5238a2bbd/packages/core/src/render3/util/view_utils.ts#L223)(by adding the HasChildViewsToRefresh flag).
为了实现这一点, markAncestorsForTraversal 引入了一个新功能替换 markViewDirty (用于标记脏的所有祖先组件)。让我们看一下底层代码:
export function consumerMarkDirty(node: ReactiveNode): void {
node.dirty = true;
producerNotifyConsumers(node);
node.consumerMarkedDirty?.(node);
}
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
export function markAncestorsForTraversal(lView: LView) {
let parent = lView[PARENT];
while (parent !== null) {
// We stop adding markers to the ancestors once we reach one that already has the marker. This
// is to avoid needlessly traversing all the way to the root when the marker already exists.
if ((isLContainer(parent) && (parent[FLAGS] & LContainerFlags.HasChildViewsToRefresh) ||
(isLView(parent) && parent[FLAGS] & LViewFlags.HasChildViewsToRefresh))) {
break;
}
if (isLContainer(parent)) {
parent[FLAGS] |= LContainerFlags.HasChildViewsToRefresh;
} else {
parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
if (!viewAttachedToChangeDetector(parent)) {
break;
}
}
parent = parent[PARENT];
}
}
在更改检测期间(由 Zone.js 触发),标记为遍历的组件,在访问时,让 Angular 了解它们不需要检查更改,但它们有脏子项。这种方式可确保在更改检测过程中可以访问它们,即使它们不脏,但在前往刷新的脏子项的途中被跳过。因此,信号在组件树中定位更改发生位置的新功能提供了我们之前讨论的“本地”更改检测。由信号强制执行的改进方法称为检测变化的目标模式。在这种模式下,Angular 仍然会启动一个自上而下的检查过程(由 Zone.js 触发召回),但现在它会遍历标记为遍历的组件,并且目标只是刷新脏的消费者。
这种方法似乎确实为整个应用程序带来了性能提升,但我们是开箱即用还是选择加入( opt-in )功能?
Change Detection Strategies 变更检测策略
一般来说,解决性能问题的最佳方法是减少工作量,这意味着运行更少的代码,而在 Angular 中,这意味着减少更改检测周期和在一个周期内检查更改的组件数量。为了实现这一点,Angular 需要一种方法来知道哪些组件必须检查或不检查更改。
由于 Angular 无法确切知道哪个组件发生了变化,并且变化检测是全局的,因此自上而下的过程假设树中的所有组件都是脏的,并且需要在每个周期检查是否有变化。这意味着将检查组件(无论脏与否)是否有更改。组件在更改检测过程中的这种行为和一致性称为更改检测策略。由于这是默认组件行为,因此称为默认更改检测策略。
为了改善这种行为,并使 Angular 减少工作量,Angular 团队引入了一种新策略,可以减少要检查更改的组件数量。这种新策略称为 OnPush 更改检测策略,它允许跳过不脏组件的子树。
当 OnPush 组件被标记为脏时,有 3 个条件,您可以在https://blog.simplified.courses/angular-change-detection-onpush-or-not/中找到有关它们的更多信息。
现在,根据这些信息,我们可以暗示 OnPush 更改检测策略似乎让我们从 v17 的本地更改检测中受益。让我们看看它是否站得住脚💪.
Hybrid Change Detection混合变化检测
出于演示目的,以下是支持我们案例的应用程序的小型复制品:
@Component({
...
selector: 'app-child-y',
templateUrl: `
<div class="container">
<h3>Child Y<br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-y />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildYComponent {...}
@Component({
...
selector: 'app-child-x',
templateUrl: `
<div class="container">
<h3>Child X <br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-x />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildXComponent {...}
@Component({
...
selector: 'app-parent',
templateUrl: `
<div class="container">
<h2>Parent <br /> value: {{count()}} runs: {{getChecked()}}</h2>
<div class="children">
<app-child-x />
<app-child-y />
</div>
</div>
`,
...
})
export class ParentComponent {...}
该应用可视化一个组件树,其中 Parent 组件具有默认更改检测,其两个子组件 ChildX 和 ChildYcomponents(每个组件都具有 OnPush 更改检测)和一个子组件(分别是 GrandChildX 组件和 GrandChildY 组件),如下所示:
@Component({
...
selector: 'app-grandchild-x',
templateUrl: `
<div class="container">
<h4>(GrandChild X <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button (click)="updateValue()">Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildXComponent {
...
updateValue() {
this.count.update((v) => v + 1);
}
}
@Component({
...
selector: 'app-grandchild-y',
templateUrl: `
<div class="container" appColor>
<h4>(GrandChild Y <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button #incCount>Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildYComponent implements AfterViewInit {
...
@ViewChild('incCount') incButton!: ElementRef<HTMLButtonElement>;
ngZone = inject(NgZone);
injector = inject(Injector);
app = inject(ApplicationRef);
ngAfterViewInit(): void {
runInInjectionContext(this.injector, () => {
this.ngZone.runOutsideAngular(() => {
fromEvent(this.incButton.nativeElement, 'click')
.pipe(throttleTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.count.update((v) => v + 1);
this.app.tick();
});
});
});
}
}
孙组件(grandchild components )之间的唯一区别是单击事件处理程序的处理方式。在下一节中,我们将了解这一决定背后的原因。
现在,在组件树的两边,所有组件都使用 OnPush 策略,让我们检查一下在 GrandChildX 组件上增加计数时会发生什么:
Signals + OnPush + Default Event Handling 信号 + OnPush + 默认事件处理
我们在这里看到的没有什么区别,就好像它是正在使用的全局战略一样。即使组件使用 OnPush 策略,它们仍会被检查是否有更改。为什么会这样?
好吧,Angular 在内部将事件侦听器包装在一个函数中,该函数标记组件及其祖先,以便在事件发生时进行检查。此外,Zone.js monkey 会修补事件,并在触发时通知 Angular,以便 Angular 可以开始更改检测。因此,在我们的例子中,当我们单击该按钮时,它会将当前组件和祖先组件标记为脏组件。由于更改检测在全局模式下启动,因此整个树都会刷新。这就是更改检测在 v17 之前的工作方式,并且出于向后兼容性目的而保持不变。
基于此,我们可以得出结论,要获得本地 CD 的好处,我们必须:以一种不标记的方式更新信号,以检查所有祖先组件。
我们处理了 GrandChildY 组件上的增量按钮,以遵守此要求,那么让我们看看我们现在👇得到了什么:
Signals + OnPush + Non-ancestor dirty way of update 信号 + OnPush + 非祖先脏更新方式
现在,我们在应用程序中获得了本地更改检测。更清楚的是,当单击按钮时,信号值会更新,然后将当前组件(消费者)标记为脏组件,并将祖先组件标记为遍历。之后,Zone.js 被触发(记住猴子补丁),通知 Angular,然后 Angular 在全局模式下启动更改检测并刷新父组件,因为它使用默认更改检测策略,在访问标记为遍历的组件时切换到目标模式(带有 HasChildViewsToRefresh 标志),并使用 OnPush 更改检测策略,在本例中为 ChildY 组件, 遍历它们但不刷新,最后到达脏的 GrandChildY 组件(使用者),切换回更改检测的全局模式,并刷新视图。Angular 在变化检测模式(全局和目标)之间进行的这种来回切换称为 混合变化检测 🐱 🏍 。
如果你想看看这是如何在内部管理的,请查看 Angular repo 中的源代码: https://github.com/angular/angular/blob/18929040704828bf4caf76797ab141ca101c4b5f/packages/core/src/render3/instructions/change_detection.ts#L376。
现在,在提到所有这些事情之后,有人可以说,似乎没有太多地方可以让我们从局部变化检测中获得好处。事实上,我们不会到处都得到好处,而且上面的点击处理也不是我们经常做的事情。但是,在其他用例中,我们可以获得此好处.
Shared Service or Parent Component State 共享服务或父组件状态
在这里,您可以看到一个更常见的情况,即您在服务中有一些共享状态,一个典型的情况是状态管理库(NgRx、Akita 等),并且该状态在整个组件树中的许多组件中使用。此外,在父组件上具有可以从子组件使用的某些状态的情况。当此状态(记住信号值⚠)更改时,只有使用该状态的组件(使用者)才会被标记为脏,并且启用 OnPush CD 策略后,您将获得本地更改检测的好处。
这是一个值得一提的典型用例,如果您还有其他我们可以从本地更改检测中受益的用例,请随时与我和社区分享。
Conclusion结论
改进变更检测一直是 Angular 团队和社区的首要任务之一。我们现在在 v17 中拥有的这种位置并不是完整的位置,但它代表了一个良好的开端,也是朝着预期在未来版本中交付的内容迈出的第一步。即使不是开箱即用,开发人员也可以通过一些调整来利用其应用程序中的本地更改检测优势:OnPush 更改检测策略和信号。要了解更多关于 Angular 团队在响应性方面的计划,请查看已发布的路线图🔥。