
今天,我们将详细分析一个完全基于
OnPush策略编写的反应性角度应用程序(
github存储库 )。 另一个应用程序使用反应形式,这对于企业应用程序来说是非常典型的。
我们将不使用Flux,Redux,NgRx,而是利用Typescript,Angular和RxJS中已经提供的功能。 事实是,这些工具不是万灵药,甚至可以为简单的应用程序增加不必要的复杂性。
Flux的
作者之一,Redux的
作者和NgRx的
作者对此诚实地警告过我们。
但是这些工具为我们的应用程序提供了非常好的功能:
- 可预测的数据流;
- 通过设计支持OnPush;
- 数据的不变性,缺乏累积的副作用和其他令人愉悦的事情。
我们将尝试获得相同的特征,但不会引入额外的复杂性。
正如您在文章结尾看到的那样,这是一个相当简单的任务-如果您从文章中删除Angular和OnPush的细节,那么只有几个简单的想法。
本文没有提供一种新的通用模式,而只是与读者分享了一些想法,这些想法由于其简单性,由于某种原因并没有立即浮现在脑海。 而且,开发的解决方案不与Flux / Redux / NgRx冲突或替代。 如果
确实需要 ,可以将它们连接起来。
为了舒适地阅读本文,需要理解术语智能,表示性和容器组件 。行动计划
应用程序的逻辑以及材料的显示顺序可以按照以下步骤的形式进行描述:
- 单独的数据用于读取(GET)和写入(PUT / POST)
- 将状态作为流加载到容器组件中
- 将状态分配到OnPush组件的层次结构
- 通知Angular有关组件更改的信息
- 封装数据编辑
要实现OnPush,我们需要解析所有在Angular中运行更改检测的方法。 这样的方法只有四种,我们将在本文中逐步考虑它们。
所以走吧
共享数据以进行读写
通常,前端和后端应用程序使用类型化协定(否则为什么要使用类型脚本?)。
我们正在考虑的演示项目没有真正的后端,但是它包含一个预先准备的描述文件
swagger.json 。 基于此,
sw2dts实用程序将生成打字稿合同。
生成的合同具有两个重要属性。
首先,使用不同的合同进行读写。 我们使用一个小的约定,并指读带有后缀“ State”的合同,并写有后缀“ Model”的合同。
通过以这种方式分离合同,我们可以共享应用程序中的数据流。 从上到下,只读状态通过组件层次结构传播。 为了修改数据,创建了一个模型,该模型最初填充有来自状态的数据,但作为单独的对象存在。 在编辑结束时,将模型作为命令发送到后端。
第二个要点是,所有“状态”字段都标记有只读修饰符。 因此,我们在打字稿级别获得了免疫支持。 现在,我们将无法使用[(ngModel)]意外更改代码中的状态或将其绑定到-在AOT模式下编译应用程序时,会出现错误。
将状态作为流加载到容器组件中
为了加载和初始化状态,我们将使用普通的角度服务。 他们将负责以下情况:
- 一个经典的示例是使用组件从路由器获取的id参数通过HttpClient进行加载。
- 创建新实体时初始化一个空状态。 例如,如果字段具有默认值或要初始化,则需要从后端请求其他数据。
- 用户执行将数据更改为后端的操作后,重新引导已加载的状态。
- 通过推式通知重新启动状态,例如,在共同编辑数据时。 在这种情况下,服务会合并本地状态和从后端获得的状态。
在演示应用程序中,我们将把前两种情况视为最典型的情况。 同样,这些场景很简单,并且允许将服务实现为简单的无状态对象,并且不会因复杂性而分心,这不是本文的主题。
可以在文件
some-entity.service.ts中找到服务的示例。
在容器组件和负载状态下,仍然可以通过DI获得服务。 通常这样做是这样的:
route.params .pipe( pluck('id'), filter((id: any) => { return !!id; }), switchMap((id: string) => { return myFormService.get(id); }) ) .subscribe(state => { this.state = state; });
但是,使用这种方法会出现两个问题:
- 您必须手动取消订阅已创建的订阅,否则会发生内存泄漏。
- 如果将组件切换为OnPush策略,它将停止响应数据加载。
异步管道可以解救。 他直接收听可观察对象,并在必要时退订。 同样,当使用异步管道时,每次Observable发布新值时,Angular都会自动触发更改检测。
在
some-entity.component component的模板中可以找到使用异步管道的示例。
在组件代码中,我们将重复的逻辑删除到自定义RxJS运算符中,添加了用于创建空状态的脚本,并使用merge运算符将两个State源合并到一个流中,并创建了一个编辑表单,我们将在后面讨论:
this.state$ = merge( route.params.pipe( switchIfNotEmpty("id", (requestId: string) => requestService.get(requestId) ) ), route.params.pipe( switchIfEmpty("id", () => requestService.getEmptyState()) ) ).pipe( tap(state => { this.form = new SomeEntityFormGroup(state); }) );
这是在容器组件中需要完成的所有工作。 然后,我们在OnPush组件中将存钱罐放入第一种调用更改检测的方法-异步管道。 这将对我们不止一次有用。
将状态分配到OnPush组件的层次结构
当您需要显示复杂状态时,我们会创建一个由小组件组成的层次结构-这就是我们处理复杂性的方式。
通常,将组件划分为类似于数据层次结构的层次结构,并且每个组件都通过Input参数接收自己的数据,以将其显示在模板中。
由于我们将所有组件都实现为OnPush,因此让我们讨论一下,这是什么以及Angular如何与OnPush组件一起使用。 如果您已经知道此材料,请随时滚动至本节末尾。
在应用程序的编译过程中,Angular为每个组件生成一个特殊的类更改检测器,它“记住”组件模板中使用的所有绑定。 在运行时,生成的类开始在每个更改检测循环中检查存储的表达式。 如果检查显示任何表达式的结果已更改,则Angular重绘该组件。
默认情况下,Angular不了解我们的组件,也无法确定它将影响哪些组件,例如,刚刚触发的setTimeout或已结束的AJAX请求。 因此,他被迫逐字检查应用程序内部的每个事件的整个应用程序-即使是简单的窗口滚动反复触发针对应用程序组件的整个层次结构的更改检测。
这就是性能问题的潜在根源-组件模板越复杂,变更检测器检查就越困难。 而且,如果有很多组件并且经常执行检查,那么更改检测将开始花费大量时间。
怎么办
如果组件不依赖于任何全局效果(顺便说一句,最好以这种方式设计组件),则其内部状态取决于:
我们现在将第二点推迟,并假设组件的状态仅取决于Input参数。
如果组件的所有Input参数都是不可变的对象,那么我们可以将组件标记为OnPush。 然后,在运行更改检测之前,Angular将检查自上次检查以来组件的输入参数的链接是否已更改。 而且,如果尚未更改,则Angular将跳过组件本身及其所有子组件的更改检测。
因此,如果我们根据OnPush策略构建整个应用程序,那么我们将从一开始就消除整个性能问题。
由于我们应用程序中的State已经是不可变的,因此不可变的对象也将传输到子组件的Input参数。 也就是说,我们准备为子组件启用OnPush,它们将响应状态更改。
例如,这些是
readonly-info.component和
nested-items.component组件。现在,让我们看看如何在OnPush范例中实现组件状态的更改。
与Angular谈谈您的状况
表示状态-这些是负责组件外观的参数:加载指示器,元素可见性标志或一个或另一个操作对用户的可访问性标记,从三个字段粘贴到一行,用户全名等。
每次组件的显示状态更改时,我们都必须通知Angular,以便它可以在UI上显示更改。
根据组件状态的来源是什么,有几种通知Angular的方法。
演示状态,根据输入参数计算
这是最简单的选择。 我们将表示状态计算逻辑放在ngOnChanges挂钩中。 更改检测将通过更改@输入参数来自动启动。 在演示中,这是
readonly-info.component 。
export class ReadOnlyInfoComponent implements OnChanges { @Input() public state: Backend.SomeEntityState; public traits: ReadonlyInfoTraits; public ngOnChanges(changes: { state: SimpleChange }): void { this.traits = new ReadonlyInfoTraits(changes.state.currentValue); } }
一切都非常简单,但是有一点需要注意。
如果组件的表示状态很复杂,尤其是如果其某些字段是根据其他字段(也由Input参数计算)来计算的,则将组件的状态放在单独的类中,使其不可变并在每次启动时重新创建ngOnChanges。 在演示项目中,一个示例是
ReadonlyInfoComponentTraits类。 使用这种方法,可以保护自己免于在相关数据更改时进行同步。
同时,值得考虑:由于组件中的逻辑过多,因此组件可能处于困难状态。 一个典型的示例是尝试在一个组件中适应具有不同系统使用方式的不同用户的表示形式。
组件本机事件
对于应用程序组件之间的通信,我们使用Output事件。 这也是运行变更检测的第三种方法。 Angular合理地假设,如果组件生成事件,则其状态可能已经发生了变化。 因此,Angular侦听所有组件输出事件,并在事件发生时触发更改检测。
在演示项目中,它是完全合成的,但是一个示例是component
Submit -button.component ,它引发一个
formSaved事件。 容器组件订阅此事件并显示带有通知的警报。
将Output事件用于预期目的,即创建它们以与父组件进行通信,而不是为了触发更改检测。 否则,在几个月和几年之后,很可能不记得为什么此事件对于这里的任何人都是不必要的,并删除它,破坏了一切。
智能组件的变化
有时,组件的状态由复杂的逻辑决定:异步服务调用,与Web套接字的连接,通过setInterval检查运行,但您永远不知道其他什么。 这些组件称为智能组件。
通常,应用程序中不是容器组件的智能组件越少,它们的生存就越容易。 但是有时候你不能没有他们。
将智能组件的状态与更改检测相关联的最简单方法是将其变成可观察对象,并使用上面已经讨论过的
异步管道 。 例如,如果更改的源是服务呼叫或响应表单状态,则这是现成的Observable。 如果状态是由更复杂的事物构成的,则可以使用
fromRose组成的fromPromise ,
websocket ,
timer ,
interval 。 或使用
Subject自己生成流。
如果没有一个合适的选项
在已经研究的三种方法都不适合的情况下,我们仍然有防弹选项-直接使用
ChangeDetectorRef 。 我们正在谈论此类的detectChanges和markForCheck方法。
全面的文档解答了所有问题,因此我们将不再赘述。 但是请注意,
ChangeDetectorRef的使用应仅限于您清楚了解自己在做什么的情况,因为这仍然是内部Angular厨房。
一直以来,我们仅发现可能需要此方法的少数情况:
- 带有变更检测的手动工作-用于实现低级组件,就是“您清楚地了解自己在做什么”的情况。
- 组件之间的复杂关系-例如,当您需要在模板中创建指向组件的链接,并将其作为参数传递给位于层次结构甚至是组件层次结构中另一个分支的另一个组件时。 听起来复杂吗? 就是这样 而且最好是重构这样的代码,因为它不仅带来变更检测,还会带来痛苦。
- Angular本身的行为的详细信息-例如,在实现自定义ControlValueAccessor时,您可能会遇到Angular异步更改控制值并且该更改未应用于所需的更改检测周期的情况。
作为在演示应用程序中使用的示例,有一个基类
OnPushControlValueAccessor ,它解决了上一段中描述的问题。 在项目中也有一个此类的继承人-自定义
radio-button.component 。
现在,我们已经讨论了对所有三种类型的组件运行更改检测和OnPush实现选项的所有四种方式:容器,智能,表示性。 我们到最后一点-使用反应形式编辑数据。
封装数据编辑
反应形式有很多限制,但这仍然是Angular生态系统中发生的最好的事情之一。
首先,它们封装了与状态良好配合的状态,并提供了所有必要的工具以响应方式响应变化。
实际上,反应形式是一种小型存储,它封装了带有状态的工作:数据和状态为禁用/有效/待处理。
我们仍然需要尽可能地支持这种封装,并避免将表述逻辑和表单逻辑混为一谈。
在演示应用程序中,您可以看到封装了其工作细节的
各个表单类 :验证,创建子FormGroup,使用输入字段的禁用状态。
我们在加载状态时在容器组件中创建根表单,并在每个状态重新启动时重新创建该表单。 这不是先决条件,但是通过这种方式,我们可以确保从先前加载状态遗留下来的表单逻辑中没有累积的效果。
在表单本身内部,我们构造控件并“推送”来自控件的数据,将其从州合同转换为模型合同。 表单的结构尽可能匹配模型的契约。 结果,表单的value属性为我们提供了一个现成的模型,用于发送到后端。
如果将来状态或模型结构发生变化,那么我们将在需要添加/删除字段的确切位置出现打字稿编译错误,这非常方便。
同样,如果状态和模型对象具有绝对相同的结构,则在打字稿中使用的结构类型将消除建立彼此无意义的映射的需求。
总体而言,表单逻辑与组件中的表示逻辑是隔离的,并且“独立存在”,而不会增加整个应用程序数据流的复杂性。
差不多了。 当我们无法将表单逻辑与应用程序的其余部分隔离开时,还有一些边界情况:
- 形式上的更改导致表示状态的更改-例如,数据块的可见性取决于所输入的值。 我们通过订阅表单事件在组件中实现它。 您可以通过前面讨论的不可变特征来实现。
- 如果您需要一个调用后端的异步验证器,则可以在组件中构造AsyncValidatorFn并将其传递给表单构造函数,而不是服务。
因此,所有“边界线”逻辑都保留在组件中最突出的位置。
结论
让我们总结一下我们得到了什么以及还有哪些研究和开发要点。
首先,OnPush策略的发展迫使我们仔细设计应用程序的数据流,因为现在我们将游戏规则指定给Angular,而不是他。
这种情况有两个后果。
首先,我们对应用程序有一种令人愉悦的控制感。 不再存在“以某种方式起作用”的魔力。 您清楚地知道了应用程序中任何给定时间的情况。 直觉正在逐步发展,这使您甚至在打开代码之前也可以了解发现错误的原因。
其次,现在我们不得不花更多的时间设计应用程序,但是结果始终是最“直接”的,因此也是最简单的解决方案。 当应用程序增长时,这显然使这种情况变成了零,这种情况变成了极其复杂的怪物,开发人员已经失去了对这种复杂性的控制,现在的开发看起来更像是神秘的仪式。
受控的复杂性和“魔术性”的缺乏降低了例如由于周期性数据更新或累积的副作用而引起的整个问题类别的可能性。 相反,当应用程序根本无法正常工作时,我们将处理在开发过程中已经显而易见的问题。 而perforce,则必须使应用程序简单明了地工作。
我们还提到了对性能的良好影响。 现在,使用非常简单的工具,例如
profiler.timeChangeDetection ,我们可以随时检查我们的应用程序是否处于良好状态。
同样,现在不尝试
禁用NgZone也是一种罪过。 首先,它将允许您在应用程序启动时不加载整个库。 其次,它将从您的应用程序中删除大量的魔术。
这就是我们结束故事的地方。
我们会保持联系!