Redux 從設計到源碼
首先明確,這篇文章主要包括三個部分:
-
Redux 背后的設計思想
-
源碼分析以及自定義中間件
-
開發中的最佳實踐
Redux背后的設計思想
在講設計思想前,先簡單講下Redux是什么?我們為什么要用Redux?
Redux是什么?
Redux是JavaScript狀態容器,能提供可預測化的狀態管理。
它認為:
-
Web應用是一個狀態機,視圖與狀態是一一對應的。
-
所有的狀態,保存在一個對象里面。
我們先來看看“狀態容器”、“視圖與狀態一一對應”以及“一個對象”這三個概念的具體體現。
如上圖,Store是Redux中的狀態容器,它里面存儲著所有的狀態數據,每個狀態都跟一個視圖一一對應。
Redux也規定,一個State對應一個View。只要State相同,View就相同,知道了State,就知道View是什么樣,反之亦然。
比如,當前頁面分三種狀態:loading(加載中)、success(加載成功)或者error(加載失敗),那么這三個就分別唯一對應著一種視圖。
現在我們對“狀態容器”以及“視圖與狀態一一對應”有所了解了,那么Redux是怎么實現可預測化的呢?我們再來看下Redux的工作流程。
首先,我們看下幾個核心概念:
-
Store:保存數據的地方,你可以把它看成一個容器,整個應用只能有一個Store。
-
State:Store對象包含所有數據,如果想得到某個時點的數據,就要對Store生成快照,這種時點的數據集合,就叫做State。
-
Action:State的變化,會導致View的變化。但是,用戶接觸不到State,只能接觸到View。所以,State的變化必須是View導致的。Action就是View發出的通知,表示State應該要發生變化了。
-
Action Creator:View要發送多少種消息,就會有多少種Action。如果都手寫,會很麻煩,所以我們定義一個函數來生成Action,這個函數就叫Action Creator。
-
Reducer:Store收到Action以后,必須給出一個新的State,這樣View才會發生變化。這種State的計算過程就叫做Reducer。Reducer是一個函數,它接受Action和當前State作為參數,返回一個新的State。
-
dispatch:是View發出Action的唯一方法。
然后我們過下整個工作流程:
-
首先,用戶(通過View)發出Action,發出方式就用到了dispatch方法。
-
然后,Store自動調用Reducer,并且傳入兩個參數:當前State和收到的Action,Reducer會返回新的State
-
State一旦有變化,Store就會調用監聽函數,來更新View。
到這兒為止,一次用戶交互流程結束。可以看到,在整個流程中數據都是單向流動的,這種方式保證了流程的清晰。
為什么要用Redux?
前端復雜性的根本原因是大量無規律的交互和異步操作。
變化和異步操作的相同作用都是改變了當前View的狀態,但是它們的無規律性導致了前端的復雜,而且隨著代碼量越來越大,我們要維護的狀態也越來越多。
我們很容易就對這些狀態何時發生、為什么發生以及怎么發生的失去控制。那么怎樣才能讓這些狀態變化能被我們預先掌握,可以復制追蹤呢?
這就是Redux設計的動機所在。
Redux試圖讓每個State變化都是可預測的,將應用中所有的動作與狀態都統一管理,讓一切有據可循。
如上圖所示,如果我們的頁面比較復雜,又沒有用任何數據層框架的話,就是圖片上這個樣子:交互上存在父子、子父、兄弟組件間通信,數據也存在跨層、反向的數據流。
這樣的話,我們維護起來就會特別困難,那么我們理想的應用狀態是什么樣呢?看下圖:
架構層面上講,我們希望UI跟數據和邏輯分離,UI只負責渲染,業務和邏輯交由其它部分處理,從數據流向方面來說, 單向數據流確保了整個流程清晰。
我們之前的操作可以復制、追蹤出來,這也是Redux的主要設計思想。
綜上,Redux可以做到:
- 每個State變化可預測。
- 動作與狀態統一管理。
Redux思想追溯
Redux作者在Redux.js官方文檔Motivation一章的最后一段明確提到:
Following in the steps of Flux, CQRS, and Event Sourcing , Redux attempts to make state mutations predictable
by imposing certain restrictions on how and when updates can happen.
我們就先了解下Flux、CQRS、ES(Event Sourcing 事件溯源)這幾個概念。
什么是ES?
-
不是保存對象的最新狀態,而是保存對象產生的事件。
-
通過事件追溯得到對象最新狀態。
舉個例子:我們平常記賬有兩種方式,直接記錄每次賬單的結果或者記錄每次的收入/支出,那么我們自己計算的話也可以得到結果,ES就是后者。
與傳統增刪改查關系式存儲的區別:
-
傳統的增刪是以結果為導向的數據存儲,ES是以過程為導向存儲。
-
CRUD是直接對庫進行操作。
-
ES是在庫里存了一系列事件的集合,不直接對庫里記錄進行更改。
優點:
-
高性能:事件是不可更改的,存儲的時候并且只做插入操作,也可以設計成獨立、簡單的對象。所以存儲事件的成本較低且效率較高,擴展起來也非常方便。
-
簡化存儲:事件用于描述系統內發生的事情,我們可以考慮用事件存儲代替復雜的關系存儲。
-
溯源:正因為事件是不可更改的,并且記錄了所有系統內發生的事情,我們能用它來跟蹤問題、重現錯誤,甚至做備份和還原。
缺點:
-
事件丟失:因為ES存儲都是基于事件的,所以一旦事件丟失就很難保證數據的完整性。
-
修改時必須兼容老結構:指的是因為老的事件不可變,所以當業務變動的時候新的事件必須兼容老結構。
CQRS(Command Query Responsibility Segregation)是什么?
顧名思義,“命令與查詢職責分離”-->”讀寫分離”。
整體的思想是把Query操作和Command操作分成兩塊獨立的庫來維護,當事件庫有更新時,再來同步讀取數據庫。
看下Query端,只是對數據庫的簡單讀操作。然后Command端,是對事件進行簡單的存儲,同時通知Query端進行數據更新,這個地方就用到了ES。
優點:
- CQ兩端分離,各自獨立。
- 技術代碼和業務代碼完全分離。
缺點:
- 強依賴高性能可靠的分布式消息隊列。
Flux是什么?
Flux是一種架構思想,下面過程中,數據總是“單向流動”,任何相鄰的部分都不會發生數據的“雙向流動”,這保證了流程的清晰。Flux的最大特點,就是數據的“單向流動”。
- 用戶訪問View。
- View發出用戶的Action。
- Dispatcher收到Action,要求Store進行相應的更新。
- Store更新后,發出一個“change”事件。
介紹完以上之后,我們來整體做一下對比。
CQRS與Flux
相同:當數據在write side發生更改時,一個更新事件會被推送到read side,通過綁定事件的回調,read side得知數據已更新,可以選擇是否重新讀取數據。
差異:在CQRS中,write side和read side分屬于兩個不同的領域模式,各自的邏輯封裝和隔離在各自的Model中,而在Flux里,業務邏輯都統一封裝在Store中。
Redux與Flux
Redux是Flux思想的一種實現,同時又在其基礎上做了改進。Redux還是秉承了Flux單向數據流、Store是唯一的數據源的思想。
最大的區別:
- Redux只有一個Store。
Flux中允許有多個Store,但是Redux中只允許有一個,相較于Flux,一個Store更加清晰,容易管理。Flux里面會有多個Store存儲應用數據,并在Store里面執行更新邏輯,當Store變化的時候再通知controller-view更新自己的數據;Redux將各個Store整合成一個完整的Store,并且可以根據這個Store推導出應用完整的State。
同時Redux中更新的邏輯也不在Store中執行而是放在Reducer中。單一Store帶來的好處是,所有數據結果集中化,操作時的便利,只要把它傳給最外層組件,那么內層組件就不需要維持State,全部經父級由props往下傳即可。子組件變得異常簡單。
- Redux中沒有Dispatcher的概念。
Redux去除了這個Dispatcher,使用Store的Store.dispatch()方法來把action傳給Store,由于所有的action處理都會經過這個Store.dispatch()方法,Redux聰明地利用這一點,實現了與Koa、RubyRack類似的Middleware機制。Middleware可以讓你在dispatch action后,到達Store前這一段攔截并插入代碼,可以任意操作action和Store。很容易實現靈活的日志打印、錯誤收集、API請求、路由等操作。
除了以上,Redux相對Flux而言還有以下特性和優點:
-
文檔清晰,編碼統一。
-
逆天的DevTools,可以讓應用像錄像機一樣反復錄制和重放。
目前,美團外賣后端管理平臺的上單各個模塊已經逐步替換為React+Redux開發模式,流程的清晰為錯誤追溯和代碼維護提供了便利,現實工作中也大大提高了人效。
源碼分析
查看源碼的話先從GitHub把這個地址上拷下來,切換到src目錄,如下圖:
看下整體結構:
其中utils下面的Warning.js主要負責控制臺錯誤日志的輸出,我們直接忽略index.js是入口文件,createStore.js是主流程文件,其余4個文件都是輔助性的API。
我們先結合下流程分析下對應的源碼。
首先,我們從Redux中引入createStore方法,然后調用createStore方法,并將Reducer作為參數傳入,用來生成Store。為了接收到對應的State更新,我們先執行Store的subscribe方法,將render作為監聽函數傳入。然后我們就可以dispatchaction了,對應更新view的State。
那么我們按照順序看下對應的源碼:
入口文件index.js
入口文件,上面一堆檢測代碼忽略,看紅框標出部分,它的主要作用相當于提供了一些方法,這些方法也是Redux支持的所有方法。
然后我們看下主流程文件:createStore.js。
主流程文件:createStore.js
createStore主要用于Store的生成,我們先整理看下createStore具體做了哪些事兒。
首先,一大堆類型判斷先忽略,可以看到聲明了一系列函數,然后執行了dispatch方法,最后暴露了dispatch、subscribe……幾個方法。這里dispatch了一個init Action是為了生成初始的State樹。
我們先挑兩個簡單的函數看下,getState和replaceReducer,其中getState只是返回了當前的狀態。replaceReducer是替換了當前的Reducer并重新初始化了State樹。這兩個方法比較簡單,下面我們在看下其它方法。
訂閱函數的主要作用是注冊監聽事件,然后返回取消訂閱的函數,它把所有的訂閱函數統一放一個數組里,只維護這個數組。
為了實現實時性,所以這里用了兩個數組來分別處理dispatch事件和接收subscribe事件。
store.subscribe()方法總結:
-
入參函數放入監聽隊列
-
返回取消訂閱函數
再來看下store.dispatch()-->分發action,修改State的唯一方式。
store.dispatch()方法總結:
-
調用Reducer,傳參(currentState,action)。
-
按順序執行listener。
-
返回action。
到這兒的話,主流程我們就講完了,下面我們講下幾個輔助的源碼文件。
bindActionCreators.js
bindActionCreators把action creators轉成擁有同名keys的對象,使用dispatch把每個action creator包裝起來,這樣可以直接調用它們。
實際情況用到的并不多,惟一的應用場景是當你需要把action creator往下傳到一個組件上,卻不想讓這個組件覺察到Redux的存在,而且不希望把Redux Store或dispatch傳給它。
combineReducers.js-->用于合并Reducer
這個方法的主要功能是用來合并Reducer,因為當我們應用比較大的時候Reducer按照模塊拆分看上去會比較清晰,但是傳入Store的Reducer必須是一個函數,所以用這個方法來作合并。代碼不復雜,就不細講了。它的用法和最后的效果可以看下上面左側圖。
compose.js-->用于組合傳入的函數
compose這個方法,主要用來組合傳入的一系列函數,在中間件時會用到。可以看到,執行的最終結果是把各個函數串聯起來。
applyMiddleware.js-->用于Store增強
中間件是Redux源碼中比較繞的一部分,我們結合用法重點看下。
首先看下用法:
const store = createStore(reducer,applyMiddleware(…middlewares))
or
const store = createStore(reducer,{},applyMiddleware(…middlewares))
可以看到,是將中間件作為createStore的第二個或者第三個參數傳入,然后我們看下傳入之后實際發生了什么。
從代碼的最后一行可以看到,最后的執行代碼相當于applyMiddleware(…middlewares)(createStore)(reducer,preloadedState)然后我們去applyMiddleware里看它的執行過程。
可以看到執行方法有三層,那么對應我們源碼看的話最終會執行最后一層。最后一層的執行結果是返回了一個正常的Store和一個被變更過的dispatch方法,實現了對Store的增強。
這里假設我們傳入的數組chain是[f,g,h],那么我們的dispatch相當于把原有dispatch方法進行f,g,h層層過濾,變成了新的dispatch。
由此的話我們可以推出中間件的寫法:因為中間件是要多個首尾相連的,需要一層層的“加工”,所以要有個next方法來獨立一層確保串聯執行,另外dispatch增強后也是個dispatch方法,也要接收action參數,所以最后一層肯定是action。
再者,中間件內部需要用到Store的方法,所以Store我們放到頂層,最后的結果就是:
看下一個比較常用的中間件redux-thunk源碼,關鍵代碼只有不到10行。
作用的話可以看到,這里有個判斷:如果當前action是個函數的話,return一個action執行,參數有dispatch和getState,否則返回給下個中間件。
這種寫法就拓展了中間件的用法,讓action可以支持函數傳遞。
我們來總結下這里面的幾個疑點。
Q1:為什么要嵌套函數?為何不在一層函數中傳遞三個參數,而要在一層函數中傳遞一個參數,一共傳遞三層?
因為中間件是要多個首尾相連的,對next進行一層層的“加工”,所以next必須獨立一層。那么Store和action呢?Store的話,我們要在中間件頂層放上Store,因為我們要用Store的dispatch和getState兩個方法。action的話,是因為我們封裝了這么多層,其實就是為了作出更高級的dispatch方法,是dispatch,就得接受action這個參數。
Q2:middlewareAPI中的dispatch為什么要用匿名函數包裹呢?
我們用applyMiddleware是為了改造dispatch的,所以applyMiddleware執行完后,dispatch是變化了的,而middlewareAPI是applyMiddleware執行中分發到各個middleware,所以必須用匿名函數包裹dispatch,這樣只要dispatch更新了,middlewareAPI中的dispatch應用也會發生變化。
Q3: 在middleware里調用dispatch跟調用next一樣嗎?
因為我們的dispatch是用匿名函數包裹,所以在中間件里執行dispatch跟其它地方沒有任何差別,而執行next相當于調用下個中間件。
到這兒為止,源碼部分就介紹完了,下面總結下開發中的最佳實踐。
最佳實踐
官網 中對最佳實踐總結的很到位,我們重點總結下以下幾個:
-
用對象展開符增加代碼可讀性。
-
區分smart component(know the State)和dump component(完全不需要關心State)。
-
component里不要出現任何async calls,交給action creator來做。
-
Reducer盡量簡單,復雜的交給action creator。
-
Reducer里return state的時候,不要改動之前State,請返回新的。
-
immutable.js配合效果很好(但同時也會帶來強侵入性,可以結合實際項目考慮)。
-
action creator里,用promise/async/await以及Redux-thunk(redux-saga)來幫助你完成想要的功能。
-
action creators和Reducer請用pure函數。
-
請慎重選擇組件樹的哪一層使用connected component(連接到Store),通常是比較高層的組件用來和Store溝通,最低層組件使用這防止太長的prop chain。
-
請慎用自定義的Redux-middleware,錯誤的配置可能會影響到其他middleware.
-
有些時候有些項目你并不需要Redux(畢竟引入Redux會增加一些額外的工作量)
作者簡介
瑩瑩,美團外賣前端研發工程師,2016年加入美團外賣,負責外賣商家管理平臺以及銷售人員App蜜蜂的整個上單流程開發。
最后,附上一條硬廣,美團外賣長期誠聘高級前端工程師/前端技術專家,歡迎發送簡歷至:tianhuan02#meituan.com。
回答“思考題”、發現文章有錯誤、對內容有疑問,都可以來微信公眾號(美團點評技術團隊)后臺給我們留言。我們每周會挑選出一位“優秀回答者”,贈送一份精美的小禮品。快來掃碼關注我們吧!
來自:https://tech.meituan.com/redux-design-code.html