Ngrx 實做筆記 - 做一個簡單的 TodoList

Posted by Joseph on April 07, 2019 · 25 mins read

這是一個基本 TodoList 的程式,並使用 Ngrx 實做。

建議要對 rxjs與 Angular有一定瞭解後再嘗試,中間關連到許多較深的概念,如果不熟可能會卡關很久。

前言

完整說明可以參考Ngrx官網,範例程式碼可參考 github

最終功能如圖示,是一個陽春的待辦事項功能,可以新增待辦事項與搬移待辦事項:

Action

為Ngrx的核心,用來描述在應用系統中的事件,首先可以先看 Action interface定義:

Action Interface

export interface Action {
    type: string;
}

官網對於 Action type描述:

The type property is for describing the action that will be dispatched in your application. The value of the type comes in the form of [Source] Event and is used to provide a context of what category of action it is, and where an action was dispatched from.

type 可以視為 Action的ID,為了方便識別Action用途,所以type的value 會是用來描述Action的字串。

除了 type外,在實做上也會需要於Action 中傳遞其他資訊,通常會將所需要在Action中傳遞的資訊傳入 payload 屬性中。

Reducer

Reducer主要為負責處理State轉換的邏輯,當前的 State如何轉換至新的State,會統一在reducer處理。

Reducer function 必須為 pure function,pure function 的意思為:

  1. 每次輸入相同的內容到function中,輸出結果都是相同的,不會有第一次輸入 1輸出2,下次輸入1輸出卻變成3的情況。
  2. 不會有side effect,意思為任何在functuion內的操作都不會影響其他地方的資料。如今天傳入一個物件到 function中,對物件做了操作(如改變屬性),在 function執行完後不會影響原呼叫程式的變數。

在reducer中會傳入兩個參數,分別為 state與 action

state:可以是初始state 或是現在的state

action:現在 dispatch的 action

Reducer function 會搭配 switch 使用,目的是在於不同 action 傳入時, reducer 可以透過 switch action的 type 來決定執行對應的程式。

State

統一儲存於 store中的資料,可以自行定義與擴展,沒有特別限制。

整體的運作流程,流程是單向的:

Dispatch -> Reducer -> New State -> Store

簡單用文字描述:

  1. View 會 dispatch Action
  2. 接會會到 Reducer處理邏輯部分
  3. reducer會回傳新的state
  4. state會存回 store中
  5. 任何有訂閱的 View會收到最新的 state資訊

前置作業:

  1. 安裝 Ngrx
npm install @ngrx/store --save
  1. 安裝 ngrx devtool (optional)
npm install @ngrx/store-devtools --save

功能分析

這邊總共會需要實做幾項功能:

  1. 新增待辦事項,新增完成後加入未完成清單
  2. 點選未完成清單中項目,將新增至已完成清單
  3. 點選已完成清單中項目,將事項移回未完成清單
  4. 點選Mark All as Done按鈕,將所有待辦事項移至已完成清單

實做Action

定義各個Action 識別資訊,官方有提供一套 rule 供大家參考如何定義好的 Action

  • Upfront - write actions before developing features to understand and gain a shared knowledge of the feature being implemented.
  • Divide - categorize actions based on the event source.
  • Many - actions are inexpensive to write, so the more actions you write, the better you express flows in your application.
  • Event-Driven - capture events not commands as you are separating the description of an event and the handling of that event.
  • Descriptive - provide context that are targeted to a unique event with more detailed information you can use to aid in debugging with the developer tools.

這邊僅示範用,故沒有特別參考官方的方式。

這邊透過 Enum 定義了四種Action 資訊。

export enum TodoListActionType {
  AddTodoItem = 'ADD_TODO_ITEM',
  CheckUncompleteItem = 'CHECK_UNCOMPLETE_ITEM',
  CheckCompleteItem = 'CHECK_COMPLETE_ITEM',
  CheckAllItems = 'CHECK_ALL_ITEMS'
}

接著可以分別定義所需使用的Action:

export class AddTodoItemAction implements Action {
  readonly type = TodoListActionType.AddTodoItem;
  constructor(public payload: string) { }
}
  1. 定義之Action必須實做 Ngrx中 Action interface
  2. type 統一透過 TodoListActionType enum 指派
  3. constructor 內 payload 為在Action發生時要傳遞的額外資訊

最後透過 union type export:

export type TodoListActions = AddTodoItemAction;

完整程式碼如下,定義了 TodoList中四種Action:

import { Action } from '@ngrx/store';

