Redux的前世-今生-來世

Functional_Labs發表於2019-03-03

這不是原始碼解讀哦!!!如果你希望看到原始碼解析,那我想你隨便 google 一下就有很多啦,當然 Redux 的原始碼本身也是簡單易懂。推薦直接閱讀~ 的原始碼本身也是簡單易懂,歡迎直接檢視原始碼

作者: 趙瑋龍

終於要更新了,第二篇文章就這樣在清明節前給大家趕出來,希望你也能有個可以充實自己的假期。這次分享下這個已經很老的前端技術棧(相對於前端發展速度來看),說他老並沒有說他的設計理念老,而是說它已經有自己的歷史印記了。還記得第一次redux發版已經是2015年6月的事情了,其實它也經歷了很多過往才是現在我們看到的樣子哦。。。

在深挖歷史之前先看看這個 lib 是幹嘛的,是時候仔細研讀下這個 Motivation 了,如果你還沒有真正使用過Redux,那我建議你可以看下文件前四章節: 動機,原理,三個原則,以及和其他理念的對比~

如果你已經使用過可以直接跳過到正片了,沒有用過的可以聽我簡單在這裡囉嗦兩句~ 按照作者的理念,因為我們日漸複雜的前端邏輯和程式碼,並且越來越多的框架和庫使用這個概念

  UI=f(data)
複製程式碼

包括前端spa的流行我們日漸複雜的專案邏輯會有越來越難控制的 state, 也就是公式中的 data,而往往這些問題都來源於 data 本身可變的資料(mutation)和非同步化(asynchronicity),作者把這兩種問題的混合效應類比了這個實驗曼妥思和可樂,我在我家廁所試過。。勸你千萬不要嘗試。。。結局往往是爆炸的局面!!!那麼這個lib就是用來規約這個狀態讓他可控的。(往往有人說Redux是一個全域性狀態管理模組,我個人覺得不盡然,它提供給我們所謂的規約以至於讓我們的狀態可控,它確實會維護一個唯一的狀態state,並且所有的data都在這裡,但是它是不是全域性的卻不是必須的!)

說完它本身的立意我們來談談它的心路歷程:

前世:

從 release 最早的0.2.0到現在的4.0.0我們可以看到作者 Dan gaearon(也是我個人比較欣賞的開源作者之一)的心路歷程,最早的 Redux 可不是現在的樣子哦(雖然我也是從3.1.0才開始使用~)

最早的版本(遙想那時候 React 正在倡導自己的 Flux 單向資料流, Github 也有各種自己基於這個理念實現的型別庫) Redux 也是基於 Flux 理念去實現的,在1.0.0之前 Redux 本身還涵蓋了如今 react-redux 庫的內容,自行封裝了類似於 Connector, Provider 等高階元件去完成 Redux 和 React 的銜接。(作者的目的也顯而易見,希望你能無痛的在 React 中去使用 Redux)。那時候的 Flux 庫大多高舉 functional programming 的大旗,因為那時候這種向函數語言程式設計借鑑的 Flux 概念本身也是這麼想的,我們前面提到過讓一切資料流向包括邏輯可控,這恰恰是 functional programming 裡 prue function 的概念,這也和 React 當年做jsx語法的初衷一致。但是一旦丟擲這樣的理論就需要你的受眾群體去接受這個概念

+--------+              +------------+            +-------+            +----+
| Action | +----------->+ Dispatcher +----------->+ Store +----------->+View|
+--------+              +-------+----+            +-------+            +-+--+
                                ^                                        |
                                |                                        |
                                |                                        |
                                |                                        |
                                +----------------------------------------+
複製程式碼

我們可以發現資料流向是單向的,這就是 Flux 核心理念,而 Redux 確實是遵循了這個理念但是又有些不同,不同在哪呢?

+----------+
|          |                +-----------+       sliceState,action        +------------+
|  Action  |dispacth(action)|           +-------------------------------->            |
|          +---------------->   store   |                                |  Reducers  |
| Creators |                |           <--------------------------------+            |
|          |                +-----+-----+    (state, action) => state    +------------+
+-----^----+                      |
      |                           |
      |                           |subscribe
      |                           |
      |                           |
      |                           |
      |              +--------+   |
      +--------------+  View  <---+
                     +--------+

