深入了解NgRx的优势和特点

536 阅读22分钟

深入了解NgRx的优势和特点

如果一个团队领导指示一个开发人员写大量的模板代码,而不是写一些方法来解决某个问题,他们需要令人信服的论据。软件工程师是问题解决者;他们更喜欢将事情自动化,避免不必要的模板。

即使NgRx带有一些模板代码,它也为开发提供了强大的工具。这篇文章表明,多花一点时间写代码会产生一些好处,使其值得付出。

大多数开发者是在Dan Abramov发布Redux库的时候开始使用状态管理的。有些人开始使用状态管理是因为它是一种趋势,而不是因为他们缺乏它。使用标准的 "Hello World "项目进行状态管理的开发者很快就会发现自己在重复写同样的代码,增加了复杂性却没有任何收获。

最终,一些人感到沮丧,完全放弃了状态管理。

我对NgRx的最初看法

我认为这个模板问题是NgRx的一个主要问题。起初,我们并没有看到它背后的大画面。NgRx是一个库,而不是一种编程范式或思维方式。然而,为了完全掌握这个库的功能和可用性,我们必须把我们的知识扩大一些,把注意力放在函数式编程上。这时,你可能就会写出模板代码,并为此感到高兴。(我是说真的。)我曾经是NgRx的怀疑者,现在我是NgRx的崇拜者。

不久前,我开始使用状态管理。我经历了上面描述的模板经验,所以我决定停止使用这个库。因为我喜欢JavaScript,所以我试图对当今所有流行的框架至少要有一个基本的了解。以下是我在使用React时学到的东西。

React有一个叫Hooks的功能。和Angular中的组件一样,Hooks是接受参数和返回值的简单函数。一个钩子可以有一个状态,这被称为一个副作用。因此,举例来说,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>;
}

正如你所看到的,这是一个直接的转换

  • SimpleButtonComponent => SimpleButton
  • @Input() name => props.name
  • 模板 => 返回值

Angular Component和React Hooks非常相似。

我们的React函数SimpleButton ,在函数式编程领域有一个重要的特点。它是一个纯函数**。**如果你正在读这篇文章,我推测你至少听过一次这个术语。NgRx.io在关键概念中两次引用了纯函数。

  • 状态变化由纯函数处理,称为reducers ,它取当前状态和最新的动作来计算新的状态。
  • 选择器是纯函数,用于选择、派生和组合状态的片段。

在React中,我们鼓励开发者尽可能地使用Hooks作为纯函数。Angular也鼓励开发者使用Smart-Dumb Component范式来实现同样的模式。

这时我意识到,我缺乏一些关键的函数式编程技能。掌握NgRx并没有花太多时间,因为在学习了函数式编程的关键概念后,我有了一个 "Aha!时刻"。我提高了对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绝对是坐落在备忘化之上的;这是它快速的主要原因之一。

这种转换应该是直观和透明的。

你可能会问自己,"那副作用呢?它们去哪里了?"在他的GOTO演讲中,Russ Olsen开玩笑说,我们的客户不为纯函数付钱给我们,他们为副作用付钱给我们。这倒是真的:如果计算器的纯函数没有被打印到某个地方,就没有人关心它了。副作用在函数式编程领域有它的位置。我们很快就会看到这一点。

现在,让我们转到维护复杂应用架构的下一步,即下一个核心概念:不可变的状态

不可变的状态

对于不可变的状态有一个简单的定义。

  • 你只能创建或删除一个状态。你不能更新它。

简单来说,要更新一个用户对象的年龄

let user = { username:"admin", age:28 }

你应该这样写

// Not like this
newUser.age = 30;

// But like this
let newUser = {...user, age:29 }

每一个变化都是一个新的对象,它从旧的对象中复制了属性。因此,我们已经处于一种不可改变的状态。

字符串、布尔值和数字都是不可变的状态。你不能追加或修改现有的值。相比之下,Date是一个可变的对象。你总是操纵同一个日期对象。