export enum TodoListActionType {
  AddTodoItem = 'ADD_TODO_ITEM',
  CheckUncompleteItem = 'CHECK_UNCOMPLETE_ITEM',
  CheckCompleteItem = 'CHECK_COMPLETE_ITEM',
  CheckAllItems = 'CHECK_ALL_ITEMS'
}
// 新增
export class AddTodoItemAction implements Action {
  readonly type = TodoListActionType.AddTodoItem;
  constructor(public payload: string) { }
}
// 從待辦移至完成
export class CheckUncompleteItemAction implements Action {
  readonly type = TodoListActionType.CheckUncompleteItem;
  constructor(public payload: number) { }
}
// 從完成移至待辦
export class CheckCompleteItemAction implements Action {
  readonly type = TodoListActionType.CheckCompleteItem;
  constructor(public payload: number) { }
}
// 將所有待辦移至完成
export class CheckAllItemsAction implements Action {
  readonly type = TodoListActionType.CheckAllItems;
}
// export union type
export type TodoListActions = AddTodoItemAction
  | CheckUncompleteItemAction
  | CheckCompleteItemAction
  | CheckAllItemsAction;

實做 Reduer

定義 State

這邊先定義要使用的state:

export class TodoState {
  todoList: TodoItem[];
  alreadyDoneList: TodoItem[];
}

內容相當簡單,為已完成與未完成清單,共兩個變數,裡面存放 TodoItem陣列。

定義初始值:

const initState: TodoState = {
  todoList: [
    new TodoItem('Take out the trash'),
    new TodoItem('Buy bread'),
    new TodoItem('Teach penguins to fly')
  ],
  alreadyDoneList: [],
};

表示一開始程式運行時,待辦清單中會有三筆資料,已完成事項中沒資料。

實做 reducer function

先以一部份來看:

export function todoListReducer(state: TodoState = initState, action: TodoListActions) {
  switch (action.type) {
    case TodoListActionType.AddTodoItem:
      return {
        ...state,
        todoList: [...state.todoList, new TodoItem(action.payload)]
      };
    default: return state;
  }
}

state參數: 當前的state,這邊有指派初始 state,在第一次執行時會使用 initState

action參數:dispatch的action

TodoListActionType:為在Action中定義的 Action enum,因傳入的 action可能為多種不同的 action,故使用 union type表示

reducer內會透過 switch 來判斷現在傳入action的type 來決定要執行哪段程式碼,如果是第一次執行時,會預設傳入一個初始化得Action,內容如下,而這一個action可以透過 switch 的 default 來處理:

{type: "@ngrx/store/init"}

其餘狀態,如TodoListActionType.AddTodoItem,因為要保持 pure function原則,所以每次都會回傳一個新的物件,避免 side effect,這邊透過 … (spread operator)來處理陣列與物件,目的與 Object.assign 方法類似,兩種方式皆可行,關於 spread operator,可以參考這個連結

簡單來看 spread operator:

/**
* 等同於複製所有 state.todoList中元素至新陣列,等同複製 state.todoList
*/
[...state.todoList]

/**
* 先複製state.todoList陣列後,再新增一個新物件於陣列尾端
*/
[...state.todoList, new TodoItem(action.payload)]

/**
* 等同於複製所有 state上的屬性至新的物件
*/
{...state}

/**
* 會由左向右執行,先複製state上所有屬性後,接著todoList會覆蓋掉原本 state上屬性
*/
{...state,  todoList: [...state.todoList, new TodoItem(action.payload)]} 

完整reducer 程式碼如下:


export function todoListReducer(state: TodoState = initState, action: TodoListActions) {
  switch (action.type) {
    case TodoListActionType.AddTodoItem:
      // 新增待辦
      return {
        ...state,
        todoList: [...state.todoList, new TodoItem(action.payload)]
      };
    case TodoListActionType.CheckUncompleteItem:
      // 待辦移至完成
      const checkedItems = state.todoList.splice(action.payload, 1);
      return {
        ...state,
        todoList: [...state.todoList],
        alreadyDoneList: [...state.alreadyDoneList, ...checkedItems]
      };
    case TodoListActionType.CheckCompleteItem:
			// 完成移至待辦
      const uncheckedItems = state.alreadyDoneList.splice(action.payload, 1);
      return {
        ...state,
        todoList: [...state.todoList, ...uncheckedItems],
        alreadyDoneList: [...state.alreadyDoneList]
      };
    case TodoListActionType.CheckAllItems:
			// 所有待辦移至完成
      return {
        ...state,
        todoList: [],
        alreadyDoneList: [
          ...state.alreadyDoneList,
          ...state.todoList]
      };
    default: return state;
  }
}

第一次寫會比較陌生,對於語法可能會感到疑惑, 需要記住的原則是 reducer 是一個 pure function,在 pure function內的操作不能造成任何的 side effect,所以在回傳新的 state時不能影響原本 state的內容