複製程式碼

我們可以從圖中看到沒有了 Dispatcher 反而多了一個 Reducers,這裡不得不提一個點就是所有的 Flux 架構都圍繞著所謂 Predictable(可預測的) 的概念來維護 state, 那麼如何做到可預測的也就是我們必須保證我們的 state Immutable(資料不可變), Flux 裡依靠 dispatcher 來分發保證 Entity 的不可變性,而 Redux 中是依靠 pure function 的概念來保證每次的 state 都是原始 state 的一個快照, 也是這個核心公式的實踐 (state, action) => state 這樣你的 Reducers 其實就是這樣的任意多個 function,如何拆分這些 function 就是你需要考慮的事情了。而如果是 pure function 的話也利於我們去做函式複用和單元測試。這就是 Redux 向函數語言程式設計的概念借鑑的理念, 如果你熟悉 Elm 你一定知道 Model 的概念,要更新一個 Model 並且對映到 view 上你需要有 updater 去更新你的 Model,這裡 Redux 借鑑了 updater 的概念去做 reducers 拆分和複用。如果你對 Elm 也感興趣可以看這裡

其實函數語言程式設計的理念也貫穿到了原始碼中,比如裡面 compose 和 middleware 的實現,這些你都可以參考原始碼,有意思的是其實縱使連原作者在一些函數語言程式設計概念上也會有沒意識到的地方,在一些實現上也遵循了一些pr的意見,比如 compose 的實現:

Redux的前世-今生-來世

從最早期的 reduceRight 改成 reduce 這點就能發現,迭代了三個大版本和多個小版本的作者依然沒有意識到從右向左執行函式竟然可以不用 reduceRight,感興趣的同學可以試驗下,我當時看到這個pr也是驚訝這個提出者的 Lisp 或者 Haskell 功底啊,才能有這樣的直覺!! (其實函數語言程式設計確實是可以鍛鍊邏輯思維模式和你的數學意識,但是真的僅此而已,並不會在所謂效能和可讀性上帶來什麼明顯提升) 為了功能的完整和解耦性,之前的版本嚴重耦合 React 也做出了調整,把上面提到的通訊高階元件單獨提到 react-redux 庫單獨維護了,這樣 Redux 本身也更加純淨的做狀態管理這件事。

今生:

在回顧了前世之後,我們來看看如今的 Redux, 在基於多個版本的迭代和大家的實踐過後,無論是從概念本身還是從最佳實踐的案例來看,包括 Github 上一些基於 Redux 做的封裝都已經有了預設的最佳實踐和使用規範,那我們來看看今天的 Redux 本身使用的場景和方式。 從上面的理念我們看出來如何拆分 reducer 和維護那個單一不可變 state 是我們使用 redux 最應該關注的事情。 我們下面主要說下在 React 中使用 Redux 的最佳實踐方式: (現實應用場景中,我們如今大多數人應該還是使用 Redux+React 的開發方式, 如果你還是對於 Redux 是個初學者那麼你應該看這裡)。 為了討論的具有一定的官方性,我們按照官方文件來看下(我會在我認為比較個人的想做出備註和闡述), 著重討論以下三方面:

  • 管理和維護唯一狀態 state
  • 如何拆分 reducer
  • 非同步狀態處理

為什麼先說 reducer 呢? 因為其實我們的 state 都是 reducer 組成的, 上面那張圖可以看出 (state, action) => state 是計算出 state 的規約公式, createStore() 這個 api 也是接受你的 reducer 來生成 state 的。 我們先來看看最外層我們需要為 state 生成 Initinalizing state 方式:

// 官網說無非兩種方式
// 最外層你有一個reducer:
function rootReducer(state = 0, action) {  // 在你的createStore第二個引數沒有的情況下,你是需要給state一個預設值
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
}

