深入了解 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() 名稱 => 道具名稱
  • 模板 => 返回值

插圖:Angular 組件和 React Hooks 非常相似。
Angular 組件和 React Hooks 非常相似。

我們的 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 框架中實現這種技術的美妙之處。

插圖:NgRx Angular 副作用

函數式角度編程

關於 Angular,首先要了解的一件事是需要盡可能頻繁地將組件解耦為更小的組件,以便更輕鬆地進行維護和測試。 這是必要的,因為我們需要劃分我們的業務邏輯。 此外,鼓勵 Angular 開發人員將組件僅用於渲染目的,並將所有業務邏輯移動到服務中。

為了擴展這些概念,Angular 用戶在他們的詞彙表中添加了“Dumb-Smart Component”模式。 這種模式要求服務調用不存在於小組件中。 因為業務邏輯駐留在服務中,我們仍然必須調用這些服務方法,等待它們的響應,然後才能進行任何狀態更改。 因此,組件內部有一些行為邏輯。

為了避免這種情況,我們可以創建一個智能組件(根組件),其中包含業務和行為邏輯,通過輸入屬性傳遞狀態,並調用監聽輸出參數的動作。 這樣,小組件實際上僅用於渲染目的。 當然,我們的根組件內部必須有一些服務調用,我們不能只刪除它們,但它的實用性僅限於業務邏輯,而不是渲染。

讓我們看一個計數器組件示例。 計數器是一個組件,它有兩個用於增加或減少值的按鈕,以及一個用於顯示currentValuedisplayField 。 所以我們最終得到了四個組件:

  • 計數器容器
  • 增加按鈕
  • 減少按鈕
  • 當前值

所有的邏輯都存在於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) 的一部分。