作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Luka Onikadze's profile image

Luka Onikadze

With solid full-stack experience, Luka目前担任前端团队负责人和开发人员, specializing in Node.js, Angular, and JavaScript.

Years of Experience

10

Share

If a team lead 指导开发人员编写大量的样板代码,而不是编写几个方法来解决某个问题, they need convincing arguments. Software engineers are problem solvers; they prefer to automate things and avoid unnecessary boilerplate.

Even though NgRx 它附带了一些样板代码,还提供了强大的开发工具. 本文演示了多花一点时间编写代码将获得值得付出努力的好处.

Most developers started using state management when Dan Abramov released the Redux library. 一些公司开始使用状态管理是因为这是一种趋势,而不是因为他们缺乏状态管理. 使用标准的“Hello World”项目进行状态管理的开发人员可能很快就会发现自己一遍又一遍地编写相同的代码, 毫无收益地增加复杂性.

最终,一些人感到沮丧,完全放弃了国家管理.

My Initial Problem With NgRx

我认为这种样板问题是NgRx的主要问题. 起初,我们没有看到它背后的大图景. NgRx是一个库,而不是一种编程范式或思维方式. However, 为了充分掌握这个库的功能和可用性, 我们必须扩展一下我们的知识,把重点放在函数式编程上. 这时,您可能会编写样板代码,并对此感到高兴. (I mean it.) I was once an NgRx skeptic; now I’m an NgRx admirer.

A while ago, I started using state management. 我经历了上面描述的样板文件,所以我决定停止使用这个库. Since I love JavaScript, 我试图至少掌握当今所有流行框架的基本知识. 以下是我在使用React时学到的东西.

React has a feature called Hooks. 就像Angular中的组件一样,Hooks是接受参数并返回值的简单函数. 钩子可以有一个状态,这被称为副作用. 例如,Angular中的一个简单按钮可以像这样翻译成React:

@Component({
  selector: 'simple-button',
  template: `  `,
})
导出类SimpleButtonComponent {
  @Input()
  name!: string;
}

 
导出默认函数SimpleButton(props: {name: string}) {
  return ;
}

正如你所看到的,这是一个简单的转换:

  • SimpleButtonComponent => SimpleButton
  • @Input() name => props.name
  • template => return value

插图:Angular Component和React Hooks非常相似.
Angular Component和React Hooks非常相似.

Our React function SimpleButton 在函数式编程世界中有一个重要特征:它是一个 pure function. 如果你正在读这篇文章,我猜你至少听过一次这个词. NgRx.io 在关键概念中引用了两次纯函数:

  • State changes are handled by pure functions called reducers 它采用当前状态和最新操作来计算新状态.
  • Selectors are pure functions 用于选择、派生和组合状态片段.

在React中,开发者被鼓励尽可能多地使用Hooks作为纯函数. Angular还鼓励开发者使用Smart-Dumb组件范式来实现相同的模式.

那时我意识到我缺乏一些关键的函数式编程技能. 掌握NgRx没花多长时间, 如在学习了函数式编程的关键概念之后, I had an “Aha! 我提高了对NgRx的理解,并希望更多地使用它来更好地理解它提供的好处.

这篇文章分享了我的学习经验以及我获得的关于NgRx和函数式编程的知识. 我没有解释NgRx的API,也没有解释如何调用动作或使用选择器. Instead, 我将分享为什么我开始欣赏NgRx是一个伟大的库:它不仅仅是一个相对较新的趋势, it provides a host of benefits.

Let’s start with functional programming.

Functional Programming

函数式编程是一种范式 这与其他范例大不相同. 这是一个非常复杂的主题,有许多定义和指导方针. However, 函数式编程包含一些核心概念,了解它们是掌握NgRx(以及一般的JavaScript)的先决条件。.

These core concepts are:

  • Pure Function
  • Immutable State
  • Side Effect

我重复一遍:这只是一个范例,仅此而已. There is no library functional.Js,我们下载并使用它来编写功能软件. 这只是一种思考编写应用程序的方式. 让我们从最重要的核心概念开始: pure function.

Pure Function

如果一个函数遵循两个简单的规则,它就被认为是纯函数:

  • 传递相同的参数总是返回相同的值
  • 缺少函数执行内部可观察到的副作用(外部状态改变), calling I/O operation, etc.)

因此,纯函数只是一个接受一些参数(或根本不接受参数)并返回预期值的透明函数. 可以保证调用这个函数不会产生副作用, 比如联网或者改变一些全局用户状态.