不变性适用于整个应用程序。如果你在改变年龄的函数里面传递一个用户对象,它不应该改变一个用户对象,它应该用更新的年龄创建一个新的用户对象并返回它。

function updateAge(user, age) {
	return {...user, age: age)
}

let user = {username: 'admin', age: 29};

let newUser = updateAge(user, 32);

为什么我们要把时间和注意力放在这上面?有几个好处值得强调。

后端编程语言的一个好处是涉及并行处理。如果一个状态的改变不依赖于引用,并且每次更新都是一个新的对象,那么你可以把过程分成几块,用无数的线程来处理同一个任务,而不需要共享相同的内存。你甚至可以跨服务器并行化任务。

对于Angular和React等框架来说,并行处理是提高应用程序性能的更有利的方式之一。例如,Angular必须检查你通过输入绑定传递的每个对象的属性,以辨别一个组件是否需要重新渲染。但如果我们设置ChangeDetectionStrategy.OnPush ,而不是默认的,它将通过引用而不是每个属性来检查。在一个大型的应用程序中,这无疑可以节省时间。如果我们以不可改变的方式更新我们的状态,我们就可以免费获得这种性能提升。

所有编程语言和框架都共享的不可更改的状态的另一个好处是类似于纯函数的好处。它更容易被思考和测试。当一个变化是一个从旧状态中诞生的新状态时,你清楚地知道你在做什么,你可以准确地跟踪状态是如何变化的,在哪里变化的。你不会丢失更新历史,你可以撤销/重做对状态的改变(React DevTools就是一个例子)。

然而,如果一个单一的状态被更新,你将不知道这些变化的历史。把不可变的状态想象成银行账户的交易历史。这实际上是一个必须具备的条件。

现在我们已经回顾了不可变性和纯粹性,让我们来解决剩下的核心概念:副作用

侧面效应

我们可以概括一下副作用的定义。

  • 在计算机科学中,如果一个操作、函数或表达式修改了其本地环境之外的一些状态变量值,那么它就被称为有副作用。也就是说,除了向操作的调用者返回一个值(主要效果)之外,它还有一个可观察的效果。

简单地说,所有在函数范围之外改变状态的东西--所有的I/O操作和一些与函数没有直接联系的工作--都可以被视为副作用。然而,我们必须避免在纯函数内部使用副作用,因为副作用与函数式编程理念相矛盾。如果你在一个纯函数里面使用I/O操作,那么它就不再是一个纯函数了。

尽管如此,我们还是需要在某个地方有副作用,因为没有副作用的应用程序是毫无意义的。在Angular中,不仅纯函数需要防止副作用,我们还必须避免在组件和指令中使用它们。

让我们来看看如何在Angular框架内实现这一技术的魅力。

功能性Angular编程

了解Angular的第一件事是需要尽可能多地将组件解耦成更小的组件,以便于维护和测试。这是必要的,因为我们需要划分我们的业务逻辑。另外,我们鼓励Angular开发者将组件仅用于渲染目的,并将所有的业务逻辑移至服务内部。

为了扩展这些概念,Angular用户在他们的词汇表中加入了 "Dumb-Smart Component"模式。这种模式要求服务调用不存在于小组件内。因为业务逻辑存在于服务中,我们仍然要调用这些服务方法,等待它们的响应,然后才进行任何状态的改变。所以,组件内部有一些行为逻辑。

为了避免这种情况,我们可以创建一个智能组件(Root Component),它包含业务和行为逻辑,通过输入属性传递状态,并调用聆听输出参数的动作。这样一来,小的组件就真的只是为了渲染的目的。当然,我们的根组件里面必须有一些服务调用,我们不能直接删除它们,但它的效用将只限于业务逻辑,而不是渲染。

让我们来看看一个计数器组件的例子。displayField 一个计数器是一个组件,它有两个增加或减少数值的按钮,和一个显示currentValue 。因此我们最终有四个组件

  • CounterContainer
  • 增加按钮
  • 减少按钮
  • 当前值

