淺談前端的狀態管理,以及anguar的狀態管理庫

weiweiyi發表於2023-03-11

背景

在當前angular專案中嘗試使用狀態管理。下面先來了解狀態管理。

什麼是狀態管理

狀態管理是一個十分廣泛的概念,因為狀態無處不在。服務端也有狀態,Spring 等框架會管理狀態,手機 App 也會把資料儲存到手機記憶體裡。

在前端技術中, 狀態管理可以幫助你管理“全域性”狀態 - 那些應用程式的許多部分都需要的狀態。比如元件之間共享的資料、頁面需要及時更新的資料等等。

為什麼需要狀態管理

我們先來看一個 facebook 的例子。

在 Facebook 沒有 Flux 這種狀態管理架構以前,就有很多狀態未同步的 bug:

在 Facebook 逛著逛著,突然來了幾條通知,但是點進去之後竟然沒有了,過了一會兒,通知又來了,點進去看到了之前的訊息,但是新的還是沒有來。

這種問題就跟他們在 Flux 介紹 中所描述的一樣:

為了更好地描述Flux,我們將其與經典的MVC結構比較。在客戶端MVC應用程式中,資料流大概如下

  1. 使用者互動控制器,
  2. 控制器更新model,
  3. 檢視讀取model的新資料,更新並顯示給使用者

image.png


但是在多個控制器,模型,父子元件等新增下,依賴變得越來越複雜。

控制器不僅需要更新當前元件,也需要更新父子元件中的共享資料。

如下圖,僅新增三個檢視,一個控制器和一個模型,就已經很難跟蹤依賴圖。

image.png


這時,facebook 提出了 Flux架構 ,它的重要思想是:

單一資料來源和單向資料流

單一資料來源要求: 客戶端應用的關鍵資料都要從同一個地方獲取
單向資料流要求:應用內狀態管理的參與者都要按照一條流向來獲取資料和發出動作,不允許雙向交換資料

image.png

舉個例子:

一個頁面中渲染了資料列表 books,這個 books 變數是透過 store 獲取的,符合單向資料流。

但是這時候要新增一本書,我們呼叫了一個 api 請求 createBook.

通常有人為了方便,會在元件裡面透過 http 呼叫請求後直接把新增的 book 加到列表裡面。這就違反了單一資料來源和單向資料流

假設這時另一個元件也在渲染資料列表 books, 這時候我們就需要手動更改這個元件中的資料。或者再次請求一次後臺。

當這樣的情況一多,意味著應用內的狀態重新變成七零八落的情況,重歸混沌,同步失效,容易bug從生。


在同一時期,Facebook 的 React 跟 Flux 一起設計出來,React 開始逐漸流行,同時在 Flux 的啟發下,狀態管理也正式走進了眾多開發者的眼前,狀態管理開始規範化地走進了人們的眼前。

狀態模式的模式和庫

Vue與React都有較為簡單的全域性狀態管理策略,比如簡單的store模式,以及第三方狀態管理庫,比如Rudux,Vuex等

Store

最簡單的處理就是把狀態存到一個外部變數裡面,當然也可以是一個全域性變數

var store = {
  state: {
    message: 'Hello!'
  },
  setMessageAction (newValue) {
    this.state.message = newValue
  },
  clearMessageAction () {
    this.state.message = ''
  }
}

image.png

但是當你認真觀察上面的程式碼的時候,你會發現:你可以直接修改 store 裡的 state.

萬一元件瞎胡修改,不透過 action,那我們也沒法跟蹤這些修改是怎麼發生的。

所以就需要規定一下,元件不允許直接修改屬於 store 例項的 state,

也就是說,元件裡面應該執行 action 來分發 (dispatch) 事件通知 store 去改變。

這樣進化了一下,一個簡單的 Flux 架構就實現了。

Flux 架構

Flux其實是一種思想,就像MVC,MVVM之類的。

image.png

Flux 把一個應用分成四部分:

  • View:檢視層
  • Action:動作,即資料改變的訊息物件。
  • Dispatcher:派發器,接收 Actions ,發給所有的 Store
  • Store:資料層,存放應用狀態與更新狀態的方法,一旦發生變動,就提醒 Views 更新頁面