// 通過官方提供的combineReducer去生成這個rootReducer, 其實你觀看原始碼的話這個方法return的還是一個 (state, action) => {}的函式
function a(state = 'zwl', action) { // 在你的createStore第二個引數沒有的情況下,你是需要給state一個預設值
  return state;
}
 
function b(state = 'zwt', action) { // 在你的createStore第二個引數沒有的情況下,你是需要給state一個預設值
  return state;
}

const rootReducer = combineReducers({ a, b })

複製程式碼

既然初始化我們看到上面提到的規約公式可以初始化你的 state, 另一個資料流向是反向的, reducer 會從你的 state 拿到需要處理的 sliceState,這裡就需要翻開書看看官方文件是怎麼提這個所謂 state 的正規化處理狀態的, 文件會從三個地方提到這個 state 本身的規約處理,分別是

當然我覺得作者已經說的很清楚了,文章尾部也給了很多連結,但是這裡還是有必要總結下這個規約的 state 正規化化大概應該有些什麼最佳實踐:

// 首先先看下這裡的 state 基本結構,當然文件中也沒有限制你,鼓勵你根據自己的業務形態去定製,但是卻是有些比較好的實踐方式
{
  visibilityFilter: 'SHOW_ALL',
  todos: [
    {
      text: 'Consider using Redux',
      completed: true,
    },
    {
      text: 'Keep all state in a single tree',
      completed: false
    }
  ]
}
// 區分領域的資料, 並且可能會有兩種非領域資料型別,一種是頁面上一些ui狀態比如一個 button 是否展示的 boolean 值,這時候你會發現所謂的 sliceState 可能就是一個 domainData 或者是它下面的一個更小的分支,這個是根據你的 reducer 拆分規則指定的, 但是你可以想象下如果你的 data 是單緯資料結構或者簡單資料結構,它就會非常好做邏輯計算,比如你有[a, b, c]單緯陣列就比[{},{},{}]要好刪查改除!
{
  domainData1: {},
  domainData2: {},
  appState1: {},
  appState2: {},
  ui: {
    uiState1: {},
    uiState2: {},
  }
}
// 經過網路上一些經驗包括筆者自己的經驗,你的基本資料型別往往會遵循一個資料原則為了儘可能維護最小的單元的資料,資料共享的部分會放在一起維護,至於如何正規化化這個 state 後面也會提到

{
  domainData1: {},
  domainData1ID: [],
  domainData2: {},
  domainData2ID: [],
  entites:{             //這裡存放你需要共享資料的部分,但是僅僅是例項, 這裡的例項的引用往往放在外面, 遵循的原則是例項和引用分開並且如果例項裡有
                        //引用domainData1裡的東西那麼其實引用的也是id,你會存一個引用的id進去
    commonData1: {},
    commonData2: {},
  },
  commonData1ID: [],
  commonData2ID: [],
  ui: {
    uiState1: {},
    uiState2: {},
  }
}

複製程式碼

下面我來說下正規化化 state 這個問題:

// 文件中列舉了一個部落格資料的例子(當然其實這個資料結構已經挺複雜的了)
const blogPosts = [
  {
    id: "post1",
    author: {username: "user1", name: "User 1"},
    body: "......",
    comments: [
      {
        id: "comment1",
        author: {username: "user2", name: "User 2"},
        comment: ".....",
      },
      {
        id: "comment2",
        author: {username: "user3", name: "User 3"},
        comment: ".....",
      }
    ]    
  },
  {
    id: "post2",
    author: {username : "user2", name : "User 2"},
    body: "......",
    comments: [
      {
        id: "comment3",
        author: {username : "user3", name : "User 3"},
        comment: ".....",
      },
      {
        id: "comment4",
        author: {username : "user1", name : "User 1"},
        comment: ".....",
      },
      {
        id: "comment5",
        author: {username : "user3", name : "User 3"},
        comment: ".....",
      }
    ]    
  }
  // 重複很多遍
]
// 其實這裡我們可以想象一下,如果我們需要更新這個資料結構,假如說直接把這個資料掛在 state 上。 那就會出現這種情況的程式碼[...state, {...slice[comments], ...sliceUpdater}]或者巢狀更深的更新方式,首先我們知道無論是擴充套件運算子和Object.assign都是淺拷貝,我們往往需要對巢狀結構每一個層級都去更新,如果運算元據結構就更加不方便了我們需要根據每個層級找到相應巢狀比較深的資料結構然後進行操作。這也就是為什麼我前面說我們儘量維持單維度的資料結構原因

