[NgRx 速成班] 讀取狀態的基礎 Store & Selector

對 NgRx 基本架構和程式碼有了一些概念後,接著來看一下如何將狀態讀取出來給程式使用,這會利用到 Store 與 Selector 兩個角色。

基本的程式骨架再上一篇「使用 NgRx Schematics 快速產生程式碼骨架」已經說明過了,這邊將會直接沿用產生出來的程式骨架,繼續調整程式碼。

Store - 集中管理的狀態

取得 Store

NgRx 是一個完整的狀態管理機制,所有的狀態會被儲存在 Store 內,我們可以隨時在程式中注入 Store 來得到狀態資料。

export class TodosComponent {
  constructor(private store: Store) {}

  ngOnInit(): void {
    this.store.subscribe((data) => console.log(data));
  }
}

不過目前還是一個空的物件,也就是整個 Store 是個單純沒有任何 feature 的物件

{ }

我們還需要定義實際上需要的 feature reducer。

宣告使用的 Reducer

要能夠取得想要的狀態資料,必須先告訴 NgRx 要使用哪些狀態,這時候必須告訴 NgRx 我們要用的 Reducer 是誰。

如果是全域的狀態,可以在 AppModule 中宣告

import { 
  todosFeatureKey, 
  reducer as todosReducer 
} from './todos/todos.reducer';

@NgModule({
  ...,
  imports: [
    ...,
    StoreModule.forRoot({ [todosFeatureKey]: todosReducer }, {})
  ]
})
export class AppModule { }

todosFeatureKey 是使用 NgRx Schematics 建立 feature 時一起建立的 feature 名稱,以之前的例子來說就是宣告成一個字串 todos

// todos.reducer.ts
export const todosFeatureKey = 'todos';
...

{ [todosFeatureKey]: todosReducer },代表 todos (也就是 todosFeatureKey) 這個 feature 會使用 todosReducer 來修剪資料。

如此在 Angular 程式一開始載入時,就會將 todosReducer 載入,並將得到的資料存到 todos 這個 feature 中,這時候的 Store 物件內會包含 todos

{
  todos: {}
}

有些時候我們會再使用到某個模組時,才需要這些狀態資料,就可以改成在指定的模組內宣告

import { 
  todosFeatureKey, 
  reducer as todosReducer 
} from './todos/todos.reducer';

@NgModule({
  ...,
  imports: [
    ...
    StoreModule.forFeature(todosFeatureKey, todosReducer)
  ]
})
export class TodosModule { }

StoreModule.forFeature 主要就是兩個參數,分別是 feature 的名稱和使用的 reducer。

這時候就只有在「第一次」使用到該模組時,才會將狀態資料載入,對於一些需要延遲載入的模組,還同時具有將 feature 相關程式碼也都延遲載入的效果。

feature 狀態的預設值

使用 NgRx Schematics 建立 feature 的話,feature 狀態的相關定義會被放在 *.reducer.ts 中。

// todos.reducer.ts
export interface State {
}

export const initialState: State = {
};

export const reducer = createReducer(
  initialState,
  ...
);

其中的 State interface 就是用來定義這個狀態的型別;而 initialState: State 就可以用來建立初始資料,下面的 reducer 使用 createReducer 方法來建立一個 Reducer,並且第一個參數就代表了這個 feature 狀態的預設值。

由於程式中會有許多的狀態,因此我會習慣把 State 重新命名成比較符合需求的名稱,如果狀態比較龐大,也會考慮額外抽一個檔案出來。

不過目前只是示範,就先不更改這些定義名稱,現在我們可以把型別和預設資料都先定義好,例如

export interface State {
  isLoading : boolean;
  todoItems: Array<{
    id: number;
    text: string;
    done: boolean;
  }>;
}

export const initialState: State = {
  isLoading : false,
  todoItems: [
    {
      id: 1,
      text: 'Task 1',
      done: true
    },
    {
      id: 2,
      text: 'Task 2',
      done: false
    }
  ]
};

這時候在讀取 Store 時,就可以看到 todos 這個 feature 下的預設資料了:

{
  todos: {
    isLoading: false,
    todoItems: [
      {
        id: 1,
        text: 'Task 1',
        done: true
      },
      {
        id: 2,
        text: 'Task 2',
        done: false
      }
  }
}

如果有多個 features,只需要如法炮製,使用 NgRx Schematics 建立 feature,並設定好在 StoreModule 中使用對應的 reducer,以及狀態預設值後,就可以統一透過 Store 來存放這些 features 的狀態了!

讀者們可以自行練習看看建立多個 features 將 Store 的內容變成以下物件:

{
  todos: {
    isLoading: false,
    todoItems: [
      { id: 1, text: 'Task 1', done: true },
      { id: 2, text: 'Task 2', done: false }
  },
  userProfile: {
    isLoading: false,
    profile: {
      name: 'Mike',
      age: 18,
      interests: ['Angular', 'RxJS', 'NgRx']
    }
  }
}

當 Store 中存放狀態越來越龐大複雜時,如果只是要取得其中一個 feature 狀態下的某個屬性,直接從龐大的 Store 中讀取會讓程式變的不好閱讀,因此實務上我們會透過接下來的 Selector 來幫助我們讀取想要的狀態。

Selector - 精準選中目標狀態

Selector 這個角色可以幫助我定義一系列意圖明確的方法,來從 Store 中取得指定的內容

在使用 NgRx Schematics 時,已經有先定一好一個最基本,從 Store 中直接取得 feature 的 Selector 了:

import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromTodos from './todos.reducer';

export const selectTodosState = createFeatureSelector<fromTodos.State>(
  fromTodos.todosFeatureKey
);

Feature Selector

createFeatureSelector 方法是用來建議一個「取得 feature 的 selector」,也稱為「feature selector」,它只需要傳入一個參數,就是實際上的 feature key,你可以想像他實際上的程式是這樣的。

例如傳入的是 todos,之後 selectTodosState 就從 Store 物件中拿到 todos 這個屬性下的資料;我們可以使用 Storeselect 方法中指定要使用什麼 Selector:

import { selectTodosState } from './todos.selectors';
import { Store } from '@ngrx/store';

export class TodosComponent {
  constructor(private store: Store) {}

  ngOnInit(): void {
    this.store
      .select(selectTodosState) // 指定 Selector
      .subscribe((data) => console.log(data));
  }
}

如果目前的 Store 物件是:

{
  todos: { isLoading: false, todoItems: [...] },
  userProfile: { ... }
}

那麼套上 selectTodosState 後,訂閱得到的只會剩下 todos 裡面的部分

{ isLoading: false, todoItems: [...] }

如此一來我們就不用管整個 Store 的大狀態,只要專注在我們感興趣的 feature 就好了。

不過這樣還不夠,除了使用 feture selector 取得指定的 feature 狀態外,我們也可以組合這個 feature selector,搭配自訂的程式,來取得更細部的狀態。

建立 Selector

我們可以使用 createSelector 這個方法,來建立一個新的 selector,這個方法的第一個參數通常是指定 feature selector,之後再撰寫自訂的邏輯,來取得這個 feature 下的資料;以下範例從 feature selector 取得 feature 狀態物件後,從 feature 物件中取得 isLoading 的狀態內容:

export const selectTodoLoading = createSelector(
  selectTodosState,
  (state) => state.isLoading
)

之後就可以直接用這個 selector 來得到 isLoading 的狀態啦:

isLoading$ = this.store.select(selectTodoLoading);

createSelector 除了使用 feature selector 當參數外,當然也可以使用一般的 selector,因此我們也可以將 selector 拆小一點以達到共用的目標,例如要取得 todos 的數量,最直覺的寫法為:

export const selectTodoCount = createSelector(
  selectTodosState,
  (state) => state.todoItems.length
)

但我們可以先寫一個取得 todoItems 的 Selector:

export const selectTodoItems = createSelector(
  selectTodosState,
  (state) => state.todoItems
);

之後再利用這個 Selector 來取的總數:

export const selectTodoCount = createSelector(
  selectTodoItems,
  (todosItems) => todosItems.length
);

組合 Selector

我們也可以透過傳入多個 Selector,把 Selector 的資料組合在一起,例如當 loading 時,todos 的數量設為 -1:

export const selectCurrentTodoCount = createSelector(
  selectTodoItems,
  selectTodoLoading,
  (todosItems, loading) => loading ? -1 : todosItems.length
);

傳參數的 Selector

前面的 Selector 都是固定好要取得的資料,但有時候我們希望可以依照指定條件作為參數來抓資料,例如指定的 id 等等,我們可以自己寫一個 function 來產生 Selector:

export const selectTodoItemById = (id: number) => createSelector(
  selectTodoItems,
  (todosItems) => todosItems.find((item) => item.id === id)
);

使用時就先帶入參數,即可取得 selector:

todo$ = this.store.select(selectTodoItemById(1));

Memoized

NgRx 中透過 Selector 取得資料會有一個內建的暫存機制,也就是當使用 Selector 取得資料後,會將資料快取起來,當再次使用 Selector 時,就可以直接使用,省去計算的成本,直到來源狀態被改變。

如果我們想要清除這個暫存資料,可以呼叫 Selector 的 release() 方法:

selectTodoItems.release();

本日小結

今天我們對於 NgRx 的 Store 有了基本的認識,Store 就是用來集中管理存放資料的地方;當要從 Store 取得資料時,比起拿到整個 Store,透過 Selector 可以幫助我們更精確的取得目標資訊。

今天的介紹都以「取得資料」為主,接下來我們相介紹如何「變更」資料,讓整個狀態管理更加完善!

如果您覺得我的文章有幫助,歡迎免費成為 LikeCoin 會員,幫我的文章拍手 5 次表示支持!