让我们来看三个简单的例子:

//Pure function
function add(a,b){
	return a + b;
}

//不纯函数违反规则1
function random(){
	return Math.random();
}

//不纯函数违反规则2
function sayHello(name){
	console.log("Hello " + name);
}
  • 第一个函数是纯函数,因为当传递相同的参数时,它总是返回相同的答案.
  • 第二个函数不是纯函数,因为它是不确定的,每次调用它都会返回不同的答案.
  • 第三个函数不是纯函数,因为它使用了一个副作用(调用 console.log).

很容易辨别函数是否是纯函数. 为什么纯函数比不纯函数好? 因为它更容易思考. 想象一下,您正在阅读一些源代码,看到一个函数调用,您知道它是纯函数. If the function name is right, you don’t need to explore it; you know that it doesn’t change anything, it returns what you expect. 当您拥有一个包含大量业务逻辑的大型企业应用程序时,这对于调试至关重要, as it can be a huge timesaver.

Also, it’s simple to test. 您不必在其中注入任何东西或模拟某些函数, 您只需传递参数并测试结果是否匹配. 测试和逻辑之间有很强的联系:如果组件易于测试, 它的工作原理和原因很容易理解.

纯函数有一个非常方便和性能友好的功能,称为记忆. 如果我们知道调用相同的参数将返回相同的值,那么我们可以简单地缓存结果,而不会浪费时间再次调用它. NgRx definitely sits on top of memoization; that’s one of the main reasons why it is fast.

转换应该是直观和透明的.

你可能会问自己:“副作用怎么办? Where do they go?” In his GOTO talkRuss Olsen开玩笑说,我们的客户付钱给我们不是为了单纯的功能,而是为了副作用. 这是真的:没有人关心计算器纯函数,如果它没有被打印到某个地方. 副作用在函数式编程领域中占有一席之地. We will see that shortly.

For now, 让我们进入维护复杂应用程序体系结构的下一个步骤, the next core concept: immutable state.

Immutable State

不可变状态有一个简单的定义:

  • 只能创建或删除状态. You can’t update it.

简单来说,要更新User对象的年龄…:

让user = {username:"admin",年龄:28}

你应该这样写:

// Not like this
newUser.age = 30;

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

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

String、Boolean和Number都是不可变状态:不能追加或修改现有值. 相反,Date是一个可变对象:您总是操作同一个Date对象.

不变性适用于整个应用程序:如果在函数内部传递用户对象,则会改变其年龄, 它不应该改变用户对象, 它应该创建一个带有更新过的年龄的新用户对象并返回它:

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

让user = {username: 'admin',年龄:29};

let newUser = updateAge(user, 32);

为什么我们要花时间和精力在这上面? 有几个好处值得强调.

后端编程语言的一个好处涉及并行处理. 如果状态更改不依赖于引用,并且每次更新都是一个新对象, 您可以将进程分成多个块,并使用无数线程处理相同的任务,而无需共享相同的内存. 您甚至可以跨服务器并行化任务.

对于像Angular和React这样的框架, 并行处理是提高应用程序性能的一种更有益的方法. For example, Angular必须检查你通过Input绑定传递的每个对象的属性,以判断某个组件是否需要重新渲染. But if we set ChangeDetectionStrategy.OnPush 与默认值不同,它将根据引用而不是每个属性进行检查. 在大型应用程序中,这无疑节省了时间. 如果我们不可变地更新状态,我们可以免费获得性能提升.

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

但是,如果一个状态被更新,您将不知道这些更改的历史记录. 可以把不可变状态想象成银行账户的交易历史记录. It’s practically a must-have.

现在我们已经回顾了不变性和纯粹性,让我们来处理剩下的核心概念: side effect.

Side Effect

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

  • In computer science, an operation, function, 如果一个表达式在其局部环境之外修改了一些状态变量值,那么它就会产生副作用. 也就是说,除了向操作的调用者返回一个值(主效果)之外,它还有一个可观察的效果.

Simply put, 在函数作用域之外改变状态的一切——所有I/O操作和一些与函数没有直接关联的工作——都可以被认为是副作用. However, 我们必须避免在纯函数中使用副作用,因为副作用与函数式编程哲学相矛盾. 如果在纯函数中使用I/O操作,那么它就不再是纯函数.

Nevertheless, 我们需要在某个地方产生副作用, 因为没有它们的应用程序将是毫无意义的. In Angular, 纯函数不仅需要防止副作用, 我们也必须避免在组件和指令中使用它们.

