【原創】Redux 卍解


Redux 卍解

ReduxFlux設計模式的又一種實現形式。

說起Flux,筆者之前,曾寫過一篇《ReFlux細說》的文章,重點對比講述了Flux的另外兩種實現形式:『Facebook Flux vs Reflux』,有興趣的同學可以一並看看。

時過境遷,現在社區里,Redux的風頭早已蓋過其他Flux,它與React的組合使用更是大家所推薦的。

Redux很火,很流行,並不是沒有道理!!它本身靈感來源於Flux,但卻不局限於Flux,它還帶來了一些新的概念和思想,集成了immutability的同時,也促成了Redux自身生態圈

筆者在看完reduxreact-redux源碼后,覺得它的一些思想和原理拿出來聊一聊,會更有利於使用者的了解和使用Redux。

:如果你是初學者,可以先閱讀一下Redux中文文檔,了解Redux基礎知識。)

數據流

作為Flux的一種實現形式,Redux自然保持着數據流的單向性,用一張圖來形象說明的話,可以是這樣:

redux-data-flow

上面這張圖,在展現單向數據流的同時,還為我們引出了幾個熟悉的模塊:Store、Actions、Action Creators、以及Views。

相信大家都不會陌生,因為它們就是Flux設計模式中所提到的幾個重要概念,在這里,Redux沿用了它們,並在這基礎之上,又融入了兩個重要的新概念:ReducersMiddlewares(稍后會講到)。


接下來,我們先說說Redux在已有概念上的一些變化,之后再聊聊Redux帶來的幾個新概念。

Store

Store — 數據存儲中心,同時連接着Actions和Views(React Components)。

連接的意思大概就是:

  1. Store需要負責接收Views傳來的Action
  2. 然后,根據Action.type和Action.payload對Store里的數據進行修改
  3. 最后,Store還需要通知Views,數據有改變,Views便去獲取最新的Store數據,通過setState進行重新渲染組件(re-render)。

上面這三步,其實是Flux單向數據流所表達出來的思想,然而要實現這三步,才是Redux真正要做的工作。

下面,我們通過答疑的方式,來看看Redux是如何實現以上三步的?


問:Store如何接收來自Views的Action?

答:每一個Store實例都擁有dispatch方法,Views只需要通過調用該方法,並傳入action對象作為形參,Store自然就就可以收到Action,就像這樣:

store.dispatch({
	type: 'INCREASE'
});

問:Store在接收到Action之后,需要根據Action.type和Action.payload修改存儲數據,那么,這部分邏輯寫在哪里,且怎么將這部分邏輯傳遞給Store知道呢?

答:數據修改邏輯寫在Reducer(一個純函數)里,Store實例在創建的時候,就會被傳遞這樣一個reducer作為形參,這樣Store就可以通過Reducer的返回值更新內部數據了,先看一個簡單的例子(具體的關於reducer我們后面再講):

// 一個reducer
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'INCREASE':
      return state + 1;
    case 'DECREASE':
      return state - 1;
    default:
      return state;
  }
}

// 傳遞reducer作為形參
let store = Redux.createStore(counterReducer);

問:Store通過Reducer修改好了內部數據之后,又是如何通知Views需要獲取最新的Store數據來更新的呢?

答:每一個Store實例都提供一個subscribe方法,Views只需要調用該方法注冊一個回調(內含setState操作),之后在每次dispatch(action)時,該回調都會被觸發,從而實現重新渲染;對於最新的Store數據,可以通過Store實例提供的另一個方法getState來獲取,就像下面這樣:

let unsubscribe = store.subscribe(() =>
  console.log(store.getState())
);

所以,按照上面的一問一答,Redux.createStore()方法的內部實現大概就是下面這樣,返回一個包含上述幾個方法的對象:

function createStore(reducer, initialState, enhancer) {
  var currentReducer = reducer
  var currentState = initialState
  var listeners = []
  
  // 省略若干代碼
  //...
  
  // 通過reducer初始化數據
  dispatch({ type: ActionTypes.INIT })

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer
  }
}

總結歸納幾點:

  1. Store的數據修改,本質上是通過Reducer來完成的。
  2. Store只提供get方法(即getState),不提供set方法,所以數據的修改一定是通過dispatch(action)來完成,即:action -> reducers -> store
  3. Store除了存儲數據之外,還有着消息發布/訂閱(pub/sub)的功能,也正是因為這個功能,它才能夠同時連接着Actions和Views。
    • dispatch方法 對應着 pub
    • subscribe方法 對應着 sub

Reducer

Reducer,這個名字來源於數組的一個函數 — reduce,它們倆比較相似的地方在於:接收一個舊的prevState,返回一個新的nextState

在上文講解Store的時候,得知:Reducer是一個純函數,用來修改Store數據的

這種修改數據的方式,區別於其他Flux,所以我們疑惑:通過Reducer修改數據給我們帶來了哪些好處?

這里,我列出了兩點:

  1. 數據拆解
  2. 數據不可變(immutability)

數據拆解

Redux有一個原則:單一數據源,即:整個React Web應用,只有一個Store,存儲着所有的數據。

這個原則,其實也不難理解,倘若多個Store存在,且Store之間存在數據關聯的情況,處理起來往往會是一件比較頭疼的事情。

然而,單一Store存儲數據,就有可能面臨着另一個問題:數據結構嵌套太深,數據訪問變得繁瑣,就像下面這樣:

let store = {
	a: 1,
	b: {
		c: true,
		d: {
			e: [2, 3]
		}
	}
};

// 增加一項: 4
store.b.d.e = [...store.b.d.e, 4]; // es7 spread

console.log(store.b.d.e); // [2, 3, 4]

這樣的store.b.d.e數據訪問和修改方式,對於剛接手的項目,或者不清楚數據結構的同學,簡直是晴天霹靂!!

為此,Redux提出通過定義多個reducer對數據進行拆解訪問或者修改,最終再通過combineReducers函數將零散的數據拼裝回去,將是一個不錯的選擇!

在JavaScript中,數據源其實就是一個object tree,object中的每一個key都可以認為是tree的一個節點,每一個葉子節點都含有一個value(非plain object),就像下面這張圖所描述的:

redux-node

而我們對數據的修改,其實就是對葉子節點value的修改,為了避免每次都從tree的根節點r開始訪問,可以為每一個葉子節點創建一個reducer,並將該葉子節點的value直接傳遞給該reducer,就像下面這樣:

// state 就是store.b.d.e的值
// [2, 3]為默認初始值
function eReducer(state = [2, 3], action) {
  switch (action.type) {
    case 'ADD':
      return [...state, 4]; // 修改store.b.d.e的值
    default:
      return state;
  }
}

如此,每一個reducer都將直接對應數據源(store)的某一個字段(如:store.b.d.e),這樣的直接的修改方式會變得簡單很多。

拆解之后,數據就會變得零散,要想將修改后的數據再重新拼裝起來,並統一返回給store,首先要做的就是:將一個個reducer自上而下一級一級地合並起,最終得到一個rootReducer

合並reducer時,需要用到Redux另一個api:combineReducers,下面這段代碼,是對上述store的數據拆解:

import { combineReducers } from 'redux';

// 葉子reducer
function aReducer(state = 1, action) {/*...*/}
function cReducer(state = true, action) {/*...*/}
function eReducer(state = [2, 3], action) {/*...*/}

const dReducer = combineReducers({
  e: eReducer
});

const bReducer = combineReducers({
  c: cReducer,
  d: dReducer
});

// 根reducer
const rootReducer = combineReducers({
  a: aReducer,
  b: bReducer
});

這樣的話,rootReducer的返回值就是整個object tree。

