前端技術 | 從Flux到Redux

飛久發表於2019-05-08

上一篇分析了Flux出現的背景和原理,最核心的思想就是“元件化+單向資料流”。

但是,Flux在設計上並非完美,具體來說主要存在以下2個不足:

1. 多Store資料依賴

由於Flux採用多Store設計,各個Store之間可能存在資料依賴。以flux-chat為例:在這個聊天軟體裡,可能會有多個人給你發訊息,比如Dave給你發了3條,Brian給你發了2條,當你點開某個人給你發的訊息後,介面需要重新整理,顯示你目前還有幾個人的未讀訊息沒有檢視:

前端技術 | 從Flux到Redux

為了解決這個需求,建立了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

和前一篇的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的完整流程應參見下面這張動圖:

前端技術 | 從Flux到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機制,說白了就是所有頁面都能訪問的一個上下文物件:

前端技術 | 從Flux到Redux

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(高階元件)技術,動態建立新元件及其例項:

前端技術 | 從Flux到Redux

那麼這個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。

前端技術 | 從Flux到Redux

相關文章