所有的逻辑都在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库

我们可以把任何网络应用分成三个核心部分

  • 业务逻辑
  • 应用状态
  • 渲染逻辑

业务逻辑是发生在应用程序上的所有行为,如联网、输入、输出、API等。

应用状态是应用程序的状态。它可以是全局的,如当前的授权用户,也可以是局部的,如当前的计数器组件值。

渲染逻辑包含了渲染,如使用DOM显示数据,创建或删除元素等。

通过使用Dumb-Smart模式,我们将渲染逻辑与业务逻辑和应用状态解耦,但我们也可以将它们分开,因为它们在概念上都是不同的。应用状态就像你的应用程序在当前时间的快照。业务逻辑就像一个静态功能,始终存在于你的应用程序中。划分它们的最重要原因是,业务逻辑大多是我们想在应用程序代码中尽可能避免的副作用。这就是NgRx库及其功能范式的闪光之处。

有了NgRx,你可以将所有这些部分解耦。有三个主要部分

  • 减速器
  • 行动
  • 选择器

结合函数式编程,这三者结合起来给我们一个强大的工具来处理任何规模的应用程序。让我们来看看它们中的每一个。

减速器

还原器是一个纯函数,它有一个简单的签名。它接收一个旧的状态作为参数,并返回一个新的状态,要么是从旧的状态派生出来的,要么是一个新的状态。状态本身是一个单一的对象,它与你的应用程序的生命周期共存。它就像一个HTML标签,一个单一的根对象。

你不能直接修改一个状态对象,你需要用还原器来修改它。这有很多好处

  • 改变状态的逻辑只存在于一个地方,你知道状态在哪里以及如何改变。
  • 减速器函数是纯函数,易于测试和管理。
  • 因为还原器是纯函数,它们可以被备忘录化,从而有可能对它们进行缓存,避免额外的计算。
  • 状态变化是不可改变的。你永远不会更新同一个实例。相反,你总是返回一个新的实例。这实现了一种 "时间旅行 "的调试体验。

这是一个微不足道的还原器的例子。

function usernameReducer(oldState, username) {
	return {...oldState, username}
}

尽管它是一个非常简单的虚拟减速器,但它是所有长而复杂的减速器的骨架。它们都有相同的好处。在我们的应用程序中,我们可以有数百个减速器,我们想做多少就做多少。

对于我们的计数器组件,我们的状态和还原器可以是这样的。

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
}

我们从组件中删除了状态。现在我们需要一种方法来更新我们的状态并调用适当的还原器。这时,动作就开始发挥作用了。

行动

动作是一种通知NgRx调用还原器和更新状态的方式。没有它,使用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 ,没有任何状态变化逻辑。它只知道要派发什么。现在我们需要一些方法将这些数据显示给视图。这就是选择器的作用。

选择器

选择器也是一个非常简单的纯函数,但与还原器不同,它不更新状态。顾名思义,选择器只是选择它。在我们的例子中,我们可以有三个简单的选择器。

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)。

了解基础知识

什么是NgRx?

NgRx是一个全局状态管理库,它有助于将领域和业务层与渲染层解耦。它是完全反应式的。所有的变化都可以通过简单的Observables来监听,这使得复杂的业务场景更容易处理。

为什么我应该使用NgRx?

它使应用程序更易于维护和测试,因为它将业务和领域逻辑与渲染层解耦。它也更容易调试,因为应用程序中的每个动作都是一个命令,可以使用Redux DevTools进行追踪。

什么时候不应该使用NgRx?

如果你的应用程序是一个只有几个域的小应用程序,或者你想快速交付一些东西,千万不要使用NgRx。它带有大量的模板代码,所以在某些情况下,它会使你的编码更加困难。

NgRx和RxJS的区别是什么?

NgRx和RxJS没有任何共同之处。它们是有不同目的的不同库。NgRx是一个状态管理库,而RxJS更像是一个工具包库,它将JavaScript的异步行为包装成Observer和obsables。此外,NgRx内部使用RxJS。