// 文件中建議我們拍平資料後得到這樣的資料結構
{
  posts: {
    byId: {
      "post1": {
        id: "post1",
        author: "user1",
        body: "......",
        comments: ["comment1", "comment2"]    
      },
      "post2": {
        id: "post2",
        author: "user2",
        body: "......",
        comments: ["comment3", "comment4", "comment5"]    
      }
    }
    allIds: ["post1", "post2"]
  },
  comments: {
    byId: {
      "comment1": {
        id: "comment1",
        author: "user2",
        comment: ".....",
      },
      "comment2": {
        id: "comment2",
        author: "user3",
        comment: ".....",
      },
      "comment3": {
        id: "comment3",
        author: "user3",
        comment: ".....",
      },
      "comment4": {
        id: "comment4",
        author: "user1",
        comment: ".....",
      },
      "comment5": {
        id: "comment5",
        author: "user3",
        comment: ".....",
      },
    },
    allIds: ["comment1", "comment2", "comment3", "commment4", "comment5"]
  },
  users: {
    byId: {
      "user1": {
        username: "user1",
        name: "User 1",
      }
      "user2": {
        username: "user2",
        name: "User 2",
      }
      "user3": {
        username: "user3",
        name: "User 3",
      }
    },
    allIds: ["user1", "user2", "user3"]  // 這裡官方推薦把相應的id放在層級內。這個地方其實都可以也可以像我前面提到的放在users平級的地方,這個取決你的專案具體而定
  }
}
// 可以發現不同資料之間都被打成平級的關係,不需要去處理深層巢狀結構的問題,在給定的ID裡去刪查改除都比較方便!這裡更新的話也是不會波及到別的 domainComponent 比如我們只是更新 users 裡的資訊只需要去更新 users > byId > user 這部分去做淺複製,它不會像上面那種巢狀資料結構整體更新影響別的相應渲染元件也去更新,這裡其實還有一個優化點我們後面會說,就是我們在選擇這個 sliceState 的時候, 從選擇的 selector 不做重複運算。
複製程式碼

這裡拍平方式建議採用Normalizr自己寫也不是不行,但是情況會比較多,這個第三方庫還是能比較好的解決這個問題。這裡再提一句這個 Normalizer 有一個 denormalize 方法便於你把 normaliz 的資料結構給裝回去。是不是感覺有點像正規化資料庫裡的 join 表的過程呢? 如果你熟悉正規化化資料庫設計,你可能覺得這有一點點正規化化資料庫的概念,只不過這裡確實是沒有嚴格的定義必須遵循第幾正規化設計,它最重要的是你需要找到適合你的正規化結構,這裡作者也在文件中去給出一些連結(當然你沒必要先去學習資料庫的概念)可以簡單瞭解下這些概念,包括多對多資料庫設計:

既然前面提到 sliceState 需要有個 selector,從 state 中選擇相應的 slice 這個分片(這裡順便把前面提到的小優化不需要做重複運算的 selector 也提一下,這裡會用到這個):

// 首先你的sliceState需要去state選擇相應的分片大多時候你都是
const usersSelector = state.users
const commonsSelector = state.commons
// 但是你會發現有些值是通過兩個selector計算而來的,我們就拿reselect官網的第一個例子來看下
import { createSelector } from 'reselect'

const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((acc, item) => acc + item.value, 0)
)

const taxSelector = createSelector(
  subtotalSelector,
  taxPercentSelector,
  (subtotal, taxPercent) => subtotal * (taxPercent / 100)
)

