上一篇分析了Flux出現的背景和原理,最核心的思想就是“元件化+單向資料流”。
但是,Flux在設計上並非完美,具體來說主要存在以下2個不足:
1. 多Store資料依賴
由於Flux採用多Store設計,各個Store之間可能存在資料依賴。以flux-chat為例:在這個聊天軟體裡,可能會有多個人給你發訊息,比如Dave給你發了3條,Brian給你發了2條,當你點開某個人給你發的訊息後,介面需要重新整理,顯示你目前還有幾個人的未讀訊息沒有檢視:
為了解決這個需求,建立了3個Store:
- ThreadStore用來儲存訊息組狀態
- MessageStore用來儲存每個組裡的訊息的狀態
- UnreadThreadStore用來計算目前還有幾個訊息組沒有檢視
當你點開某個訊息組時,顯然你需要先更新ThreadStore和MessageStore,然後再更新UnreadThreadStore。由於Store的註冊順序是不確定的,為了應付這種依賴,Flux提供了waitFor()機制,每個Store在註冊之後都會生成一個令牌(dispatchToken),通過等待令牌的方式確保其他Store被優先更新。
因此UnreadThreadStore的程式碼會寫成下面這個樣子:
Dispatcher.waitFor([
ThreadStore.dispatchToken,
MessageStore.dispatchToken
]);
switch (action.type) {
case ActionTypes.CLICK_THREAD:
UnreadThreadStore.emitChange();
break;
...
}
複製程式碼
雖然可以工作,但是總覺得不是很優雅,在一個Store中需要顯示地包含其他Store的呼叫。當然你會說,乾脆把這3個Store的程式碼糅到一起,搞成一個Store不就行了?但是這樣又會導致程式碼結構不夠清晰,不利於多模組分工協作。
為了兼顧這兩個方面,Redux使用全域性唯一Store,外部可以使用多個reducer來修改Store的不同部分,最後會把所有reducer的修改再組合成一個新的Store狀態。
2.狀態修改不是純函式
所謂純函式,是指輸出只和輸入相關,相同的輸入一定會得到相同的輸出。用專業一點的術語來說,純函式沒有“副作用”。我們先來看看Flux中是怎麼修改狀態的:
Dispatcher.register(action => {
switch(action.type) {
case ActionTypes.CLICK_THREAD:
_currentID = action.threadID;
ThreadStore.emitChange();
break;
...
}
複製程式碼
可以看到,是直接修改變數值,然後顯式傳送一個change事件來通知View。
我們再來看看Redux中是怎麼修改狀態的:
export default function threadReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.CLICK_THREAD: {
return { ...state, _currentID: action.threadID };
...
}
複製程式碼
細心的人可能已經看出來了,主要有3點區別:
- 前面的函式裡只有一個action引數,而這裡多了一個state引數
- 不是直接修改state中的欄位,而是需要返回一個新的state物件
- 不需要顯式傳送事件通知View,實際上,Redux內部會檢測state物件的引用是否發生了變化,然後自動通知View進行重新整理
那麼有人會說了,為啥要這麼做,好像也沒看到啥好處嘛?當然是有好處的,這樣可以支援“時間旅行除錯(Time Travel Debugging)”。所謂時間旅行除錯,指的是可以支援狀態的無限undo / redo。由於state物件是被整體替換的,如果想回到上一個狀態重新執行,那麼直接替換成上一步的state物件就可以了。
3.什麼是Redux?
首先我們要搞清楚,Redux解決了哪些問題?主要是以下3點:
1.如何在應用程式的整個生命週期內維持所有資料?
Redux是一個“狀態容器”。寫過React或者ReactNative的同學可能會有感受,如果多個頁面需要共享資料時,需要把資料一層層地傳遞下去,非常繁瑣。如果能有一個全域性統一的地方儲存資料,當資料發生變化時自動通知View重新整理介面,是不是很美好呢?因此,我們需要一個“狀態容器”。
2.如何修改這些資料?
Redux借鑑了分散式計算中的map-reduce的思想,把Store中的資料分割(map)成多個小的物件,通過純函式修改這些物件,最後再把所有的修改合併(reduce)成一個大的物件。修改資料的純函式被稱為reducer。
3.如何把資料變更傳播到整個應用程式?
通過訂閱(subscribe)。如果你的View需要跟隨資料的變化動態重新整理,可以呼叫subscribe()註冊回撥函式。在這一點上,Redux是非常粗粒度的,每次只要有新的action被分發,你都會收到通知。顯然,你需要對通知進行過濾,這意味著你可能會寫很多重複程式碼。不過,這也是出於通用性和靈活性考慮,實際上Redux不僅可以用於React,也可以用在Vue.js或者Angular上。可以搭配特定框架相關的適配層比如react-redux來規避這些重複程式碼。
說了這麼多,我們來看一下Redux的基本框架:
和前一篇的Flux框架圖對比一下可以發現,Redux去除了dispatcher元件(因為只有一個Store),增加了recuder元件(用於更新Store的不同部分)。下面詳細介紹各個部分的作用。
4.Redux基本概念
4.1 Store
首先我們需要建立一個全域性唯一的Store,Redux提供了輔助函式createStore():
import { createStore } from 'redux'
var store = createStore(() => {})
複製程式碼
你可能注意到了,createStore()需要提供一個引數,這個引數就是reducer。
4.2 Reducer
前面介紹過,reducer就是一個純函式,輸入引數是state和action,輸出新的state。一般的程式碼模板如下:
var reducer = (state = {}, action) => {
switch (action.type) {
case 'MY_ACTION':
return {...state, message: action.message}
default:
return state
}
}
複製程式碼
需要注意的是,default分支一定要返回state,否則會導致狀態丟失。
好了,現在我們有了reducer,可以作為引數傳遞給4.1節中的createStore()函式了。
createStore()只能接受一個reducer引數,如果我們有多個reducer怎麼辦?這時需要使用另一個輔助函式combineReducers():
import { combineReducers } from 'redux'
var reducer = combineReducers({
first: firstReducer,
second: secondReducer
})
複製程式碼
combineReducers()會把多個reducer組合成一個,當有action過來時會依次呼叫每個子reducer,所以實際上你可以組織成一個樹狀結構。
4.3 Action
所謂action,其實就是一個普通的javascript物件,一般會包含一個type屬性用於標識型別,以及一個payload屬性用於傳遞引數(名字可以隨便取):
var action = {
type: 'MY_ACTION',
payload: { message: 'hello' }
}
複製程式碼
那麼如何傳送action呢?store提供了一個dispatch()函式:
store.dispatch(action)
複製程式碼
4.4 Action Creator
所謂action creator,其實就是一個用來構建action物件的函式:
var actionCreator = (message) => {
return {
type: 'MY_ACTION',
payload: { message: message }
}
}
複製程式碼
所以4.3節傳送action的程式碼也可以寫成這樣:
store.dispatch(actionCreator('hello'))
複製程式碼
4.5 狀態讀取和訂閱
當你傳送了一個action,reducer被呼叫並完成狀態修改,那麼前端視是怎麼感知到狀態變化的呢?我們需要通過subscribe()進行訂閱:
store.subscribe(() => {
let state = store.getState()
... ...
})
複製程式碼
store的getState()函式可以獲得當前狀態的一個副本,然後就可以重新整理介面了,以React為例,可以呼叫this.setState()或者this.forceUpdate()觸發重新渲染。
當檢視元件比較多時,每次都要寫這段訂閱程式碼會比較繁瑣,後面會介紹通過react-redux來簡化這一過程。
4.6 Middleware
第3章的那張圖其實還少畫了個東西,叫做middleware(中介軟體)。那麼這個middleware是幹什麼用的呢?
在Web應用中經常會有非同步呼叫,比如請求網路、查詢資料庫什麼的。我們首先傳送一個action啟動非同步任務,並希望在非同步任務完成以後再更新狀態,應該如何實現呢?在Flux中,我們可以在dispatcher裡完成:首先啟動非同步任務,然後在回撥函式中再傳送一個新的action去更新Store。但是Redux中去除了dispatcher的概念,你能呼叫的只有store的dispatch()函式而已,那我們該怎麼辦呢?答案就是middleware。
所以,Redux的完整流程應參見下面這張動圖:
我們先來看一個簡單的middleware的例子:
var thunkMiddleware = ({ dispatch, getState }) => {
return (next) => {
return (action) => {
return typeof action === 'function' ?
action(dispatch, getState) :
next(action)
}
}
}
複製程式碼
可以發現,其實middleware就是一個三層巢狀的函式:
- 第一層向其餘兩層提供dispatch和 getState 函式
- 第二層提供 next 函式,它允許你顯式的將處理過的輸入傳遞給下一個middleware或 reducer
- 第三層提供從上一個中介軟體或從 dispatch 傳遞來的 action
所以,實際上middleware可以理解在action進入reducer之前進行了一次攔截。在這個例子裡,如果action是一個函式,我們就不會把action繼續傳遞下去,而是呼叫這個函式去執行非同步任務。當非同步任務執行完畢後,我們可以呼叫dispatch()函式傳送一個新的action,用於呼叫reducer更新狀態。
那麼我們如何註冊一箇中介軟體呢?Redux提供了一個工具函式applyMiddleware(),可以直接作為createStore()的一個引數傳遞進去:
const store = createStore(
reducer,
applyMiddleware(myMiddleware1, myMiddleware2)
)
複製程式碼
預告一下,後面一篇要介紹的redux-saga,其實就是一個Redux中介軟體。
5.使用react-redux
Redux的設計主要考慮的是通用性和靈活性,如果想更好的配合React的元件化程式設計習慣,你可能需要react-redux。
Redux使用全域性唯一的Store,另外當你需要傳送action的時候,必須通過store的dispatch()函式。這對於一個有很多頁面的React應用來說,意味著只有兩種選擇:
- 在所有頁面中import全域性store物件
- 通過props把store物件一層一層地傳遞下去
這顯然極其繁瑣,幸運的是,React提供了Context機制,說白了就是所有頁面都能訪問的一個上下文物件:
react-redux利用React的Context機制進行了封裝,提供了<Provider>元件和connect()函式來實現store物件的全域性可訪問性。
5.1 <Provider>
這是一個React元件,使用時需要把它包裹在應用層根元件的外面,然後把全域性store物件賦值給它的store屬性:
import { Provider } from 'react-redux'
import store from './mystore'
export default class Application extends React.Component {
render () {
return (
<Provider store={ store }>
<Home />
</Provider>
)
}
}
複製程式碼
5.2 connect()
Provider元件只是把store物件放進了Context中,如果你需要訪問它,還需要一些額外的程式碼,react-redux提供了一個connect()函式來幫你完成這些工作。
實際上,connect()就幫你做了兩件事:
- 在你的元件外面包裝了<Context.Consumer>元件,獲取Context中的store物件
- 根據你提供的selector函式,幫你把state中的值以及store.dispatch()函式對映到props中,這樣在程式碼中你就可以直接通過this.props.xxx進行訪問了
實現層面上,connect()採用了React的HOC(高階元件)技術,動態建立新元件及其例項:
那麼這個connect()怎麼用呢?我們通過3個應用場景依次介紹。
1.你只是希望能在元件中使用dispatch()直接派發action
這是最簡單的情況,你只需要在匯出元件的時候加上connect()就可以了:
export default connect()(MyComponent)
複製程式碼
當你需要派發action的時候,可以直接呼叫this.props.dispatch()。
2.你不想直接使用dispatch(),希望能夠自動派發action
實際上你會發現,如果action很多的話,你需要不停地呼叫dispatch()函式。為了使我們的實現更加“宣告式”,最好是把派發邏輯封裝起來。實際上Redux中有一個輔助函式bindActionCreators()來完成這項工作,它會為每個action creator生成同名的函式,自動呼叫dispatch()函式:
const increment = () => ({ type: "INCREMENT" });
const decrement = () => ({ type: "DECREMENT" });
const boundActionCreators = bindActionCreators({ increment, decrement }, dispatch);
// 返回值:
// {
// increment: (...args) => dispatch(increment(...args)),
// decrement: (...args) => dispatch(decrement(...args)),
// }
複製程式碼
這樣你就可以直接呼叫boundActionCreators.increment()派發action了。那麼如何跟connect()聯絡起來呢?這裡需要用到它的第2個引數(第1個引數後面再介紹)mapDispatchToProps,舉個例子:
const mapDispatchToProps = (dispatch) => {
return bindActionCreators({ increment, decrement }, dispatch);
}
export default connect(null, mapDispatchToProps)(MyComponent)
複製程式碼
這樣,你就可以在元件中直接呼叫this.props.increment()函式了。
你以為這樣就結束了?還有更簡單的方法,連bindActionCreators()都不用寫!你可以直接提供一個物件,包含所有的action creator就行了(這被稱為“物件簡寫”方式):
const mapDispatchToProps = { increment, decrement }
export default connect(null, mapDispatchToProps)(MyComponent)
複製程式碼
注意:如果你提供了mapDispatchToProps引數,那麼預設情況下dispatch就不會再注入到props中了。如果你還想使用this.props.dispatch(),可以在mapDispatchToProps的返回值物件中加上dispatch屬性。
3.你希望訪問store中的資料
這應該是使用最多的場景,元件訪問store中的資料並重新整理介面。根據“無狀態元件”設計原則,我們不應該直接訪問store,而需要通過一個“selector函式”把store中的資料對映的props中進行訪問,這個“selector函式”就是conntect()的第1個引數mapStateToProps。舉個例子:
const mapStateToProps = (state = {}, ownProps) => {
return {
xxx: state.xxx
}
}
export default connect(mapStateToProps)(MyComponent)
複製程式碼
這樣你在元件中就可以通過this.props.xxx進行訪問了。另外,它還會幫你自動訂閱store,任何時候store狀態資料發生變化,mapStateToProps都會被呼叫並導致介面重新渲染。除了第一個引數state之外,還有一個可選引數ownProps,如果你的元件需要用自身的props資料到store中檢索資料,可以通過這個引數獲取。
當然,你可以同時提供mapStateToProps和mapDispatchToProps引數,這樣你就可以獲得兩方面的功能:
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)
複製程式碼
最後,以一張思維導圖結束本篇文章,下一篇介紹redux-saga。