總結一點:Redux通過一個個reducer完成了對整個數據源(object tree)的拆解訪問和修改。


數據不可變

React在利用組件(Component)構建Web應用時,其實無形中創建了兩棵樹:虛擬dom樹組件樹,就像下圖所描述的那樣(原圖):

react component tree

所以,針對這樣的樹狀結構,如果有數據更新,使得某些組件應該得到重新渲染(re-render)的話,比較推薦的方式就是:自上而下渲染(top-down rendering),即頂層組件通過props傳遞新數據給子孫組件。

然而,每次需要更新的組件,可能就是那么幾個,但是React並不知道,它依然會遍歷執行每個組件的render方法,將返回的newVirtualDom和之前的prevVirtualDom進行diff比較,然后最后發現,計算結果很可能是:該組件所產生的真實dom無需改變!/(ㄒoㄒ)/~~(無用功導致的浪費性能)

所以,為了避免這樣的性能浪費,往往我們都會利用組件的生命周期函數shouldComponentUpdate進行判斷是否有必要進行對該組件進行更新(即,是否執行該組件render方法以及進行diff計算)?

就像這樣:

  shouldComponentUpdate(nextProps) {
    if (nextProps.e !== this.props.e) { // 這里的e是一個字段,可能是對象引用,也可能是數值,布爾值
      return true; // 需要更新
    }
    return false; // 無需更新
  }

但,往往這樣的比較,對於字面值還行,對於對象引用(object,array),就糟糕了,因為:

let prevProps = {
	e: [2, 3]
};

let nextProps = prevProps;

nextProps.e.push(4);

console.log(prevProps.e === nextProps.e); // 始終為true

雖然你可以通過deepEqual來解決這個問題,但對嵌套較深的結構,性能始終會是一個問題。

所以,最后對於對象引用的比較,就引出了不可變數據(immutable data)這個概念,大體的意思就是:一個數據被創建了,就不可以被改變(mutation)

如果你想改變數據,就得重新創建一個新的數據(即新的引用),就像這樣:

let prevProps = {
	e: [2, 3]
};

let nextProps = {
  e:[...prevProps.e, 4] // es7 spread
};

console.log(prevProps.e === nextProps.e); // false

也許,你已經發現每個Reducer函數在修改數據的時候,正是這樣做的,最后返回的都是一個新的引用,而不是直接修改引用的數據,就像這樣:

function eReducer(state = [2, 3], action) {
  switch (action.type) {
    case 'ADD':
      return [...state, 4]; // 並沒有直接地通過state.push(4),修改引用的數據
    default:
      return state;
  }
}

最后,因為combineReducers的存在,之前的那個object tree的整體數據結構就會發生變化,就像下面這樣:

redux-node-change

現在,你就可以在shouldComponentUpdate函數中,肆無忌憚地比較對象引用了,因為數據如果變化了,比較的就會是兩個不同的對象!

總結一點:Redux通過一個個reducer實現了不可變數據(immutability)。

PS:當然,你也可以通過使用第三方插件(庫)來實現immutable data,比如:React.addons.update、Immutable.js。(只不過在Redux中會顯得那么沒有必要)。

Middleware

Middleware — 中間件,最初的思想毫無疑問來自:Express

中間件講究的是對數據的流式處理,比較優秀的特性是:鏈式組合,由於每一個中間件都可以是獨立的,因此可以形成一個小的生態圈。

在Redux中,Middlerwares要處理的對象則是:Action

每個中間件可以針對Action的特征,可以采取不同的操作,既可以選擇傳遞給下一個中間件,如:next(action),也可以選擇跳過某些中間件,如:dispatch(action),或者更直接了當的結束傳遞,如:return

標准的action應該是一個plain object,但是對於中間件而言,action還可以是函數,也可以是promise對象,或者一個帶有特殊含義字段的對象,但不管怎樣,因為中間件會對特定類型action做一定的轉換,所以最后傳給reducer的action一定是標准的plain object。

