更好用的 Redux

吳曉軍發表於2018-10-14

本文是 《使用 RxJS + Redux 管理應用狀態》系列第三篇文章,將介紹我們在使用 Redux 時的困惑,如何重新思考 Redux 定下的正規化,以及我們能為此做出的努力。返回第一篇:使用 redux-observable 實現元件自治

本系列的文章地址彙總:

為什麼我們需要 Redux?

首先要明確的是,Redux 並不是 React 獨有的一個外掛,它是順應前端元件化開發潮流而誕生的一種狀態管理模型,你在 Vue 或者 Angular 中也可以使用這個模型。

目前,大家都比較認可的是,某一時刻的應用或者元件狀態,將對應此時應用或者元件的 UI:

UI = f(state)
複製程式碼

那麼,在前端元件化開發的時候,就需要思考兩個問題:

  1. 狀態來源
  2. 狀態管理

元件所具有的狀態,一搬來源於兩個方面:

  1. 自身具有的狀態:例如一個 Button 元件自身含有一個計數狀態 count,表示自己被點選的次數。
  2. 外部注入的狀態:例如一個 Modal 元件,就需要由外部注入一個是否顯示的狀態 visible。React 將外部注入的狀態稱為 props

狀態源為元件輸送了其需要的狀態,進而,元件的外觀形態也得到了確認。在簡單工程和簡單元件中,我們思考了狀態來源也就行了,如果引入額外的狀態管理方案(例如我們為一個使用 Redux 管理一個按鈕元件的狀態),反而會加重每個元件的負擔,造成了多餘的抽象和依賴。

而對於大型前端工程和複雜元件來說,其往往具有如下特點:

  1. 資料複雜
  2. 元件豐富

在這種場景下,樸素的狀態管理就顯得捉襟見肘了,主要體現在下面幾個方面:

  1. 當元件層級過深時,如何優雅得呈遞元件需要的狀態,或者說元件如何更方便取得自己需要的狀態
  2. 如何回溯到某個狀態
  3. 如何更好的測試狀態管理

Redux 正是要去解決這些問題,從而讓大型前端工程的狀態更加可控。Redux 提出了一套約定模型,讓狀態的更新和派發都集中了:

更好用的 Redux

Redux 所使用的模型是受到了 Elm 的啟發:

更好用的 Redux

在 Elm 中,流動於應用中的是訊息(msg) :一個由**訊息型別(type)所標識,並且攜帶了內容(payload)**的資料結構。訊息決定了資料模型(model)怎麼更新,而資料又決定了 UI 形態。

而在 Redux 中,訊息被稱替代為動作(action),並且使用 reducer 來描述狀態隨行為的變遷。另外,與 Elm 不同的是,Redux 專注於狀態管理,而不再處理檢視(View),因此 ,Redux 也不是分型的(關於分型架構的介紹,可以看 的博文)。

在瞭解到 Redux 的利好,或者被 Redux 的流行所吸引後,我們引入 Redux 作為應用的狀態管理器,這讓整個應用的狀態變動都變得無比清晰,狀態在一條鏈路上湧動,我們甚至可以回到或者前進到某個狀態。然而,Redux 就真的完美無缺嗎?

不完美的 Redux

Redux 當然不完美,它最困擾我們的就是下面兩個方面:

  1. 囉嗦的樣板程式碼
  2. 低下的非同步任務處理能力

假定前端需要從服務端拉取一些資料並進行展示,在 Redux 的模式下,完成從資料拉取到狀態更新,就需要經歷:

(1)定義若干的 action type:

const FETCH_START = 'FETCH_START'
const FETCH_SUCCESS = 'FETCH_SUCCESSE'
const FETCH_ERROR = 'FETCH_ERROR'
複製程式碼

(2)定義若干 action creator,這裡假定我們使用 redux-thunk 驅動非同步任務:

const fetchSuccess = data => ({
  type: FETCH_START,
  payload: { data }
})

const fetchError = error => ({
  type: FETCH_ERROR,
  payload: { error }
})

const fetchData = (params) => {
  return (dispatch, getState) => {
    return api.fetch(params)
      .then(fetchSuccess)
    	.catch(fetchError)
  }
}
複製程式碼

(3)在 reducer 中,對不同 action type,通過 switch-case 宣告不同的狀態更新方式:

function reducer(state = initialState, action) {
  const { type, payload } = action
  switch(action.type){
    case FETCH_START: {
      return { ...state, loading: true }
    }
    case FETCH_SUCCESS: {
      return { ...state, loading: false, data: payload.data }
    }
    case FETCH_ERROR: {
      return { ...state, loading: false, data: null, error: payload.error}
    }
  }
}
複製程式碼

這個流程帶來的問題是:

  1. 個人開發不夠專注:工程中,我們是分散管理 action type、action 及 reducer 的,走完一套流程,需要在當中不停的跳躍,思路不夠集中。
  2. 多人協作不夠高效:同樣是因為 action type、action 及 reducer 的分散,多人協作時就會出現名字衝突,相似業務的流程重複等問題。這對我們的應用狀態設計提出了比較高的要求。優秀的設計是狀態易於定位,變遷流程清晰,無冗餘狀態,而低下的設計就會讓狀態膨脹難於定位,變遷流程錯綜複雜,冗餘狀態隨處可見。

怎麼用好 Redux

當我們受困於 Redux 的負面影響時,切到其他的狀態管理方案(例如 mobx 或者 mobx-state-stree),也不太現實,一方面是遷移成本大,一方面你也不知道新的狀態管理方案是否就是銀彈。但是,對 Redux 的負面影響無動於衷或者忍氣吞聲,也只會讓問題越滾越大,直到失控。

在開始討論如何更好地 Redux 之前,我們需要明確一點,樣板程式碼和非同步能力的缺乏,是 Redux 自身設計的結果,而非目的,換句話說,Redux 設計出來,並不是要讓開發者去撰寫樣本程式碼,或者去糾結怎麼處理非同步狀態更新。

我們需要再定義一個角色,讓他來代替我們去寫樣板程式碼,讓他給予我們最優秀的非同步任務處理能力,讓他負責一切 Redux 中惡心的事兒。因此,這個角色就是一個讓 Redux 變得更加優雅的框架,至於如何建立這個角色,需要我們從單個元件開始,重新梳理下應用形態,並著眼於:

  1. 如何打掉 Redux 的樣板程式碼
  2. 如何更優雅地處理非同步任務

元件的樣子

一個元件的生態大概是這樣的:

更好用的 Redux

即:資料經處理形成頁面狀態,頁面狀態決定 UI 渲染

應用的樣子

而元件生態(UI + 狀態 + 狀態管理方式)的組合就構成了我們應用:

更好用的 Redux

這裡元件生態特意只展示了資料到狀態這一步,因為 Redux 處理的正是這個部分。我們暫且可以定義資料到狀態的過程為 flow,即一個業務流的意思。

應用劃分

借鑑於 Elm,我們可以按資料模型對應用進行劃分:

更好用的 Redux

其中,模型具有的屬性有:

  • name: 模型名稱
  • state:模型的初始狀態
  • reducers:處理當前模型狀態的 state
  • selectors:服務於當前模型的 state selectors
  • flows:當前模型涉及的業務流(副作用)

這個經典的劃分模型正是 Dva 的應用劃分手段,只是模型屬性略有不同。

假定我們建立了 user 模型和 post 模型,那麼框架將掛載他們的狀態到 user 和 post 狀態子樹下:

更好用的 Redux

約定 —— 打掉樣板程式碼

有了模型這個概念後,框架就能定義一系列的約定去減少樣板程式碼的書寫。首先,我們回顧下以前我們是怎麼定義的一個 action type 的:

  • action 名稱
  • 指定一個 namespace 防止名字衝突

例如,我們這樣定義使用者資料拉取相關的 action type:

const FETCH = 'USRE/FETCH'
const FETCH_SUCCESS = 'USER/FETCH_SUCCESSE'
const FETCH_ERROR = 'USER/FETCH_ERROR'
複製程式碼

其中, FETCH 對應的是一個非同步 拉取資料的 action,FETCH_SUCCESSFETCH_ERROR 則對應兩個同步修改狀態的 action。

同步 action 約定

對於同步的、不包含副作用的 action,我們直接將其呈遞到 reducer,是不會破壞 reducer 純度的。 因此,我們不妨約定: model 下 reducer 的名字對映一個直接對狀態操作的 action type:

SYNC_ACTION_TYPE = MODEL_NAME/REDUCER_NAME
複製程式碼

例如下面這個 user model:

const userModel = {
  name: 'user',
  state: {
    list: [],
    total: 0,
    loading: false
  },
  reducers: {
    fetchStart(state, payload) {
      return { ...state, loading:true }
    }
  }
}
複製程式碼

當我們派發了一個型別為 user/fetchStart 的 action 之後,action 就帶著其 payload 進入到 user.fetchStart 這個 reducer 下,進行狀態變更。

非同步 action 約定

對於非同步的 action,我們就不能直接在 reducer 進行非同步任務處理,而 model 中的 flow 就是非同步任務的集裝箱:

ASYNC_ACTION_TYPE = MODEL_NAME/FLOW_NAME
複製程式碼

例如下面這個 model:

const user = {
  name: 'user',
  state: {
    list: [],
    total: 0,
    loading: false
  },
  flows: {
    fetch() {
      // ... 處理一些非同步任務
    }
  }
}
複製程式碼

如果我們在 UI 裡面發出了個 user/fetch,由於 user model 中存在一個名為 fetch 的 flow,那麼就進入到這個flow 中進行非同步任務的處理。

狀態的覆蓋與更新

如果每個狀態的更新都去撰寫一個對應的 reducer 就太累了,因此,我們可以考慮為每個模型定義一個 change reducer,用於直接更新狀態:

const userModel = {
  name: 'user',
  state: {
    list: [],
    pagination: {
      page: 1,
      total: 0
    },
    loading: false
  },
  reducers: {
    change(state, action) {
      return { ...state, ...action.payload }
    }
  }
}
複製程式碼

此時,當我們派發了下面的一個 action,就將能夠將 loading 狀態置為 true:

dispatch({
  type: 'user/change',
  payload: {
    loading: true
  }
})
複製程式碼

但是,這種更新是覆蓋式的,假定我們想要更新狀態中的當前頁面資訊:

dispatch({
  type: 'user/change',
  payload: {
    pagination: { page: 1 }
  }
})
複製程式碼

狀態就會變為:

{
  list: [],
  pagination: {
  	page: 1
  },
  loading: false
}
複製程式碼

pagination 狀態被整個覆蓋掉了,其中的總數狀態 total 就丟失了。

因此,我們還要定義一個 patch reducer,意為對狀態的補丁更新,它只會影響到 action payload 中宣告的子狀態:

import { merge } from 'lodash.merge'
const userModel = {
  name: 'user',
  state: {
    list: [],
    pagination: {
      page: 1,
      total: 0
    },
    loading: false
  },
  reducers: {
    change(state, action) {
      return {
        { ...state, ...action.payload }
      }
    },
    patch(state, action) {
      return deepMerge(state, action.payload)
    }
  }
}
複製程式碼

現在,我們嘗試只更新分頁:

dispatch({
  type: 'user/patch',
  payload: {
    pagination: { page: 1 }
  }
})
複製程式碼

新的狀態就是:

{
  list: [],
  pagination: {
  	page: 1,
    total: 0
  },
  loading: false
}
複製程式碼

注意:這裡的實現不是生產環境的實現,直接使用 lodash 的 merge 是不夠的,實際專案中還要進行一定改造。

非同步任務的組織

Dva 使用了 redux-saga 進行副作用(主要是非同步任務)的組織,Rematch 則使用了 async/await 進行組織。從長期的實踐來看,我更偏向於使用 redux-observable,尤其是在其 1.0 版本的釋出之後,更是帶來了可觀察的 state$,使得我們能更加透徹地實踐響應式程式設計。我們回顧下前文中提到的該模式的好處:

  • 統一資料來源,observable 之間可組合
  • 宣告式程式設計,程式碼直爽簡潔
  • 優秀的競態處理能力
  • 測試友好
  • 便於實現元件自治

因此,對於模型非同步任務的處理,我們選擇 redux-observable:

const user:Model<UserState> = {
  name: 'user',
  state: {
    list: [],
    // ...
  },
  reducers: {
    // ...
  },
  flows: {
    fetch(flow$, action$, state$) {
      // ....
    }
  }
}
複製程式碼

與 epic 的函式簽名略有不同的是,每個 flow 多了一個 flow$ 引數,以上例來說,它就相當於:

action$.ofType('user/fetch')
複製程式碼

這個引數便於我們更快的取到需要的 action。

處理載入態與錯誤態

前端工程中經常會有錯誤展示和載入展示的需求,

更好用的 Redux

如果我們手動管理每個模型的載入態和錯誤態就太麻煩了,因此在根狀態下,單獨劃分兩棵狀態子樹用於處理載入態與錯誤態,這樣,便於框架去治理載入與錯誤,開發者直接在狀態樹上取用即可:

  • loading
  • error

更好用的 Redux

如圖,載入態和錯誤態還需要根據粒度進行劃分,有大粒度的 flow 級別,用於標識一個 flow 是否正在進行中;也有小粒度的 service 級別,用於標識某個非同步服務是否在進行中。

例如,若:

loading.flows['user/fetch'] === true
複製程式碼

即表示 user model 下的 fetch flow 正在進行中。

若:

loading.services['/api/fetchUser'] === true
複製程式碼

即表示 /api/fetchUser 這個服務正在進行中。

響應式的服務治理

前端呼叫後端服務操縱資料是一個廣泛的需求,因此,我們還希望所謂的中間角色(框架)能夠在我們的業務流中注入服務,完成服務和應用狀態的互動:觀察呼叫狀況,自動捕獲呼叫異常,適時地修改應用 loading 態和 error 態,方便使用者直接在頂層狀態取用服務執行狀況。

另外,在響應式程式設計的正規化下,框架提供的服務治理,在處理服務的成功和錯誤時應該也是響應式的,即成功和錯誤將是預定義的流(observable 物件),從而讓開發者能更好的利用到響應式程式設計的能力:

const user:Model<UserState> = {
  name: 'user',
  state: {
    list: [],
    total: 0
  },
  reducers: {
    fetchSuccess(state, payload) {
      return { ...state, list: payload.list, total: payload.total }
    },
    fetchError(state, payload) {
      return { ...state, list:[] }
    }
  },
  flows: {
    fetch(flow$, action$, state$, dependencies) {
      const { service } = dependencies
      return flow$.pipe(
        withLatestFrom(state$, (action, state) => {
          // 拼裝請求引數
          return params
        }),
        switchMap(params => {
          const [success$, error$] = service(getUsers(params))
          return merge(
            success$.pipe(
              map(resp => ({
                type: 'user/fetchSuccess',
                payload: {
                  list: resp.list,
                  total: resp.total
                }
            	}))
            ),
            error$.pipe(
              map(error => ({
              	type: 'user/fetchError'
            	}))
            )
          )
        })
      )
    }
  }
}
複製程式碼

reobservable

上面的種種思考,概括下來其實就是 Dva architecture + redux-observable,前者能夠打掉 Redux 冗長囉嗦的樣板程式碼,後者則負責非同步任務治理。

比較遺憾的是,Dva 沒有使用 redux-observable 進行副作用管理,也沒有相關外掛實現使用 redux-observable 或者 RxJS 進行副作用管理,並且,通過 Dva 暴露的 hook 去實現一個 redux-observable 的 Dva 中介軟體也頗為不暢,因此,筆者嘗試撰寫了一個 reobservable 來實現上面提到框架,它與 Dva 不同的是:

  1. 只關注應用狀態,不涉及元件路由的其他生態
  2. 整合 loading 和 error 處理
  3. 使用 redux-observable 而不是 redux-saga 處理副作用
  4. 響應式的服務處理,支援應用自定義服務細節

如果你的應用使用了 Redux,你苦於 Redux 種種負面影響,並且你還是一個響應式程式設計和 RxJS 的愛好者,你可以嘗試下 reobservable。但是如果你偏愛 saga,或者 async await,你還是應該選擇 Dva 或者 Rematch,術業有專攻。

參考資料

關於本系列

  • 本系列將從介紹 redux-observable 1.0 開始,闡述自己在結合 RxJS 到 Redux 中的心得體會。涉及內容會有 redux-observable 實踐介紹,redux-observable 實現原理探究,最後會介紹下自己當前基於 redux-observble + dva architecture 的一個 state 管理框架 reobservable。
  • 本系列不是 RxJS 或者 Redux 入門,不再講述他們的基礎概念,宣揚他們的核心優勢。如果你搜尋 RxJS 不小心進到了這個系列,對 RxJS 和 FRP 程式設計產生了興趣,那麼入門我會推薦:
  • 本系列更不是教程,只是介紹自己在 Redux 中應用 RxJS 的一些思路,希望更多人能指出當中存在的誤區,或者交流更優雅的實踐。
  • 由衷的感謝實踐路上一些師兄的幫助,尤其感謝騰訊雲的 questguo 學長在模式上的指導。reobservable 脫胎於騰訊雲 questguo 主導的 React 框架 —— TCFF,期待未來 TCFF 的開源。
  • 感謝小雨的設計支援。

相關文章