让我们来看看如何在Angular框架中实现这项技术的美妙之处.

插图:NgRx角度的副作用

Functional Angular Programming

要理解Angular的第一件事是,需要尽可能频繁地将组件解耦成更小的组件,以方便维护和测试. 这是必要的,因为我们需要划分业务逻辑. Also, Angular developers 是否鼓励将组件仅用于呈现目的,而将所有业务逻辑移动到服务中.

为了扩展这些概念,Angular用户添加了“Dumb-Smart Component” pattern to their vocabulary. 此模式要求服务调用不存在于小组件中. 因为业务逻辑驻留在服务中, 我们仍然需要调用这些服务方法, wait for their response, 然后才进行状态改变. 因此,组件内部有一些行为逻辑.

To avoid that, 我们可以创建一个智能组件(根组件), 哪个包含业务和行为逻辑, 通过输入属性传递状态, 和调用操作监听输出参数. 这样一来,小组件实际上只用于呈现目的. Of course, 我们的根组件内部必须有一些服务调用,我们不能直接删除它们,但它的效用将仅限于业务逻辑, not rendering.

让我们来看一个Counter Component的例子. 计数器是一个组件,它有两个增加或减少值的按钮,还有一个 displayField that displays the currentValue. 所以我们最终有四个组成部分:

  • CounterContainer
  • IncreaseButton
  • DecreaseButton
  • CurrentValue

All the logic lives inside the CounterContainer,所以这三个都只是渲染器. 下面是它们三个的代码:

@Component({
  selector: 'decrease-button',
  template: ``,
})
导出类buttoncomponent
  @Input()
  disabled!: boolean;

  @Output()
  increase = new EventEmitter();
}

@Component({
  selector: 'current-value',
  template: ``,
})
导出类CurrentValueComponent {
  @Input()
  currentValue!: string;
}

@Component({
  selector: 'increase-button',
  template: ``,
})
导出类增加按钮组件{
  @Input()
  disabled!: boolean;

  @Output()
  increase = new EventEmitter();
}

看看它们是多么的简单和纯洁. 它们没有状态或副作用,它们只依赖于输入属性和发出事件. 想象一下测试它们是多么容易. 我们可以称它们为纯组分,因为它们确实是纯组分. 它们只依赖于输入参数, have no side effects, 并且总是通过传递相同的参数返回相同的值(模板字符串).

因此,函数式编程中的纯函数会被转换成Angular中的纯组件. But where does all the logic go? 逻辑仍然在那里,但在一个稍微不同的地方,即 CounterComponent.

@Component({
  selector: 'counter-container',
  template: `
    
    
     
    
    
  `,
})
导出类CounterContainerComponent实现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 Library

我们可以将任何web应用程序划分为三个核心部分:

  • Business Logic
  • Application State
  • Rendering Logic

业务逻辑、应用程序状态和呈现逻辑的说明.

Business Logic 所有的行为都发生在应用程序中吗, such as networking, input, output, API, etc.

Application State is the state of the application. It can be global, as the currently authorized user, and also local, 作为当前计数器组件值.

Rendering Logic 包括呈现,比如使用DOM显示数据,创建或删除元素,等等.

通过使用Dumb-Smart模式,我们将呈现逻辑与业务逻辑和应用程序状态解耦,但我们也可以将它们分开,因为它们在概念上彼此不同. 应用程序状态就像你的应用程序在当前时间的快照. 业务逻辑就像一个静态功能,它总是出现在你的应用程序中. 划分它们的最重要原因是,业务逻辑主要是我们希望在应用程序代码中尽可能避免的副作用. 这就是NgRx库及其函数式范例的亮点所在.

使用NgRx,你可以解耦所有这些部分. There are three main parts:

  • Reducers
  • Actions
  • Selectors

结合函数式编程, 这三者结合起来为我们提供了一个强大的工具来处理任何大小的应用程序. Let’s examine each of them.

Reducers

减速器是一个纯函数,具有简单的签名. 它接受旧状态作为参数并返回新状态, 从旧的或新的衍生出来的. 状态本身是一个单独的对象,它伴随着应用程序的生命周期而存在. 它就像一个HTML标签,一个根对象.

你不能直接修改状态对象,你需要用reducer来修改它. That has a number of benefits:

  • 更改状态逻辑位于单个位置,并且您知道状态在何处以及如何更改.
  • 减速器函数是纯函数,易于测试和管理.
  • 因为约简是纯函数, they can be memoized, 使得缓存它们并避免额外的计算成为可能.
  • State changes are immutable. 永远不要更新同一个实例. 相反,你总是返回一个新的. 这就实现了“时间旅行”调试体验.