到這邊 Action與 Reducer大致完成,最後於AppModule上做宣告即可。

註冊 StoreModule

上面定義好了Action與Reducer後,最後需要在AppModule中 import StoreModule後才會生效:

@NgModule({
  declarations: [
    AppComponent,
    TodoListComponent,
    TodoItemComponent,
    TodoPageComponent,
    AlreadyDoneComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    StoreModule.forRoot({ todoState: todoListReducer }),
    StoreDevtoolsModule.instrument()
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

forRoot參數傳入一個物件, todoState與 todoListReducer,可以解讀為使用todoState時程式會搭配 todoListReducer使用,目的在建立 todoState與 todoListReducer的關連。

StoreDevtoolsMopdule.instrument() 是一個 ngrx開發用的工具,必須安裝對應套件才可使用,安裝後,若瀏覽器有安裝 Redux 相關套件,則可以在開發人員控制台看見程式運作期間狀態的改變。

到這邊大致上都完成,最後只要將從 store取得資料與 dispatch方法寫入程式中即可。

從 Store中取得 State

在程式內只要注入 Store 即可取得 state:

以 TodoListComponent來看:

export class TodoListComponent implements OnInit {

  todoList: TodoItem[] = [];

  constructor(private store: Store<TodoState>) { }

  ngOnInit() {
    /** 
    * select 內容必須對應到 StoreModule上註冊的狀態
    * 因 StoreModule.forRoot({ todoState: todoListReducer })
    * 故 store.select('todoState')
    */
    this.store.select('todoState')
      .subscribe((state: TodoState) => this.todoList = state.todoList);
  }
}

todoList:用來儲存當前待辦事項

constructor注入 store,指明 store可以取得 TodoState,因AppModule上註冊了 StoreModule.forRoot({ todoState: todoListReducer }),故可以推斷出這邊可以拿到 TodoState

this.store.select(‘todoState’):因為StoreModule上註冊了 todoState:todoListReducer,在 store可以透過 select語法取得 todoState變數,並回傳 TodoState

其餘程式可以參考程式碼,每個的原理都相同。

執行Action

執行 Action需要透過 store 的 dispatch 方法, dispatch接受一個 action 參數,以下面一段 TodoListComponent程式來看:

export class TodoListComponent implements OnInit {

  todoList: TodoItem[] = [];

  constructor(private store: Store<TodoState>) { }
  /*
  * 透過 store dispatch一個 action
  * 1 因 action 為 class,故使用 new Object方式 建立 Action
  *
  * 2 因 CheckUncompleteItemAction 中的 constructor 中需要傳入 payload:number,所以需要在   dispatch時傳進一個參數,定義可以見 todo-list.action -> CheckUncompleteItemAction
  *
  * 3 這邊的 payload代表要從 todoList中要移至 alreadyDoneList 的 index索引,傳入index為 3 表示要將 todoList第三筆資料移除,並加入 alreadyDoneList尾端
  */
  onItemClicked(index: number) {
    this.store.dispatch(new CheckUncompleteItemAction(index));
  }
}

其餘 Action 的 dispatch 可以直接參考程式碼。

整個流程可以用一張動畫圖闡述,Middlewares的部份這篇沒有提到,主要是處理非同步請求時使用:

圖片來源

使用 Redux Devtool 觀察狀態轉變

如果有在程式內註冊了,則可以在瀏覽器加裝 devtool 來觀察狀態轉變

建議 devtool 只在開發時啟用,佈屬上 prod 環境時建議要關閉。

下列連結可能只是暫時的,若已經無法查到資訊,則在 Google上輸入 redux devtool your_browser_name 就可以找到。

套件資訊:

Chrome

Firefox

其餘可參考 這裡 ,或參考官方說明

這邊以 Firefox做展示,安裝後開啟 開發者控制台,可以看見安裝好的 Redux devtool:

接著開啟程式,會看見程式的initAction與 initState資訊:

接著執行CheckUncompleteItemAction,可以看見 state的變化:

同時還可以觀察 dispatch的 action資訊:

觀察 state 前後的差異:

這些狀態還可以匯出與重現,如果遇到一些難以追查的 bug,可以透過 redux devtool來觀察狀態的變化,並重現問題

重新執行,可觀察狀態的變更:

完整操作如下:

有這些功能,在開發上無疑的是一大利器,可以多加利用。

總結

這邊僅簡單介紹 Ngrx基本的運作模式與使用,一個簡單的功能就必須寫這麼多東西,不過熟了之後其實與 Angular 中的 service有點相似。

除此之外還有非同步請求與 routing的部分尚未涵蓋到,等哪天讀熟了再分享出來。