本文翻譯自10 Tips for Better Redux Architecture,這篇文章寫得真不錯,建議能夠看看原文。本文從屬於筆者的Web 前端入門與最佳實踐系列文章。javascript
記得我纔開始使用React的時候,尚未Redux呢,那時候只有Flux 架構的理論概念和一堆各類各樣的實現。前端
而硝煙以後,如今主流的狀態與數據管理框架便數Redux與MobX,其中MobX並不是Flux的實現。Redux如此流行的一個重要乃其普遍的適用性,其不只適用於React,還能應用於包括Angular 2在內的衆多前端框架中。java
而MobX與Redux相比,我會更傾向於在簡單的UI工程中使用它,換言之,我認爲MobX並無提供不少Redux擁有的特性。所以根據你的項目特質來選擇合適的狀態管理框架纔是正道。react
另外,Relay與Falcor也是很流行的能夠用於狀態管理的工具,不過不一樣於Redux或者MobX,它們對於後端有特定的需求,只能基於GraphQL或者Falcor Server,而且Relay中的狀態也與服務端持久化存儲的某些數據相匹配。換言之,Relay與Falcor並無提供了能夠獨立運行在客戶端而且支持暫時態的狀態管理功能,不過若是你須要同時維護Client-Only與Server-Persisted的狀態,那麼混用Redux與Relay也是個不錯的選擇。沒有最好的工具,只有最合適的工具。git
Redux的做者Dan Abramov提供了很多的優質學習資源,譬如Getting Started with Redux與building-react-applications-with-idiomatic-redux,這兩者均可以引領你一步一步地熟悉而且掌握Redux基礎,而本文則是從工程實踐經驗的角度來介紹一些高級的技巧讓你可以更好地使用Redux。github
Redux最重要的兩個特性能夠歸納爲:編程
Deterministic View Renders:可肯定/可控的視圖渲染redux
Deterministic State Reproduction:可肯定/可控的狀態重現
Determinism對於保證應用的可測試性與診斷調試以及Bug修復很是重要。若是你的應用視圖與狀態是非Deterministic,也就意味着你沒法瞭解到視圖與狀態是不是有效的,乃至於非Deterministic自己就是一個Bug。不過不少東西本質上就是不可肯定的,譬如用戶什麼時候產生輸入操做以及網絡I/O的變化等等。而在這種狀況下咱們又該如何保證代碼的工做正常呢?那就要從代碼隔離下手了。後端
Redux最主要的目標便是在界面渲染或者經過網絡獲取數據時將狀態管理隔離於譬如I/O這樣的反作用。在隔離了反作用以後,代碼會變得清晰不少,也能更加方便地審閱與測試你的獨立於界面操做與網絡請求的業務邏輯代碼。當你的View Render獨立於網絡I/O,也就意味着此時的View Render是可肯定的View Render,即在有相同的狀態輸入的狀況下永遠會得到相同的結果。不少新手在考慮如何建立視圖的時候,會參考一下步驟:這部分須要一個User Model,這樣我才能在Model中發起異步請求而後經過Promise來根據名字更新User Component。另外一塊須要TODO Items,在獲取完畢以後遍歷整個數組而後渲染到屏幕上。不過這種模式會存在如下的缺陷:數組
在任意的時間點你並不會擁有用於渲染整個界面的所有數據,不少時候直到組件開始某些操做以前並不會開始數據抓取過程。
不一樣的數據獲取可能會在不一樣的時間點結束,從而會改變View Render序列的渲染順序。爲了能真實瞭解渲染的順序,你必需要知道些沒法控制的東西:每一個異步請求的完成間隔。就譬如在上述描述的場景中,你並不知道User數據與TODO數據哪一個會先被得到,就好像薛定諤的貓,誰也不知道會是什麼結果。
有時候事件監聽器也會修改視圖狀態,這樣也會觸發另外一輪的渲染,如此遞歸,從而使得渲染序列變得更加不可知。
直接將數據存儲在視圖狀態中而且容許異步的事件監聽器去修改這些視圖狀態最大的問題在於將數據獲取、數據處理與視圖渲染混合在一塊兒,就比如作一盤亂麻般的意大利麪條:
Nondeterminism = Parallel Processing + Shared State
而Flux框架所作的就是嚴格的隔離規範與順序操做保證這樣一種可控性:
首先,某個時刻咱們都有已知而且固定的狀態
而後,根據該狀態進行視圖渲染,在視圖渲染的過程當中並不會受到任何異步監聽器的影響,而且保證在相同的狀態下會渲染出相同的視圖
Event Listeners負責監聽用戶輸入或者網絡請求,在接受到異步的觸發以後,會將Actions投遞到Dispatcher
在某個Action分發以後,狀態會根據Action更新到下個已知的狀態,僅有經過分發的Action才能修改全局狀態
在上述的Flux架構中,視圖負責監聽用戶輸入而且將之轉化爲Action對象,而後Action對象會被投遞到Store中。Store在接收到Action以後會根據不一樣的Action類型與載荷數據更新應用狀態,而後通知View進行重渲染。固然,View並不是惟一的輸入與事件觸發源,不過咱們也能夠經過設置其餘的事件監聽器來派發其餘的Action對象,以下所示:
另外須要注意的是,Flux中的狀態更新是事務性的,不一樣於簡單的調用狀態更新函數或者直接操做對象值,任何一個Action對象都是事務記錄。能夠把它類比於銀行中的交易,當你存入一筆錢到你的帳戶時,並不會覆蓋清除你5分鐘以前的交易記錄,而會將新的結算信息添加到事務的歷史記錄中。一個Action對象以下所示:
{ type: ADD_TODO, payload: 'Learn Redux' }
Action對象容許咱們將全部對於對象的操做所有記錄下來,而這些記錄能夠經過可控的方式進行狀態重現,也就是說,在相同的初始狀態下只要將相同的事務以相同的順序進行執行,就可以獲得相同的狀態結果。總結一下,在這樣一種可控的狀態管理中,咱們可以方便地達成如下目標:
易測試性
方便地Undo/Redo
Time Travel Debugging
可重現性:即便某個狀態已經被清除了,可是隻要你保留有事務處理的歷史記錄,你就能夠重現該狀態
若是你的UI工做流程自己便不復雜,那麼還堅持要用Redux就有點大材小用,過分使用了。譬如你打算弄一個剪刀錘子布的小遊戲,你以爲你須要Undo/Redo功能嗎?這種遊戲每局差很少一分鐘左右吧,即便用戶把遊戲弄崩潰了,也只要簡單的重啓遊戲便可。當你打算啓動一個新項目時,你能夠考量如下幾點來判斷是否須要Redux:
用戶工做流比較簡單
用戶之間並不會有所協做交互
並不須要關心Server Side Events或者WebSockets
對於每一個View而言只須要從單一的數據源抓取數據
這種時候你並不須要花費額外的精力來維持可控的可重現的狀態,這時候你就能夠嘗試使用MobX。不過,隨着你的應用的功能增長,複雜度的增長,事務型的狀態就有所必要了,而MobX並無提供這種事務型的狀態管理:
用戶工做流比較複雜
應用中可能包含不少不一樣性質的工做流,譬若有普通用戶與管理員之分
用戶之間會發生交互
使用WebSockets或者SSE
對於單一視圖也須要從多個EndPoint抓取數據
這時候你再引入事務型狀態管理模型那就棋逢對手,物有所值了。爲啥說WebSockets與SSE狀態下建議引入Transactional State呢?隨着你不斷地增長異步I/O源,你會愈來愈難以在模糊的狀態管理中理解到底會發生啥。在我我的的理解中,大部分的SAAS產品的UI工做流都挺複雜的,那麼此時使用相似於Redux這樣的事務型狀態管理解決方案可以增長應用的健壯性與可擴展性。
Flux規定了單向的數據流規範與基於Action對象的事務型狀態管理,不過Flux並無指明應該如何處理Action對象,這也是Redux獨有的特色之一。當咱們初學Redux狀態管理時,不可避免地會接觸到Reducer的概念,那麼何謂Reducer函數呢?
在函數式編程中,常見的兩個輔助函數reducer()
與fold()
經常被用於將列表中的某個值轉化爲某個單一的輸出值。這裏就給出了一個基於Array.prototype.reduce()
函數的求和Reducer的例子:
const initialState = 0; const reducer = (state = initialState, data) => state + data; const total = [0, 1, 2, 3].reduce(reducer); console.log(total); // 6
不一樣於面向某個數組進行操做,Redux提供的Reducer函數主要是面向Action對象流進行操做,咱們上文中有提到Action對象大概是這樣的:
{ type: ADD_TODO, payload: 'Learn Redux' }
咱們能夠將上述的求和Reducer轉化爲以下的Redux風格的Reducer:
const defaultState = 0; const reducer = (state = defaultState, action) => { switch (action.type) { case 'ADD': return state + action.payload; default: return state; } };
而後咱們就可使用一系列的Action對象進行測試了:
const actions = [ { type: 'ADD', payload: 0 }, { type: 'ADD', payload: 1 }, { type: 'ADD', payload: 2 } ]; const total = actions.reduce(reducer, 0); // 3
爲了能保證可控的狀態重現,Reducers必須保證爲純函數,即毫無反作用。所謂純函數,會有以下特性:
相同的輸入會有相同的輸出
並無任何的反作用
須要注意的是,在JavaScript中,全部的傳入函數中的非原始類型都會以引用形式傳遞,換言之,若是你傳入了某個Object對象,而後在函數中直接改變了其屬性值,那麼函數外的該Object對象屬性值也會發生變化。這也就是所謂的反作用,若是你不知道某個傳入函數中的對象的所有操做記錄你也就沒法知道該函數的真實返回值。這也就致使了整個函數的不可控性與不肯定性。
Reducers應該返回某個新的Object對象,譬如使用Object.assign({}, state, { thingToChange })
來修改而且得到某個對象值。而對於全部的Array參數,它們一樣是引用類型傳入的,你不能直接使用push()
、pop()
、.shift()
、unshift()
、reverse()
、splice()
或者相似的操做來修改傳入的數組。咱們應該使用concat()
函數來代替push()
進行操做,譬如咱們須要添加某個Reducer來處理ADD_CHAT
事件:
const ADD_CHAT = 'CHAT::ADD_CHAT'; const defaultState = { chatLog: [], currentChat: { id: 0, msg: '', user: 'Anonymous', timeStamp: 1472322852680 } }; const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); default: return state; } };
如你所見,咱們可使用Object.assign()
或者Array中的concat()
函數來建立新的對象。若是你但願在JavaScript中如何使用純函數,那麼能夠參考master-the-javascript-interview-what-is-a-pure-function這篇文章。
何謂Single Source of Truth?即應用中的全部狀態都存放在單一的存儲中,任何須要訪問狀態的地方都須要經過該存儲的引用進行訪問。固然,對於不一樣的業務邏輯/不一樣的事物能夠設置不一樣的狀態源,譬如URL能夠認爲是用戶輸入與請求參數的Single Source of Truth。而應用中存在某個Configuration Service用於存放全部的API URLs信息。而若是你選用Redux做爲狀態管理框架,任何對於狀態的訪問操做都必須經過Redux。換言之,若是你沒有使用單一的狀態存儲源,你可能會失去以下的特性:
可肯定的/可控的視圖渲染
可肯定的/可控的狀態重現
方便的Undo/Redo
Time Travel Debugging
易測性
咱們但願Action歷史記錄中的Action易於追蹤易於理解,若是全部的Action都是設置了較短的、通用性的譬如CHANGE_MESSAGE
這樣的名字,也就會難以理解APP中到底發生了啥。而若是Action類型能有更具說明性的命名,譬如:CHAT::CHANGE_MESSAGE
,可讓咱們在調試的時候更方便地去理解到底發生了啥。所以,咱們建議將全部在Reducer中用到的Action聲明歸結到一個文件中(文中建議是放置到Reducer文件的首部),而且在文件頭部顯式聲明該類型,這會有助於你:
保證命名的一致性
快速理解Reducer API功能
發現Pull Request中所作的修改
有時候,當我跟別人說你並不能在Reducer中進行相似於ID生成或者獲取當前時間等操做時,不少人以一種關懷智障的表情看着我。不過平心而論,最合適的來處理有反作用的邏輯而不是在每次須要構建該Action的時候就寫一遍代碼的地方當屬Action Creator。Action Creator的優勢可列舉以下:
不須要在不少地方導入聲明在Reducer文件中的Action類型常量
在實際的分發Action以前能夠進行些簡單的計算或者輸入轉換
減小模板代碼的數量
這裏咱們嘗試使用Action Creator來建立ADD_CHAT
Action對象:
// Action creators can be impure. export const addChat = ({ // cuid is safer than random uuids/v4 GUIDs // see usecuid.org id = cuid(), msg = '', user = 'Anonymous', timeStamp = Date.now() } = {}) => ({ type: ADD_CHAT, payload: { id, msg, user, timeStamp } });
這裏咱們使用cuid來爲每條聊天記錄構建標識,使用Date.now()
來生成時間戳。這些帶有反作用的操做是絕對不能運行在Reducer中的,不然就會破壞Reducer的事務型狀態管理的特性。
有些人可能會認爲使用Action Creator會增長項目中的代碼的數量,不過做者認爲偏偏相反的是,經過引入Action Creator能夠方便地減小Reducer中的代碼的數量。譬如咱們須要添加兩個功能,容許用戶自定義它們的用戶名與在線狀態,那麼咱們可能須要添加兩個Action Type到Reducer中:
const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); case CHANGE_STATUS: return Object.assign({}, state, { statusMessage: payload }); case CHANGE_USERNAME: return Object.assign({}, state, { userName: payload }); default: return state; } };
對於一個須要處理複雜邏輯的Reducer,這些細微的功能需求可能使其迅速變得龐雜。而做者在平常的工做中會構建不少比這個更加複雜的Reducer,裏面充斥着大量重複冗餘的代碼。而咱們又該如何簡化這些代碼呢?咱們能夠嘗試將全部對於簡單狀態的改變合併到單個Action中完成:
const chatReducer = (state = defaultState, action = {}) => { const { type, payload } = action; switch (type) { case ADD_CHAT: return Object.assign({}, state, { chatLog: state.chatLog.concat(payload) }); // Catch all simple changes case CHANGE_STATUS: case CHANGE_USERNAME: return Object.assign({}, state, payload); default: return state; } };
儘管咱們須要添加額外的註釋,這種樣子實現也會比原來兩個單獨的Case處理要減小不少的代碼。另外咱們會關注到在Reducer文件中常常出現的關鍵字:switch,可能你在其餘文章中有看到過,應該避免使用switch
語句,特別是須要避免對於落空狀態的依賴與處理,不然會讓整個Case的處理變得異常腫脹。不過做者認爲:
Reducers的一個重要特性就是可組合性,所以咱們徹底能夠經過組合Reducer來避免過於腫脹的Case處理出現。當你以爲某個Case列表太長的時候,將其切分到不一樣的Reducer中便可。
保證每一個Case處理的最後都添加一個返回語句,這樣就不會陷入未命中的狀況了。
最後,與上面Reducer相匹配的Action Creator,應該遵循以下寫法:
export const changeStatus = (statusMessage = 'Online') => ({ type: CHANGE_STATUS, payload: { statusMessage } }); export const changeUserName = (userName = 'Anonymous') => ({ type: CHANGE_USERNAME, payload: { userName } });
如代碼所示,Action Creator再也不僅僅單純地構造出某個Action對象,還將傳入的參數轉化爲了Reducer中所須要的形式,從而簡化了Reducer中的代碼。
若是你使用譬如Sublime Text或者Atom這樣流行的編輯器,它會自動地讀取ES6的默認解構值而且幫你在調用某個Action Creator時推導出必須的參數有哪些,這樣你就能方便地使用智能提示與自動完成功能了。這一特性可以簡化開發者額外的認知壓力,他們不用再煩惱於由於老是記不住Payload的形式而不得不常常翻閱源代碼了。因此這裏推薦你可使用相似於Tern、TypeScript或者Flow這樣的類型推導插件或者強類型語言。不過筆者是更推薦使用ES6在解構賦值中提供的默認解構值的特性做爲函數簽名,而不是使用類型註解,緣由以下:
這樣就能夠只學習標準的JavaScript而不須要再去學習Flow或者TypeScript這樣的JavaScript超集
越少的語法能保證越好的可讀性
使用默認值有助於在CI時避免類型錯誤,也能在運行時避免觸發大量的undefined
參數賦值
假如你已經構建好了一個數萬行代碼的複雜的聊天APP應用,而後親愛的產品經理跟你說須要添加一個新的Exciting特性進去,而不得不要修改你現有的狀態樹中的數據結構。不方,這裏介紹的Selector便是一種有效地將狀態樹的結構與應用的其餘部分解耦和的工具。
基本上對於我寫的每一個Reducer,我都會建立一個對應的Selector來將全部須要用於構建View的變量導出,對於簡單的Chat Reducer,可能要以下所寫:
export const getViewState = state => Object.assign({}, state, { // return a list of users active during this session recentlyActiveUsers: [...new Set(state.chatLog.map(chat => chat.user))] });
若是你將須要對狀態所作的簡單的計算放置到Selector中,你能夠獲得以下的遍歷:
遵循了職責分割的原則,減小了Reducer與Components的複雜度
將應用的其餘部分與狀態結構解耦和
不少的研究都有專門對比過Test-First、Test-After以及No-Test這三個不一樣的開發模式,結果都是類似的:大部分研究都代表在開發以前先編寫測試用例可以減小40-80%Bug出現的比率。即便在編寫本文中全部的例子以前,我都會先寫好對應的測試用例。爲了不過於簡單的測試用例,我編寫了以下的工廠方法來產生預測值:
const createChat = ({ id = 0, msg = '', user = 'Anonymous', timeStamp = 1472322852680 } = {}) => ({ id, msg, user, timeStamp }); const createState = ({ userName = 'Anonymous', chatLog = [], statusMessage = 'Online', currentChat = createChat() } = {}) => ({ userName, chatLog, statusMessage, currentChat });
注意,這兩個工廠方法中我都提供了默認值,也就保證了我在編寫測試用例時僅須要將我感興趣的參數傳入,其餘的參數使用默認值便可。
describe('chatReducer()', ({ test }) => { test('with no arguments', ({ same, end }) => { const msg = 'should return correct default state'; const actual = reducer(); const expected = createState(); same(actual, expected, msg); end(); }); });
這裏我是使用Tape做爲默認的TestRunner,以前我也有2~3年的時間在使用Mocha與Jasmine,還有不少其餘的框架。你應該可以注意到我傾向於使用嵌套的測試用例編寫方式,多是受以前使用Mocha與Jasmine較多的影響,我習慣先在外層聲明某個測試組件,而後在內層聲明組件的傳入參數。這裏我分別給出對於Action Creator、Selector的測試用例。
describe('addChat()', ({ test }) => { test('with no arguments', ({ same, end}) => { const msg = 'should add default chat message'; const actual = pipe( () => reducer(undefined, addChat()), // make sure the id and timestamp are there, // but we don't care about the values state => { const chat = state.chatLog[0]; chat.id = !!chat.id; chat.timeStamp = !!chat.timeStamp; return state; } )(); const expected = Object.assign(createState(), { chatLog: [{ id: true, user: 'Anonymous', msg: '', timeStamp: true }] }); same(actual, expected, msg); end(); }); test('with all arguments', ({ same, end}) => { const msg = 'should add correct chat message'; const actual = reducer(undefined, addChat({ id: 1, user: '@JS_Cheerleader', msg: 'Yay!', timeStamp: 1472322852682 })); const expected = Object.assign(createState(), { chatLog: [{ id: 1, user: '@JS_Cheerleader', msg: 'Yay!', timeStamp: 1472322852682 }] }); same(actual, expected, msg); end(); }); });
這個例子有個頗有趣的地方在於,addChat()
Action Creator自己非純函數。也就是說除非你傳入特定的值進行覆蓋,不然你並不能預測它到底會生成怎樣的屬性。所以在這裏咱們使用了pipe函數,將那些咱們不關注的變量值忽略掉。咱們只會關心這些值是否存在,可是並不會關心這些值到底如何。對於pipe函數的詳細用法能夠以下所示:
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); const fn1 = s => s.toLowerCase(); const fn2 = s => s.split('').reverse().join(''); const fn3 = s => s + '!' const newFunc = pipe(fn1, fn2, fn3); const result = newFunc('Time'); // emit!
describe('getViewState', ({ test }) => { test('with chats', ({ same, end }) => { const msg = 'should return the state needed to render'; const chats = [ createChat({ id: 2, user: 'Bender', msg: 'Does Barry Manilow know you raid his wardrobe?', timeStamp: 451671300000 }), createChat({ id: 2, user: 'Andrew', msg: `Hey let's watch the mouth, huh?`, timeStamp: 451671480000 }), createChat({ id: 1, user: 'Brian', msg: `We accept the fact that we had to sacrifice a whole Saturday in detention for whatever it was that we did wrong.`, timeStamp: 451692000000 }) ]; const state = chats.map(addChat).reduce(reducer, reducer()); const actual = getViewState(state); const expected = Object.assign(createState(), { chatLog: chats, recentlyActiveUsers: ['Bender', 'Andrew', 'Brian'] }); same(actual, expected, msg); end(); }); });