更好用的 Redux

本文是 《使用 RxJS + Redux 管理應用狀態》系列第三篇文章,將介紹咱們在使用 Redux 時的困惑,如何從新思考 Redux 定下的範式,以及咱們能爲此作出的努力。返回第一篇:使用 redux-observable 實現組件自治html

本系列的文章地址彙總:前端

爲何咱們須要 Redux?

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

目前,你們都比較承認的是,某一時刻的應用或者組件狀態,將對應此時應用或者組件的 UI:git

UI = f(state)
複製代碼

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

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

組件所具備的狀態,一搬來源於兩個方面:編程

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

狀態源爲組件輸送了其須要的狀態,進而,組件的外觀形態也獲得了確認。在簡單工程和簡單組件中,咱們思考了狀態來源也就好了,若是引入額外的狀態管理方案(例如咱們爲一個使用 Redux 管理一個按鈕組件的狀態),反而會加劇每一個組件的負擔,形成了多餘的抽象和依賴。redux

而對於大型前端工程和複雜組件來講,其每每具備以下特色:後端

  1. 數據複雜
  2. 組件豐富

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

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

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

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

在 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. 如何更優雅地處理異步任務

組件的樣子

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

即:數據經處理造成頁面狀態,頁面狀態決定 UI 渲染

應用的樣子

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

這裏組件生態特地只展現了數據到狀態這一步,由於 Redux 處理的正是這個部分。咱們暫且能夠定義數據到狀態的過程爲 flow,即一個業務流的意思。

應用劃分

借鑑於 Elm,咱們能夠按數據模型對應用進行劃分:

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

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

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

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

約定 —— 打掉樣板代碼

有了模型這個概念後,框架就能定義一系列的約定去減小樣板代碼的書寫。首先,咱們回顧下之前咱們是怎麼定義的一個 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。

處理加載態與錯誤態

前端工程中常常會有錯誤展現和加載展現的需求,

若是咱們手動管理每一個模型的加載態和錯誤態就太麻煩了,所以在根狀態下,單獨劃分兩棵狀態子樹用於處理加載態與錯誤態,這樣,便於框架去治理加載與錯誤,開發者直接在狀態樹上取用便可:

  • loading
  • error

如圖,加載態和錯誤態還須要根據粒度進行劃分,有大粒度的 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 的開源。
  • 感謝小雨的設計支援。
相關文章
相關標籤/搜索