這樣定義之後,我們修改store裡的資料只能透過 Action , 並讓 Dispatcher 進行派發。

可以發現,Flux的最大特點就是資料都是單向流動的。

同時也有一個特點 : 多Store

Redux

受 Facebook 的 Flux 啟發, Redux 狀態管理庫演變出來,Dan Abramov 與 Andrew Clark於2015年建立。

React 的狀態管理庫中,Redux基本佔據了最主流位置。

它和 Flux 有什麼不同呢?

image.png

Redux 相比 Flux 有以下幾個不同的點:

  • Redux是 單Store, 整個應用的 State 儲存在單個 Store 的物件樹中。
  • 沒有 Dispatcher

Redux 的組成:

  • Store
  • Action
  • Reducer

store

Redux 裡面只有一個 Store,整個應用的資料都在這個大 Store 裡面。

import { createStore } from 'redux';
const store = createStore(fn);

Reducer

Redux 沒有 Dispatcher 的概念,因為 Store 裡面已經整合了 dispatch 方法。

import { createStore } from 'redux';
const store = createStore(fn);
store.dispatch({
  type: 'ADD_TODO',
  payload: 'Learn Redux'
});

那 Reducer 負責什麼呢?

負責計算新的 State

這個計算的過程就叫 Reducer。

它需要兩個引數:

  • PreviousState,舊的State
  • action,行為

工作流如下
image.png

簡單走一下工作流:

1.使用者透過 View 發出 Action:
store.dispatch(action);

2.然後 Store 自動呼叫 Reducer,並且傳入兩個引數:當前 State 和收到的 Action。Reducer 會返回新的 State 。

let nextState = xxxReducer(previousState, action);

3.Store 返回新的 State 給元件

// 或者使用訂閱的模式
let newState = store.getState();
component.setState(newState);   

總結

FluxRedux
store單store多store
dispatcher無,依賴reducer來替代事件處理器,store和dispatcher合併

這兩種模式剛好對應著 angular 匹配的兩種狀態管理庫: @ngxs/store , @ngrx/platform

下面會提到。

其他的模式和庫

還有一些其他的庫, 比如 Vue 用的 Vuex , Mobx等。這裡就不一一介紹了。

Angular 需要狀態管理嗎

如果你是一個 Angular 的開發者,貌似沒有狀態管理框架也可以正常開發,並沒有發現缺什麼東西。那麼在 Angular 中如何管理前端狀態呢?

首先在 Angular 中有個 Service的概念。

Angular 區別於其他框架的一個最大的不同就是:基於 DI 的 service。也就是,我們基本上,不實用任何狀態管理工具,也能實現類似的功能。

比如,如下程式碼就很簡單地實現資料的管理:

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { User } from './type';

export interface UserState {
  users: User[];
}

@Injectable({
  providedIn: 'root'
})
export class UserStateService {
  userState$: Observable<UserState>;

  private _userState$ = new BehaviorSubject<UserState>(null);
  constructor() {
    this.userState$ = this._userState$.asObservable();
  }

  get userState(): UserState {
    return this._userState$.getValue();
  }

  addUser(user: User): void {
    const users = [
      ...this.userState.users,
      user
    ];

    this._userState$.next({
      users
    })
  }
}

這樣的狀態流其實也很清晰,簡單易維護,基本上不需要複雜的狀態管理框架。


假如我們作為大型專案的 angular 開發者

如果我們想引入一個簡單的第三方狀態管理框架統一管理起來呢,應該選什麼庫?

Redux 是React社群提供的。angular 能用,但是總感覺是為了Redux而生的。
Vuex 是Vue 特有的狀態管理庫。
MobX 在大型專案中,程式碼難以維護

實際上,我們搜尋與 angular 匹配的狀態庫,大多數都是這兩種:

其中, @ngrx/platform 是Angular 的Redux 執行, 也就是說,和 Redux 很像。

而 @ngxs/store 更像是 Flux

為了進一步瞭解兩個狀態管理庫,下面我簡單對這兩個管理庫,嘗試寫了一些demo。

@ngrx/platform

NgRx 是在 Angular 中使用 RxJS 的 狀態管理庫,靈感來自 Redux(React 陣營被應用最廣泛的狀態管理工具),官方文件

實際上, Ngrx 比原生的 Redux 概念更多一些,比如 effect等。

工作流如下:
image.png

下面我們透過寫一個demo,理解一下工作流。

假設有以下使用場景:

假設我們把 民族 這個欄位單獨拿出來建立一個表,而這個表交給使用者維護,可以點 + 號來彈窗新增民族。
此時我們希望:彈窗新增後的民族,能夠實時的更新到 民族列表 元件中。

示例

1.定義實體

export interface Nation {
  id: number;
  name: string;
}

2.定義actions

// 定義獲取 nation 列表 的三種行為,獲取,獲取成功,獲取失敗
export const GET_NATIONS = '[Nation] GET_NATIONS';
export const GET_NATIONS_SUCCESS = '[Nation] GET_NATIONS_SUCCESS';
export const GET_NATIONS_ERROR = '[Nation] GET_NATIONS_ERROR';

/**
 * get All
 */
export class GetAllNations implements Action {
  readonly type = GET_NATIONS;
}

export class GetAllNationsSuccess implements Action {
  readonly type = GET_NATIONS_SUCCESS;

  constructor(public payload: Nation[]) {
  }
}

export class GetAllNationsError implements Action {
  readonly type = GET_NATIONS_ERROR;

  constructor(public payload: any) {
  }
}

3.定義Reducer
當收到 上面定義的 三種 actions 時,透過 swith case, 分別進行不同的處理, 更新State

// 定義 State介面
export interface State {
  data: Nation[];
  selected: Nation;
  loading: boolean;
  error: string;
}
// 初始化資料
const initialState: State = {
  data: [],
  selected: {} as Nation,
  loading: false,
  error: ''
};

export function reducer(state = initialState, action: AppAction ): State {
  switch (action.type) {
    case actions.GET_NATIONS:
      return {
        ...state,
        loading: true
      };
    case actions.GET_NATIONS_SUCCESS:
      return {
        ...state,
        data: action.payload,
        loading: true
      };
    case actions.GET_NATIONS_ERROR:
      return {
        ...state,
        loading: true,
        error: action.payload
      };
  }
  return state;
}

/*************************
 * SELECTORS
 ************************/
export const getElementsState = createFeatureSelector<ElementsState>('elements');

const getNationsState = createSelector(getElementsState, (state: ElementsState) => state.nation);

export const getAllNations = createSelector(getNationsState, (state: State) => {
    return state.loading ? state.data : [];
  }
);
export const getNation = createSelector(getNationsState, (state: State) => {
  return state.loading ? state.selected : {};
});

4.定義service

@Injectable()
export class NationService {
  protected URL = 'http://localhost:8080/api/nation';

  constructor(protected http: HttpClient) {
  }
  // 獲取列表
  public findAll(params?: any): Observable<Nation[]> 
    return this.http.get<Nation[]>(this.URL, {params});
  }
  // 新增
  public save(data: Nation): Observable<Nation> {
    let headers = new HttpHeaders();
    headers = headers.set('Content-Type', 'application/json; charset=utf-8');
    
    return this.http.post<Nation>(this.URL, data, {headers});
  }
}

5.定義Effect,用於偵聽 ation, 並呼叫api

@Injectable()
export class NationEffect {
  constructor(private actions$: Actions,
              private nationsService: NationService) {
  }

 // 偵聽 GET_NATIONS, 並呼叫獲取列表的service方法
  getAllNations$ = createEffect(() => {
    return this.actions$.pipe(
      tap((data) => console.log(data)),
      ofType(actions.GET_NATIONS),
      switchMap(() => this.nationsService.findAll()),
      map(response => new GetAllNationsSuccess(response)),
      catchError((err) => [new GetAllNationsError(err)])
    );
  });
}
  // 偵聽 CREATE_NATION, 並呼叫新增的service方法
  createNations$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(actions.CREATE_NATION),
    map((action: AddNation) => action.payload),
    switchMap(newNation => this.nationsService.save(newNation)),
    map((response) => new AddNationSuccess(response.id)),
    catchError((err) => [new AddNationError(err)])
  );
  });
  1. 元件使用
@Component({
  selector: 'app-index',
  templateUrl: './index.component.html',
  styleUrls: ['./index.component.css']
})
export class IndexComponent implements OnInit {
  nations: Nation[] | undefined;

  show = false;

  model = {} as Nation;

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

  ngOnInit(): void {
    // 初始化,執行 GetAllNations aciton,並分發
    this.store.dispatch(new GetAllNations());
    // 透過selector,獲取 state中的資料
    this.store.select(getAllNations).subscribe((data) => {
      this.nations = data;
    });
  }


  save(): void {
    if (confirm('確認要新增嗎?')) {
      const nation = this.model;
      // 執行新增 action
      this.store.dispatch(new AddNation(nation));
      this.show = false;
      this.model = {} as Nation;
    }
  }

}

全域性的Store只有一個, 那我們怎麼知道去哪個 State中獲取呢?

答案是透過 selector進行選擇。

7.定義selector

/**
 * nation的selector, 獲取nation的 state
 */
export const getElementsState = createFeatureSelector<ElementsState>('elements');

const getNationsState = createSelector(getElementsState, (state: ElementsState) => state.nation);

export const getAllNations = createSelector(getNationsState, (state: State) => {
    return state.loading ? state.data : [];
  }
);
export const getNation = createSelector(getNationsState, (state: State) => {
  return state.loading ? state.selected : {};
});

走一遍工作流

image.png

1.元件執行 GetAllNations 的action,獲取資料列表

ngOnInit(): void {
    this.store.dispatch(new GetAllNations());
 }

2.reducer 獲取到 action, 返回 State, 這時候 State剛初始化,資料為空。

 switch (action.type) {
    case actions.GET_NATIONS:
      return {
        ...state,
        loading: true,
      };
}

3.同時 Effect 也獲取到該 action

向service請求,獲取後臺資料後,執行 GetAllNationsSuccess action,表示成功獲取到後臺資料。

getAllNations$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(actions.GET_NATIONS),
      switchMap(() => this.nationsService.findAll()),
      map(response => new GetAllNationsSuccess(response)),
      catchError((err) => [new GetAllNationsError(err)])
    );
});

4.reducer 獲取到 GetAllNationsSuccess action

將後臺返回的資料更新到 State中。

case actions.GET_NATIONS_SUCCESS:
      return {
        ...state,
        data: action.payload,
        loading: true,
      };

5.元件利用 selector 獲取 State 中的資料

this.store.select(getAllNations).subscribe((data) => {
      this.nations = data;
});

這時候,就完成了對 State 的訂閱,資料的更新能夠及時地收到。

從而實現了單一資料流和單向資料流。

新增操作也是類似的原理,可檢視我寫的 demo: https://stackblitz.com/edit/node-zpcxsq?file=src/app/nation/i...

我的感受

參考了幾個谷歌上的demo寫的。 實際使用起來感覺很臃腫。幾點感覺:

1.對於一種aciton,比如獲取資料列表,需要定義三種aciton, 分別是獲取、獲取成功、獲取失敗。

2.reducer 裡大量使用了switch case, 頁面程式碼很長。

3.需要定義Effect, 資料流多了一層,感覺不便。

4.程式碼需要有一定理解,新手上手會有點懵。

當然也有可能因為我沒有對比多個狀態管理庫。下面我再嘗試一下ngxs/Stroe.

ngxs/Stroe

待更新。。。。


參考:
https://zhuanlan.zhihu.com/p/140073055
https://cloud.tencent.com/developer/article/1815469
http://fluxxor.com/what-is-flux.html
https://zhuanlan.zhihu.com/p/45121775
https://juejin.cn/post/6890909726775885832

相關文章