export const totalSelector = createSelector(
  subtotalSelector,
  taxSelector,
  (subtotal, tax) => ({ total: subtotal + tax })
)

let exampleState = {
  shop: {
    taxPercent: 8,
    items: [
      { name: 'apple', value: 1.20 },
      { name: 'orange', value: 0.95 },
    ]
  }
}

console.log(subtotalSelector(exampleState)) // 2.15
console.log(taxSelector(exampleState))      // 0.172
console.log(totalSelector(exampleState))    // { total: 2.322 }

// 這裡使用reselect的作用是如果下次傳入的shopItemsSelector,taxPercentSelector 並沒有改變那麼這個selector不會重新計算,這個大家有興趣可以看下原始碼,本身原始碼也不多很容易看完!

複製程式碼

上面概念裡提到了 selector 和 state 也能多少看到 state 本身只是可讀(read only)並不可修改, 下面我來說下我們的函式 reducer 如何拆分,它的規約又是如何的(官方說有以下幾種 reducer):

  • reducer
  • root reducer
  • slice reducer
  • case function
  • higher-order reducer

具體定義你可以參考這裡, 在我看來也不盡然非要分的這麼細,函式主要的作用還是幫我們拆分邏輯以及能達到複用的效果,所以拆分 reducer 才是核心的概念。 具體的拆分邏輯可以參考這裡,我這裡就不班門弄斧了,文件的案例足夠清楚了。 這應該是我看到最上心的文件了。不得不說作者是一個用心且勤奮的人!!!

我們這裡就說一些特殊場景的 reducer 如何處理,當然文件裡還是說了在這裡如何處理需要跨分片資料的 reducer,通俗點講就是我們需要 sliceStateA 的 reducer 需要處理 sliceStateB 裡的資料:

// 第一種方式
// (還記得我們開頭說的 Initinalizing state 的方式嗎? 下面兩種方式就是利用這點)
function combinedReducer(state, action) {      // 在 root 層去拿最外面的 state 把相應需要的 sliceState 傳給相應需要的 reducer
  switch(action.type) {
    case "A_TYPICAL_ACTION": {
      return {
        a: sliceReducerA(state.a, action),
        b: sliceReducerB(state.b, action)
      };
    }
    case "SOME_SPECIAL_ACTION": {
      return {
        // 明確地把 state.b 作為額外引數進行傳遞
        a: sliceReducerA(state.a, action, state.b),
        b: sliceReducerB(state.b, action)
      }        
    }
    case "ANOTHER_SPECIAL_ACTION": {
      return {
        a: sliceReducerA(state.a, action),
        // 明確地把全部的 state 作為額外引數進行傳遞
        b: sliceReducerB(state.b, action, state)
      }         
    }    
    default: return state;
  }
}

// 第二種方式
const combinedReducer = combineReducers({
  a: sliceReducerA,
  b: sliceReducerB
});
 
function crossSliceReducer(state, action) {
  switch(action.type) {
    case "SOME_SPECIAL_ACTION": {
      return {
          // state.b是額外的引數
          a: handleSpecialCaseForA(state.a, action, state.b),
          b: sliceReducerB(state.b, action)
      }        
    }
    default: return state;
  }
}
 
function rootReducer(state, action) {
  const intermediateState = combinedReducer(state, action);
  const finalState = crossSliceReducer(intermediateState, action);
  return finalState;
}

// 這都是官方推薦的方法, 但是你會發現萬變不離其中,都需要從根部 root 去拿 state 達到共享資料的方式,並且無論是 combineReducers 還是 function 的方式都是要 Initinalizing state 的
複製程式碼

最後再來簡單討論下非同步化的問題,首先在早期 Redux 版本原始碼裡是兼顧了非同步方案的,就是我們所熟悉的 redux-thunk 當然跟 react-redux 被整理出來單獨作為專案一樣的,它也被單獨整理出來只是在文件中提及了一下。其實市面上的基於 Redux 非同步解決方案也非常多,解決不同場景的 redux-thunk 應該就夠了,但是還有很複雜的請求場景可能就需要下面兩個現在比較流行的庫去解決:

針對兩個方案沒有好壞之分,首先他們都解決了同樣的問題,但是兩個理念完全不一樣。

第一種方案

用 Generator 去解決非同步問題並且自己定義了很多 api 便於你解決各種複雜場景的非同步問題 例如: [call, put, cancel,...] 很多種方法,關於這個 redux-saga 文論是官方文件還是網路上的各種教程已經很多我就不在這廢話了。

第二種方案

採用了 rx.js 的方式去解決非同步問題,而 rx.js 這個庫主要是 reactive programming 一種實現,它屬於 reactivex 其中一個分支利用流概念解決非同步程式設計問題。這是一個非常大的話題,我們有機會也會開專題來討論下這個 rx.js。雖然學習它本身會比 redux-saga 有更多的 api,可能還有一堆之前沒有接觸過的概念需要理解。但是就面向未來可能性上學習 rx.js 本身的價值肯定會比 redux-saga 要有用的多。

不過筆者也會根據業務和團隊來決定這個問題比較合理,如果算上學習成本和開發成本可能本身 redux-saga 更加適合大型專案和多人維護團隊。所以具體哪種方式更加適合你,就由你來定啦!

最後來看下官方推薦的一些專案目錄做法,你在這裡也能看到比較全的做法! 我比較推薦第一種做法: 分別定義 actions, reducers(裡面有相應的 selector), constants(actionTypes), components, containers 這樣我覺得比較清晰。 說了這麼多現在成熟的最佳實踐。是不是該暢想下未來呢?

來世:

其實我在上一篇文章中也提到了 React 本身的核心理念應該是會相容單向資料流的方式(因為新的 context api 的存在!) 如果你不熟悉這個 context 可以參考React blog 這裡我只是暢想下,僅代表個人觀點,不能代表未來任何發展趨勢。

// 上一篇文章我們利用 context 去實現 react-redux 的時候我們利用 context 傳遞了 redux 本身的 store,具體的 provider 和 connect 可以參考上一篇文章
// 我們自己實現的 store應該是這樣的。(全部憑自我意淫。。。可以看個思路)

export const makeStore = (store) => {
  let subscriptions = []
  const Context = createContext()

  const getState = () => store.initialState ?  store.initialState : {}  //  拿到當前的 state

  const subscribe = fn => {
    subscriptions = [...subscriptions, fn]
  }

  // 這裡把 Provider 和 Connect 拿進來,他們倆分別使儲存這裡 store 和把 mapStateProps 以及 actions 傳遞進去
  class Privider...  // 一個維護Context.Provider 負責傳遞 store,更改store
  class Connect...  // 一個負責消費的Context.Cousumer 傳遞給你的元件相應的state,和actions
  // 這裡我還沒想好如何維護整體程式碼結構。。
}

複製程式碼

在我準備發文章的時候,已經有人完成了這類,那我就只能安利一波了。希望大家能看到一個方向而不是全盤否決 Redux。 因為畢竟現在我們還沒有真正做好代替它的準備,而且我相信你如果真的要代替的話,在現有的專案和新專案可能都會有不少坑,不過俗話說得好不踩坑怎麼進步呢?(歡迎大家多多踩坑哈哈哈哈!!!!)

寫在最後的話:

我們經歷一門技術也好,經歷一個技術時代革新也罷。其實往往最重要的是過程,如果我們忽略過程只在乎結果那麼一切好像都是沒有調味的菜----索然無味了,再回歸到 Redux 本身,它給我們帶來的最多的是一種規約(如果你跟著文章讀下來你應該會體會到!),如何在如今多人團隊的專案中儘量增加可讀性和提高維護成本,也是工程化歷來探討的主題。當然所謂的最佳實踐也不過是我們真正實踐過後從無論是後端也好別的行業也好借鑑那些我們真正有用的知識加以改造。所謂觸類旁通的重要性吧!最後期望讀者還能繼續關注我的個人更新以及團隊更新!!!願在技術的浪潮中我們共勉前行。

相關文章