比如說:

  • [redux-thunk]里的action可以是一個函數,用來發起異步請求。
  • [redux-promise]里的action可以是一個promise對象,用來更優雅的進行異步操作。
  • [redux-logger]里的action就是一個標准的plain object,用來記錄action和nextState的。
  • 一個自定義中間件:延遲action的執行,這里就存在一個特殊字段:action.meta.delay,具體如下:
// 用 { meta: { delay: N } } 來讓 action 延遲 N 毫秒。
const timeoutScheduler = store => next => action => {
  if (!action.meta || !action.meta.delay) {
    return next(action)
  }

  let timeoutId = setTimeout(
    () => next(action),
    action.meta.delay
  )

  return function cancel() {
    clearTimeout(timeoutId)
  }
}

那么問題來了,這么多的中間件,如何使用呢?

先看一個簡單的例子:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
import rootReducer from '../reducers';

// store擴展
const enhancer = applyMiddleware(
  thunk,
  createLogger()
);

const store = createStore(rootReducer, initialState, enhancer);

// 觸發action
store.dispatch({
	type: 'ADD',
	num: 4
});

注意:單純的Redux.createStore(...)創建的Store實例,在執行store.dispatch(action)的時候,是不會執行中間件的,只是單純的action分發。

要想給Store實例附加上執行中間件的能力,就必須改造createStore函數,最新版的Redux是通過傳入store擴展(store enhancer)來解決的,而具有中間件功能的store擴展,則需要使用applyMiddleware函數生成,就像下面這樣:

// store擴展
const enhancer = applyMiddleware(
  thunk,
  createLogger()
);

const store = createStore(rootReducer, initialState, enhancer);

上面的寫法是新版Redux才有的,以前的寫法則是這樣的(新版兼容的哦):

// 舊寫法
const createStoreWithMiddleware = applyMiddleware(
  thunk,
  createLogger()
)(createStore);

const store = createStoreWithMiddleware(reducer, initialState)

至於改造后的createStore方法為何擁有了執行中間件的能力,大家可以看一下appapplyMiddleware的源碼。

最后,簡單用一張圖來驗證一句話的正確性:中間件提供的是位於 action 被發起之后,到達 reducer 之前的擴展點

redux-middleware

react-redux

為了讓Redux能夠更好地與React配合使用,react-redux庫的引入就顯得必不可少。

react-redux主要暴露出兩個api:

  1. Provider組件
  2. connect方法

Provider

Provider存在的意義在於:想通過context的方式將唯一的數據源store傳遞給任意想訪問的子孫組件

比如,下面要說的connect方法在創建Container Component時,就需要通過這種方式得到store,這里就不展開說了。

不熟悉React context的同學,可以看看官方介紹


connect

Redux中的connect方法,跟Reflux.connect方法有點類似,最主要的目的就是:讓Component與Store進行關聯,即Store的數據變化可以及時通知Views重新渲染。

下面這段源碼(來自connect.js),能夠說明上述觀點:

trySubscribe() {
    if (shouldSubscribe && !this.unsubscribe) {
	  // 跟store關聯,消息訂閱
      this.unsubscribe = this.store.subscribe(this.handleChange.bind(this))
      this.handleChange()
    }
}

handleChange() {
    if (!this.unsubscribe) {
      return
    }

    const prevStoreState = this.state.storeState
    const storeState = this.store.getState()

    if (!pure || prevStoreState !== storeState) {
      this.hasStoreStateChanged = true
      this.setState({ storeState }) // 組件重新渲染
    }
}

另外,connect方法,還引出了另外兩個概念,即:容器組件(Container Component)和展示組件(Presentational Component)。

感興趣的同學,可以看下這篇文章《Presentational and Container Components》,了解兩者的區別,這里就不展開討論了。

最后

以上就是筆者對Redux及其相關知識的理解,不對的地方歡迎留言交流,新浪微博


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM