深入了解 NgRx 的优势和特性
已发表: 2022-03-11如果团队负责人指示开发人员编写大量样板代码,而不是编写一些方法来解决某个问题,那么他们需要有说服力的论据。 软件工程师是问题解决者; 他们更喜欢使事情自动化并避免不必要的样板。
尽管 NgRx 带有一些样板代码,但它也提供了强大的开发工具。 本文展示了花费更多时间编写代码将产生值得付出努力的好处。
当 Dan Abramov 发布 Redux 库时,大多数开发人员开始使用状态管理。 有些人开始使用状态管理是因为它是一种趋势,而不是因为他们缺乏它。 使用标准“Hello World”项目进行状态管理的开发人员很快就会发现自己一遍又一遍地编写相同的代码,增加了复杂性却没有任何收获。
最终,有些人变得沮丧并完全放弃了状态管理。
我最初的 NgRx 问题
我认为这个样板问题是 NgRx 的一个主要问题。 起初,我们无法看到它背后的大局。 NgRx 是一个库,而不是编程范式或思维方式。 但是,为了完全掌握这个库的功能和可用性,我们必须更多地扩展我们的知识并专注于函数式编程。 那时您可能会开始编写样板代码并为此感到高兴。 (我是认真的。)我曾经是一个 NgRx 怀疑论者。 现在我是 NgRx 的崇拜者。
不久前,我开始使用状态管理。 我经历了上面描述的样板体验,所以我决定停止使用这个库。 因为我喜欢 JavaScript,所以我尝试至少对当今使用的所有流行框架有基本的了解。 这是我在使用 React 时学到的。
React 有一个叫做 Hooks 的特性。 就像 Angular 中的组件一样,钩子是接受参数和返回值的简单函数。 一个钩子可以有一个状态,这被称为副作用。 因此,例如,Angular 中的一个简单按钮可以像这样翻译成 React:
@Component({ selector: 'simple-button', template: ` <button>Hello {{ name }}</button> `, }) export class SimpleButtonComponent { @Input() name!: string; } export default function SimpleButton(props: { name: string }) { return <button>{props.name} </button>; }如您所见,这是一个简单的转换:
- 简单按钮组件 => 简单按钮
- @Input() 名称 => 道具名称
- 模板 => 返回值
我们的 React 函数SimpleButton在函数式编程世界中具有一个重要特征:它是一个纯函数。 如果您正在阅读本文,我想您至少听说过这个词一次。 NgRx.io 在关键概念中两次引用了纯函数:
- 状态更改由称为
reducers的纯函数处理,这些函数采用当前状态和最新操作来计算新状态。 - 选择器是用于选择、派生和组合状态片段的纯函数。
在 React 中,鼓励开发人员尽可能多地将 Hooks 用作纯函数。 Angular 还鼓励开发人员使用 Smart-Dumb 组件范例来实现相同的模式。
那时我意识到我缺乏一些关键的函数式编程技能。 很快就掌握了 NgRx,因为在学习了函数式编程的关键概念之后,我有一个“啊哈! 时刻”:我提高了对 NgRx 的理解,并希望更多地使用它来更好地了解它提供的好处。
这篇文章分享了我的学习经历以及我获得的关于 NgRx 和函数式编程的知识。 我不会解释 NgRx 的 API 或如何调用操作或使用选择器。 相反,我分享了为什么我开始欣赏 NgRx 是一个很棒的库:它不仅仅是一个相对较新的趋势,它提供了许多好处。
让我们从函数式编程开始。
函数式编程
函数式编程是一种与其他范式有很大不同的范式。 这是一个非常复杂的主题,有许多定义和指南。 然而,函数式编程包含一些核心概念,了解它们是掌握 NgRx(以及一般的 JavaScript)的先决条件。
这些核心概念是:
- 纯函数
- 不可变状态
- 副作用
我再说一遍:这只是一个范例,仅此而已。 没有库 functional.js 可供我们下载并用于编写功能软件。 这只是一种思考编写应用程序的方式。 让我们从最重要的核心概念开始:纯函数。
纯函数
如果一个函数遵循两个简单的规则,它就被认为是一个纯函数:
- 传递相同的参数总是返回相同的值
- 缺乏可观察到的涉及函数执行内部的副作用(外部状态更改、调用 I/O 操作等)
所以纯函数只是一个透明函数,它接受一些参数(或根本没有参数)并返回一个期望值。 您可以放心,调用此函数不会产生副作用,例如联网或更改某些全局用户状态。
我们来看三个简单的例子:
//Pure function function add(a,b){ return a + b; } //Impure function breaking rule 1 function random(){ return Math.random(); } //Impure function breaking rule 2 function sayHello(name){ console.log("Hello " + name); }- 第一个函数是纯函数,因为它在传递相同的参数时总是返回相同的答案。
- 第二个函数不是纯函数,因为它是不确定的,并且每次调用时都会返回不同的答案。
- 第三个函数不是纯函数,因为它使用了副作用(调用
console.log)。
很容易辨别函数是否纯。 为什么纯函数比不纯函数好? 因为想起来比较简单。 想象一下,您正在阅读一些源代码并看到一个您知道是纯的函数调用。 如果函数名是对的,则无需探究; 你知道它不会改变任何东西,它会返回你所期望的。 当您拥有一个包含大量业务逻辑的大型企业应用程序时,这对于调试至关重要,因为它可以节省大量时间。
此外,它很容易测试。 您不必在其中注入任何东西或模拟某些函数,您只需传递参数并测试结果是否匹配。 测试和逻辑之间有很强的联系:如果一个组件很容易测试,那么就很容易理解它是如何工作的以及为什么工作。
纯函数带有一个非常方便且性能友好的功能,称为记忆。 如果我们知道调用相同的参数将返回相同的值,那么我们可以简单地缓存结果,而不是浪费时间再次调用它。 NgRx 绝对位于 memoization 之上。 这是它快速的主要原因之一。
你可能会问自己,“副作用呢? 他们去哪里?” 在他的 GOTO 演讲中,Russ Olsen 开玩笑说,我们的客户不会为纯粹的功能付钱给我们,而是为副作用付钱给我们。 确实如此:没有人关心 Calculator 纯函数,如果它没有被打印在某个地方。 副作用在函数式编程领域中占有一席之地。 我们很快就会看到。
现在,让我们进入维护复杂应用程序架构的下一步,下一个核心概念:不可变状态。
不可变状态
不可变状态有一个简单的定义:
- 您只能创建或删除一个状态。 你不能更新它。
简单来说,更新用户对象的年龄……:
let user = { username:"admin", age:28 }......你应该这样写:
// Not like this newUser.age = 30; // But like this let newUser = {...user, age:29 }每个更改都是一个新对象,它复制了旧对象的属性。 因此,我们已经处于一种不可变状态的形式中。
String、Boolean 和 Number 都是不可变状态:您不能附加或修改现有值。 相反,Date 是一个可变对象:您总是操纵同一个日期对象。
不变性适用于整个应用程序:如果您在更改其年龄的函数内部传递一个用户对象,它不应该更改用户对象,它应该创建一个具有更新年龄的新用户对象并返回它:
function updateAge(user, age) { return {...user, age: age) } let user = {username: 'admin', age: 29}; let newUser = updateAge(user, 32);我们为什么要花时间和精力在这上面? 有几个好处值得强调。
后端编程语言的一个好处是并行处理。 如果状态更改不依赖于引用并且每次更新都是一个新对象,那么您可以将进程拆分为多个块并使用无数线程处理相同的任务,而无需共享相同的内存。 您甚至可以跨服务器并行化任务。
对于 Angular 和 React 等框架,并行处理是提高应用程序性能的更有益的方法之一。 例如,Angular 必须检查您通过 Input 绑定传递的每个对象的属性,以确定是否必须重新渲染组件。 但是如果我们设置ChangeDetectionStrategy.OnPush而不是默认值,它将通过引用而不是每个属性进行检查。 在大型应用程序中,这绝对可以节省时间。 如果我们不可变地更新我们的状态,我们将免费获得这种性能提升。
所有编程语言和框架共享的不可变状态的另一个好处类似于纯函数的好处:更容易思考和测试。 当更改是从旧状态产生的新状态时,您可以确切地知道自己在做什么,并且可以准确跟踪状态更改的方式和位置。 您不会丢失更新历史记录,并且可以撤消/重做状态更改(React DevTools 就是一个示例)。
但是,如果单个状态得到更新,您将不知道这些更改的历史记录。 想想一个不可变的状态,比如银行账户的交易历史。 这实际上是必须的。
现在我们已经回顾了不变性和纯粹性,让我们解决剩下的核心概念:副作用。
副作用
我们可以概括副作用的定义:
- 在计算机科学中,如果操作、函数或表达式在其本地环境之外修改某些状态变量值,则称其具有副作用。 也就是说它除了返回一个值(主效果)给操作的调用者外,还有一个可观察的效果。
简单地说,在函数范围之外改变状态的所有事情——所有的 I/O 操作和一些不直接连接到函数的工作——都可以被认为是副作用。 但是,我们必须避免在纯函数中使用副作用,因为副作用与函数式编程理念相矛盾。 如果你在纯函数内部使用 I/O 操作,那么它就不再是纯函数了。
然而,我们需要在某处产生副作用,因为没有它们的应用程序将毫无意义。 在 Angular 中,不仅需要保护纯函数免受副作用影响,我们还必须避免在组件和指令中使用它们。
让我们看看如何在 Angular 框架中实现这种技术的美妙之处。
函数式角度编程
关于 Angular,首先要了解的一件事是需要尽可能频繁地将组件解耦为更小的组件,以便更轻松地进行维护和测试。 这是必要的,因为我们需要划分我们的业务逻辑。 此外,鼓励 Angular 开发人员将组件仅用于渲染目的,并将所有业务逻辑移动到服务中。
为了扩展这些概念,Angular 用户在他们的词汇表中添加了“Dumb-Smart Component”模式。 这种模式要求服务调用不存在于小组件中。 因为业务逻辑驻留在服务中,我们仍然必须调用这些服务方法,等待它们的响应,然后才能进行任何状态更改。 因此,组件内部有一些行为逻辑。
为了避免这种情况,我们可以创建一个智能组件(根组件),其中包含业务和行为逻辑,通过输入属性传递状态,并调用监听输出参数的动作。 这样,小组件实际上仅用于渲染目的。 当然,我们的根组件内部必须有一些服务调用,我们不能只删除它们,但它的实用性仅限于业务逻辑,而不是渲染。
让我们看一个计数器组件示例。 计数器是一个组件,它有两个用于增加或减少值的按钮,以及一个用于显示currentValue的displayField 。 所以我们最终得到了四个组件:
- 计数器容器
- 增加按钮
- 减少按钮
- 当前值
所有的逻辑都存在于CounterContainer中,所以这三个都只是渲染器。 这是他们三个的代码:
@Component({ selector: 'decrease-button', template: `<button (click)="increase.emit()" [disabled]="disabled"> Decrease </button>`, }) export class DecreaseButtonComponent { @Input() disabled!: boolean; @Output() increase = new EventEmitter(); } @Component({ selector: 'current-value', template: `<button> {{ currentValue }} </button>`, }) export class CurrentValueComponent { @Input() currentValue!: string; } @Component({ selector: 'increase-button', template: `<button (click)="increase.emit()" [disabled]="disabled"> Increase </button>`, }) export class IncreaseButtonComponent { @Input() disabled!: boolean; @Output() increase = new EventEmitter(); }看看它们是多么的简单和纯粹。 它们没有状态或副作用,它们只依赖于输入属性和发射事件。 想象一下测试它们是多么容易。 我们可以称它们为纯组件,因为它们是真实的。 它们只依赖于输入参数,没有副作用,并且总是通过传递相同的参数返回相同的值(模板字符串)。
因此,函数式编程中的纯函数被转移到 Angular 中的纯组件中。 但是所有的逻辑都去哪里了? 逻辑仍然存在,但在稍微不同的地方,即CounterComponent 。
@Component({ selector: 'counter-container', template: ` <decrease-button [disabled]="decreaseIsDisabled" (decrease)="decrease()"> </decrease-button> <current-value [currentValue]="currentValue"> </current-value> <increase-button (increase)="increase()" [disabled]="increaseIsDisabled"> </increase-button> `, }) export class CounterContainerComponent implements OnInit { @Input() disabled!: boolean; currentValue = 0; get decreaseIsDisabled() { return this.currentValue === 0; } get increaseIsDisabled() { return this.currentValue === 100; } constructor() {} ngOnInit(): void {} decrease() { this.currentValue -= 1; } increase() { this.currentValue += 1; } } 如您所见,行为逻辑存在于CounterContainer中,但缺少渲染部分(它在模板中声明组件),因为渲染部分是针对纯组件的。

我们可以根据需要注入尽可能多的服务,因为我们在这里处理所有数据操作和状态更改。 值得一提的是,如果我们有一个深度嵌套的组件,我们不能只创建一个根级组件。 我们可以将它分成更小的智能组件并使用相同的模式。 最终,它取决于每个组件的复杂性和嵌套级别。
我们可以轻松地从该模式跳转到 NgRx 库本身,它只是它上面的一层。
NgRx 库
我们可以将任何 Web 应用程序分为三个核心部分:
- 商业逻辑
- 应用状态
- 渲染逻辑
业务逻辑是应用程序发生的所有行为,例如网络、输入、输出、API 等。
应用程序状态是应用程序的状态。 它可以是全局的,作为当前授权的用户,也可以是本地的,作为当前的计数器组件值。
渲染逻辑包括渲染,例如使用 DOM 显示数据、创建或删除元素等。
通过使用 Dumb-Smart 模式,我们将渲染逻辑与业务逻辑和应用程序状态分离,但我们也可以将它们分开,因为它们在概念上彼此不同。 应用程序状态就像您的应用程序在当前时间的快照。 业务逻辑就像是始终存在于您的应用程序中的静态功能。 划分它们的最重要原因是业务逻辑主要是我们希望在应用程序代码中尽可能避免的副作用。 这就是 NgRx 库及其功能范式大放异彩的时候。
使用 NgRx,您可以解耦所有这些部分。 主要分为三个部分:
- 减速机
- 行动
- 选择器
结合函数式编程,这三者结合起来为我们提供了一个强大的工具来处理任何规模的应用程序。 让我们检查它们中的每一个。
减速机
reducer 是一个纯函数,它有一个简单的签名。 它将旧状态作为参数并返回一个新状态,该新状态要么派生自旧状态,要么来自新状态。 状态本身是一个对象,它与应用程序的生命周期一起存在。 它就像一个 HTML 标签,一个单一的根对象。
您不能直接修改状态对象,您需要使用减速器对其进行修改。 这有很多好处:
- 更改状态逻辑位于一个地方,您知道状态更改的位置和方式。
- reducer 函数是纯函数,易于测试和管理。
- 因为 reducer 是纯函数,它们可以被记忆,从而可以缓存它们并避免额外的计算。
- 状态变化是不可变的。 你永远不会更新同一个实例。 相反,您总是返回一个新的。 这可以实现“时间旅行”调试体验。
这是一个 reducer 的简单示例:
function usernameReducer(oldState, username) { return {...oldState, username} }尽管它是一个非常简单的虚拟减速器,但它是所有长而复杂的减速器的骨架。 他们都有相同的好处。 我们的应用程序中可以有数百个 reducer,我们可以根据需要制作任意数量的 reducer。
对于我们的 Counter 组件,我们的 state 和 reducer 可能如下所示:
interface State{ decreaseDisabled:boolean; increaseDisabled:boolean; currentValue:number; } const MIN_VALUE=0; const MAX_VALUE =100; function decreaseReducer(oldState) { const newValue = oldState.currentValue -1 return {...oldState,currentValue : newValue, decreaseDisabled: newValue===MIN_VALUE } function increaseReducer(oldState) { const newValue = oldState.currentValue + 1 return {...oldState,currentValue : newValue, decreaseDisabled: newValue===MAX_VALUE }我们从组件中删除了状态。 现在我们需要一种方法来更新我们的状态并调用适当的 reducer。 这就是行动发挥作用的时候。
行动
action 是一种通知 NgRx 调用 reducer 并更新状态的方法。 否则,使用 NgRx 将毫无意义。 动作是我们附加到当前减速器的简单对象。 调用它后,将调用适当的 reducer,因此在我们的示例中,我们可以执行以下操作:
enum CounterActions { IncreaseValue = '[Counter Component] Increase Value', DecreaseValue = '[Counter Component] Decrease Value', } on(CounterActions.IncreaseValue,increaseReducer); on(CounterActions.DecreaseValue,decreaseReducer);我们的操作与减速器有关。 现在我们可以进一步修改我们的容器组件并在必要时调用适当的操作:
@Component({ selector: 'counter-container', template: ` <decrease-button [disabled]="decreaseIsDisabled" (decrease)="decrease()"> </decrease-button> <current-value [currentValue]="currentValue"> </current-value> <increase-button (increase)="increase()" [disabled]="increaseIsDisabled"> </increase-button> `, }) export class CounterContainerComponent implements OnInit { constructor(private store: Store<any>) {} decrease() { this.store.dispatch(CounterActions.DicreaseValue); } increase() { this.store.dispatch(CounterActions.IncreaseValue); } }注意:我们删除了状态,我们将很快添加回来。
现在我们的CounterContainer没有任何状态改变逻辑。 它只知道要发送什么。 现在我们需要某种方式将这些数据显示到视图中。 这就是选择器的用途。
选择器
选择器也是一个非常简单的纯函数,但与 reducer 不同的是,它不会更新状态。 顾名思义,选择器只是选择它。 在我们的示例中,我们可以有三个简单的选择器:
function selectCurrentValue(state) { return state.currentValue; } function selectDicreaseIsDisabled(state) { return state.decreaseDisabled; } function selectIncreaseIsDisabled(state) { return state.increaseDisabled; } 使用这些选择器,我们可以选择智能CounterContainer组件中的每个状态切片。
@Component({ selector: 'counter-container', template: ` <decrease-button [disabled]="ecreaseIsDisabled$ | async" (decrease)="decrease()" > </decrease-button> <current-value [currentValue]="currentValue$ | async"> </current-value> <increase-button (increase)="increase()" [disabled]="increaseIsDisabled$ | async" > </increase-button> `, }) export class CounterContainerComponent implements OnInit { decreaseIsDisabled$ = this.store.select(selectDicreaseIsDisabled); increaseIsDisabled$ = this.store.select(selectIncreaseIsDisabled); currentValue$ = this.store.select(selectCurrentValue); constructor(private store: Store<any>) {} decrease() { this.store.dispatch(CounterActions.DicreaseValue); } increase() { this.store.dispatch(CounterActions.IncreaseValue); } }默认情况下,这些选择是异步的(就像一般的 Observables 一样)。 至少从模式的角度来看,这并不重要。 对于同步的情况也是如此,因为我们只是从我们的状态中选择一些东西。
让我们退后一步,看看大局,看看我们迄今为止所取得的成就。 我们有一个计数器应用程序,它具有三个几乎相互分离的主要部分。 没有人知道应用程序状态如何管理自身或渲染层如何渲染状态。
解耦的部分使用桥(Actions、Selectors)相互连接。 它们解耦到这样的程度,我们可以将整个状态应用程序代码移动到另一个项目,例如移动版本。 我们唯一需要实现的就是渲染。 但是测试呢?
在我看来,测试是 NgRx 最好的部分。 测试这个示例项目类似于玩井字游戏。 只有纯函数和纯组件,所以测试它们是轻而易举的事。 现在想象一下,如果这个项目变得更大,有数百个组件。 如果我们遵循相同的模式,我们只会将越来越多的部分添加在一起。 它不会变成一团乱七八糟、难以阅读的源代码。
我们快完成了。 只剩下一件重要的事情需要介绍:副作用。 到目前为止,我多次提到副作用,但我没有解释将它们存储在哪里。
这是因为副作用是锦上添花,通过构建这种模式,很容易将它们从应用程序代码中删除。
副作用
假设我们的计数器应用程序中有一个计时器,每三秒它会自动将值增加一。 这是一个简单的副作用,它必须存在于某个地方。 根据定义,它与 Ajax 请求具有相同的副作用。
如果我们考虑副作用,大多数有两个主要原因存在:
- 在国家环境之外做任何事
- 更新应用程序状态
例如,在 LocalStorage 中存储一些状态是第一个选项,而从 Ajax 响应更新状态是第二个选项。 但它们都有相同的特征:每个副作用都必须有一些起点。 它需要至少调用一次以提示它开始操作。
正如我们之前所概述的,NgRx 有一个很好的工具来给某人一个命令。 那是一个动作。 我们可以通过调度一个动作来调用任何副作用。 伪代码可能如下所示:
function startTimer(){ setInterval(()=>{ console.log("Hello application"); },3000) } on(CounterActions.StartTime,startTimer) ... // We start timer by dispatching an action dispatch(CounterActions.StartTime);这很微不足道。 正如我之前提到的,副作用要么更新,要么不更新。 如果副作用没有更新任何内容,则无事可做; 我们就离开它。 但是如果我们想更新一个状态,我们该怎么做呢? 与组件尝试更新状态的方式相同:调用另一个操作。 所以我们在副作用内部调用一个动作,它会更新状态:
function startTimer(store) { setInterval(()=> { // We are dispatching another action dispatch(CounterActions.IncreaseValue) }, 3000) } on(CounterActions.StartTime, startTimer); ... // We start timer by dispatching an action dispatch(CounterActions.StartTime);我们现在有一个功能齐全的应用程序。
总结我们的 NgRx 经验
在结束 NgRx 之旅之前,我想提一些重要的话题:
- 显示的代码是我为这篇文章发明的简单伪代码; 它仅适用于演示目的。 NgRx 是真实资源所在的地方。
- 没有官方指南可以证明我关于将函数式编程与 NgRx 库联系起来的理论。 这只是我在阅读了由高技能人员创建的数十篇文章和源代码示例后形成的观点。
- 使用 NgRx 之后,您肯定会意识到它比这个简单的示例要复杂得多。 我的目标不是让它看起来比实际上更简单,而是向您展示即使它有点复杂,甚至可能导致到达目的地的路径更长,但值得付出额外的努力。
- NgRx 最糟糕的用法是在任何地方都使用它,而不管应用程序的大小或复杂性如何。 在某些情况下,您不应该使用 NgRx; 例如,在表格中。 在 NgRx 中实现表单几乎是不可能的。 表单粘在 DOM 本身上; 他们不能分开生活。 如果您尝试将它们解耦,您会发现自己不仅讨厌 NgRx,而且总体上讨厌 Web 技术。
- 有时使用相同的样板代码,即使是一个小例子,也会变成一场噩梦,即使它可以在未来使我们受益。 如果是这种情况,只需与另一个令人惊叹的库集成,它是 NgRx 生态系统 (ComponentStore) 的一部分。