这是一个简单的减速器的例子:

函数usernameeducer (oldState, username) {
	return {...oldState, username}
}

尽管它是一个非常简单的虚拟减速器,但它是所有长而复杂的减速器的骨架. 他们都有同样的好处. 我们可以在我们的应用程序中有数百个减速器,我们可以随心所欲地制造.

对于我们的Counter组件,我们的状态和reducer看起来是这样的:

interface State{
	decreaseDisabled:boolean;
	increaseDisabled:boolean;
	currentValue:number;
}

const MIN_VALUE=0;
const MAX_VALUE =100;

函数递减(oldState) {
	const newValue = oldState.currentValue -1
	return {...oldState,currentValue: newValue, reducedisabled: newValue===MIN_VALUE
}

函数increereducer (oldState) {
	const newValue = oldState.currentValue + 1
	return {...oldState,currentValue: newValue, reducedisabled: newValue===MAX_VALUE
}

我们从组件中删除了状态. 现在我们需要一种方法来更新状态并调用相应的reducer. 这就是行动发挥作用的时候.

Actions

动作是通知NgRx调用reducer并更新状态的一种方式. 没有它,使用NgRx就没有意义. 动作是我们附加到电流减速器上的一个简单对象. After calling it, 将调用相应的reducer, 所以在我们的例子中,我们可以有以下动作:

enum CounterActions {
  递增值= '[计数器组件]递增值',
  递减值= '[Counter Component]递减值',
}

on(CounterActions.IncreaseValue,increaseReducer);
on(CounterActions.DecreaseValue,decreaseReducer);

我们的动作与减速器相连. 现在我们可以进一步修改容器组件,并在必要时调用适当的操作:

@Component({
  selector: 'counter-container',
  template: `
    
    
     
    
    
  `,
})
导出类CounterContainerComponent实现OnInit {
  constructor(private store: Store) {}

  decrease() {
    this.store.dispatch(CounterActions.DicreaseValue);
  }

  increase() {
    this.store.dispatch(CounterActions.IncreaseValue);
  }
}

Note: 我们删除了状态,我们将很快添加回来.

Now our CounterContainer 没有任何状态改变逻辑. It just knows what to dispatch. 现在我们需要某种方式将这些数据显示给视图. That’s the utility of selectors.

Selectors

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

函数selectCurrentValue(state) {
	return state.currentValue;
}

函数selectDicreaseIsDisabled(state) {
	return state.decreaseDisabled;
}

函数selectincreeisdisabled (state) {
	return state.increaseDisabled;
}

使用这些选择器,我们可以在智能中选择状态的每个切片 CounterContainer component.

@Component({
  selector: 'counter-container',
  template: `
    
    
     
    
    
  `,
})
导出类CounterContainerComponent实现OnInit {
  decreaseIsDisabled$ = this.store.选择(selectDicreaseIsDisabled);
  increaseIsDisabled$ = this.store.选择(selectIncreaseIsDisabled);
  currentValue$ = this.store.select(selectCurrentValue);

  constructor(private store: Store) {}

  decrease() {
    this.store.dispatch(CounterActions.DicreaseValue);
  }

  increase() {
    this.store.dispatch(CounterActions.IncreaseValue);
  }
}

默认情况下,这些选择是异步的(通常也是异步的)。. 至少从模式的角度来看,这并不重要. 对于同步对象也是如此,因为我们只需从我们的状态中选择一些东西.

让我们退后一步,看看大局,看看我们迄今为止取得了什么成就. 我们有一个计数器应用程序,它有三个主要部分,它们几乎是相互解耦的. 没有人知道应用程序状态是如何管理自身的,或者呈现层是如何呈现状态的.

解耦的部分使用桥接器(动作、选择器)相互连接. 它们被解耦到这样的程度,我们可以把整个状态应用程序代码移到另一个项目中, 比如手机版本. 我们唯一需要实现的就是渲染. But what about testing?

在我看来,测试是NgRx最好的部分. 测试这个示例项目类似于玩井字游戏. 只有纯函数和纯组件,所以测试它们很容易. 现在想象一下,如果这个项目变得更大,有数百个组件. 如果我们遵循同样的模式,我们只会把越来越多的碎片加在一起. 它不会变成一堆杂乱的、不可读的源代码.

We’re almost done. 只剩下一件重要的事情:副作用. 到目前为止,我提到过很多次副作用,但我没有解释在哪里储存它们.

这是因为副作用是蛋糕上的糖霜,通过建立这种模式, 从应用程序代码中删除它们非常容易.

Side Effects

假设我们的计数器应用程序中有一个计时器,它每三秒自动增加一个值. 这只是一个简单的副作用,肯定存在于某个地方. 根据定义,它与Ajax请求具有相同的副作用.

如果我们考虑副作用,大多数副作用的存在主要有两个原因:

  • 在状态环境之外做任何事情
  • Updating application state

For example, 在LocalStorage中存储一些状态是第一种选择, 其次是从Ajax响应更新状态. 但它们都有一个共同的特征:每个副作用都有一个起点. 至少需要调用它一次,以提示它开始操作.

正如我们前面概述的那样,NgRx有一个很好的工具,可以向某人发送命令. That’s an action. 我们可以通过调度一个动作来调用任何副作用. 伪代码看起来像这样:

function startTimer(){
    setInterval(()=>{
 	console.log("Hello application");
    },3000)
}

on(CounterActions.StartTime,startTimer)
...
//我们通过调度一个动作来启动timer

dispatch(CounterActions.StartTime);

It’s pretty trivial. 正如我之前提到的,副作用要么更新,要么不更新. If a side effect doesn’t update anything, there’s nothing to do; we just leave it. 但如果我们想更新状态,我们该怎么做呢? 与组件尝试更新状态的方式相同:调用另一个操作. 所以我们在副作用中调用一个动作来更新状态:

function startTimer(store) {
    setInterval(()=> {
          //我们正在调度另一个动作
 	    dispatch(CounterActions.IncreaseValue)
    }, 3000)
}

on(CounterActions.StartTime, startTimer);
...
//我们通过调度一个动作来启动timer

dispatch(CounterActions.StartTime);

现在我们有了一个功能齐全的应用程序.

Summarizing Our NgRx Experience

在结束我们的NgRx之旅之前,我想提到一些重要的话题:

  • The code shown is simple pseudo code I invented for the article; it is only fit for demonstration purposes. NgRx 这个地方有真正的情报来源吗.
  • 关于将函数式编程与NgRx库连接起来的理论,没有官方的指导方针来证明. 这只是我在阅读了许多由高技能人员创建的文章和源代码示例后形成的观点.
  • 在使用NgRx之后,你肯定会意识到它比这个简单的例子要复杂得多. 我的目的不是让它看起来比实际简单,而是告诉你,即使它有点复杂,甚至可能导致到达目的地的路径更长, it’s worth the added effort.
  • NgRx最糟糕的用法是到处使用它, 无论应用程序的大小或复杂程度如何. 有些情况下,你 should not use NgRx; for example, in forms. 在NgRx中实现表单几乎是不可能的. Forms are glued to the DOM itself; they can’t live separately. If you try to decouple them, 你会发现自己不仅讨厌NgRx,而且讨厌一般的web技术.
  • 有时使用相同的样板代码, even for a small example, can turn into a nightmare, 即使它能在未来给我们带来好处. If that’s the case, 只需与另一个惊人的库集成即可, 它是NgRx生态系统的一部分(ComponentStore).

Understanding the basics

  • What is NgRx?

    NgRx是一个全局状态管理库,可以帮助将域层和业务层与呈现层分离. It’s fully reactive. 所有的变化都可以通过简单的observable来监听, 这使得复杂的业务场景更容易处理.

  • Why should I use NgRx?

    它使应用程序更易于维护和测试,因为它将业务和领域逻辑与呈现层分离. 它也更容易调试,因为应用程序中的每个操作都是可以使用Redux DevTools跟踪的命令.

  • When should you not use NgRx?

    如果你的应用程序很小,只有几个域,或者你想快速交付一些东西,那就不要使用NgRx. 它附带了许多样板代码, 因此,在某些情况下,它会使您的编码更加困难.

  • NgRx和RxJS的区别是什么?

    NgRx和RxJS没有任何共同之处. 它们是不同的库,有着不同的用途. NgRx是一个状态管理库, 而RxJS更像是一个工具包库,它将JavaScript异步行为包装到观察者和可观察对象中. 此外,NgRx在内部使用RxJS.

就这一主题咨询作者或专家.
Schedule a call
Luka Onikadze's profile image
Luka Onikadze

Located in Tbilisi, Georgia

Member since February 9, 2021

About the author

With solid full-stack experience, Luka目前担任前端团队负责人和开发人员, specializing in Node.js, Angular, and JavaScript.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Years of Experience

10

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.