Redux 卍解
說起Flux,筆者之前,曾寫過一篇《ReFlux細說》的文章,重點對比講述了Flux的另外兩種實現形式:『Facebook Flux vs Reflux』,有興趣的同學可以一並看看。
時過境遷,現在社區里,Redux的風頭早已蓋過其他Flux,它與React的組合使用更是大家所推薦的。
Redux很火,很流行,並不是沒有道理!!它本身靈感來源於Flux,但卻不局限於Flux,它還帶來了一些新的概念和思想,集成了immutability的同時,也促成了Redux自身生態圈。
筆者在看完redux和react-redux源碼后,覺得它的一些思想和原理拿出來聊一聊,會更有利於使用者的了解和使用Redux。
(注
:如果你是初學者,可以先閱讀一下Redux中文文檔,了解Redux基礎知識。)
數據流
作為Flux的一種實現形式,Redux自然保持着數據流的單向性
,用一張圖來形象說明的話,可以是這樣:
上面這張圖,在展現單向數據流的同時,還為我們引出了幾個熟悉的模塊:Store、Actions、Action Creators、以及Views。
相信大家都不會陌生,因為它們就是Flux設計模式中所提到的幾個重要概念,在這里,Redux沿用了它們,並在這基礎之上,又融入了兩個重要的新概念:Reducers
和Middlewares
(稍后會講到)。
接下來,我們先說說Redux在已有概念上的一些變化,之后再聊聊Redux帶來的幾個新概念。
Store
Store — 數據存儲中心,同時連接
着Actions和Views(React Components)。
連接
的意思大概就是:
- Store需要負責接收Views傳來的Action
- 然后,根據Action.type和Action.payload對Store里的數據進行修改
- 最后,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
}
}
總結歸納幾點:
- Store的數據修改,本質上是通過Reducer來完成的。
- Store只提供get方法(即getState),不提供set方法,所以數據的修改一定是通過
dispatch(action)
來完成,即:action -> reducers -> store - Store除了存儲數據之外,還有着
消息發布/訂閱
(pub/sub)的功能,也正是因為這個功能,它才能夠同時連接
着Actions和Views。- dispatch方法 對應着 pub
- subscribe方法 對應着 sub
Reducer
Reducer,這個名字來源於數組的一個函數 — reduce,它們倆比較相似的地方在於:接收一個舊的prevState,返回一個新的nextState。
在上文講解Store的時候,得知:Reducer是一個純函數,用來修改Store數據的。
這種修改數據的方式,區別於其他Flux,所以我們疑惑:通過Reducer修改數據給我們帶來了哪些好處?
這里,我列出了兩點:
- 數據拆解
- 數據不可變(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),就像下面這張圖所描述的:
而我們對數據的修改,其實就是對葉子節點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樹
和組件樹
,就像下圖所描述的那樣(原圖):
所以,針對這樣的樹狀結構,如果有數據更新,使得某些組件應該得到重新渲染(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的整體數據結構就會發生變化,就像下面這樣:
現在,你就可以在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 之前的擴展點。
react-redux
為了讓Redux能夠更好地與React配合使用,react-redux庫的引入就顯得必不可少。
react-redux主要暴露出兩個api:
- Provider組件
- 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及其相關知識的理解,不對的地方歡迎留言交流,新浪微博。