Flux是由Facebook提出的,用於組織應用的一種架構,它基於一個簡單的原則:資料在應用中單向流動。這就是所謂的“單向資料流”
,簡單的記法是把資料比作鯊魚:鯊魚只能向前遊。
Facebook公佈了一些Flux的範例,至少有六種第三方庫實現如雨後春筍般湧現。在本文中,當我們提及“Flux”時,我們講的是Facebook的實現。
一個Flux例子
為理解 Flux,我們們來完整做一個 Todo 基本應用。在 Facebook 的 Flux 程式碼庫,可以得到該專案的完整程式碼。
載入ToDo條目
當應用啟動的時候,ToDoApp的響應模組獲得儲存在ToDoStore中的資料並展示,ToDoStore完全不知道ToDoApp的模組。如果把模組看做是View部分、ToDoStore看做Model部分,那麼目前為止,這和MVC沒什麼不同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//TodoApp1.react.js // Loading the initial data into the application: // ... /** * Retrieve the current TODO data from the TodoStore */ function getTodoState() { return { allTodos: TodoStore.getAll(), areAllComplete: TodoStore.areAllComplete() }; } var TodoApp = React.createClass({ getInitialState: function() { return getTodoState(); }, // ... |
在這個簡單的例子中,我們不關心 ToDoStore 如何載入初始化資料。
建立一個新的ToDo條目
ToDoApp元件有一個用於建立新條目的表格,當使用者提交了表格後,它就會如上圖演示的那樣,從Flux系統中踢出一條資料流。
1. 元件通過呼叫自己的回撥方法來處理表格提交。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// Header1.react.js // Saving a new ToDo calls the '_onSave' callback // ... var Header = React.createClass({ /** * @return {object} */ render: function() { return ( <header id="header"> <h1>todos</h1> <TodoTextInput id="new-todo" placeholder="What needs to be done?" onSave={this._onSave} /> </header> ); }, // ... |
2. 元件回撥方法呼叫ToDoAction的Create方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Header2.react.js // The '_onSave' callback calls the 'TodoActions' method to create an action // ... /** * Event handler called within TodoTextInput. * Defining this here allows TodoTextInput to be used in multiple places * in different ways. * @param {string} text */ _onSave: function(text) { if (text.trim()){ TodoActions.create(text); } } |
3. ToDoAction建立一個TODO_CREATE型別的動作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// TodoActions.js // The 'create' method creates an action of type 'TODO_CREATE' // ... var TodoActions = { /** * @param {string} text */ create: function(text) { AppDispatcher.handleViewAction({ actionType: TodoConstants.TODO_CREATE, text: text }); }, // ... |
4. 該動作被髮送到排程器。
5. 排程器把該動作傳遞到Store中所有註冊了該動作的回撥方法中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// AppDispatcher.js // The 'handleViewAction' dispatches the action to all stores. // ... var Dispatcher = require('flux').Dispatcher; var assign = require('object-assign'); var AppDispatcher = assign(new Dispatcher(), { /** * A bridge function between the views and the dispatcher, marking the action * as a view action. Another variant here could be handleServerAction. * @param {object} action The data coming from the view. */ handleViewAction: function(action) { this.dispatch({ source: 'VIEW_ACTION', action: action }); } }); // ... |
6. ToDoStore有一個註冊了的監聽TODO_CREATE動作的回撥方法,因此更新了自己的資料。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// TodoStore1.js // The TodoStore has registered a callback for the 'TODO_CREATE' action. // ... /** * Create a TODO item. * @param {string} text The content of the TODO */ function create(text) { // Hand waving here -- not showing how this interacts with XHR or persistent // server-side storage. // Using the current timestamp + random number in place of a real id. var id = (+new Date() + Math.floor(Math.random() * 999999)).toString(36); _todos[id] = { id: id, complete: false, text: text }; } // Register to handle all updates AppDispatcher.register(function(payload) { var action = payload.action; var text; switch(action.actionType) { case TodoConstants.TODO_CREATE: text = action.text.trim(); if (text !== '') { create(text); } break; // ... |
7. 在更新了自己的資料後,ToDoStore發出了一個變更事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// TodoStore2.js // TodoStore emits a 'change' event after handling the action. // ... // Register to handle all updates AppDispatcher.register(function(payload) { var action = payload.action; var text; switch(action.actionType) { case TodoConstants.TODO_CREATE: text = action.text.trim(); if (text !== '') { create(text); } break; // ... default: return true; } // This often goes in each case that should trigger a UI change. This store // needs to trigger a UI change after every view action, so we can make the // code less repetitive by putting it here. We need the default case, // however, to make sure this only gets called after one of the cases above. TodoStore.emitChange(); return true; // No errors. Needed by promise in Dispatcher. }); // ... |
8.ToDoApp元件監聽到了ToDoStore的變更事件,並基於ToDoStore中最新的資料重新渲染了UI。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// TodoApp2.react.js // The component listens for changes and calls the '_onChange' callback // ... var TodoApp = React.createClass({ getInitialState: function() { return getTodoState(); }, componentDidMount: function() { TodoStore.addChangeListener(this._onChange); }, componentWillUnmount: function() { TodoStore.removeChangeListener(this._onChange); }, // ... /** * Event handler for 'change' events coming from the TodoStore */ _onChange: function() { this.setState(getTodoState()); } // ... |
Flux 與 MVC 對比
Flux是作為MVC的一種替代而問世的,其文件解釋說它“通過支援單向資料流迴避了MVC”。在比較Flux和MVC時,需理解三件事:
- 在JavaScript中,“MVC”實際上指的是“MV*”。
- Flux並不比MV*簡單。
- 相比於MV*,Flux使事情更有可能預測。
為了比較Flux和MVC,必須首先搞清楚,我們所說的MVC的含義。
ToDoMVC(一個測評站點)上給出的15個 JS 框架中,沒有一個是嚴格實現了“Model、View、Control”的設計模式。以Backbone.js為例:它擁有model和view部分,但可以說是不含有controller(控制器)的。許多JavaScript框架中控制器的角色都被view或者model吸收了,並且可能存在其他的功能類別,如路由器。
當我們用“MVC”或者“MV*”來描述JavaScript架構時,我們一般是指在處理業務邏輯和使用者互動時,其關注點是分離的;在展示和使用者互動時,其資料儲存是分割為不同的“model”的。
這個過程可能是如下方式:view從model中得到資訊並展示給使用者,然後使用者與view互動,這些互動觸發view從model中獲得更新資料,這可能會出發view中的使用者互動的更新。
題圖:基本的MVC資料流
Flux並不比MV*簡單
圖題:複雜的MVC資料流
你可能已經觀看過Facebook對Flux的介紹(注:YouTube上的一個視訊),以及關於為什麼“MVC不擴充套件”的分析,包括下圖中7對不同的model和view之間的資料流動:
這讓MVC看起來特別令人困惑——看那一堆箭頭!誰能理清圖中究竟發生了什麼?所以看起來Flux會比較簡單一點,是不?
但是在視訊中,我們沒能看到在Flux的實現中複雜的層面,一切都“簡化”到一個簡單的資料流。
圖題:基本的Flux資料流
現在有必要看看一個複雜系統的Flux實現的樣貌了。如下圖所示,你會發現相比於MVC,這裡有更多的剪頭和圖示,而不是更少。
圖題:複雜的Flux資料流
Flux實際上和MV*擁有的元件數目是相同的,這也是為什麼上圖中它看起來和MV*一樣複雜的原因——但是有個關鍵性的差別是:所有的剪頭都指向一個方向,在整個系統中形成一個閉環。
Flux使事情可預測
在Flux和MV*的圖中,都有很多事情在進行,但在可預測層面上,Flux有更好的表現。
Flux中的排程器保證系統中同時只有一個事務流,如果排程器在處理完一個已存在的事務之前收到另一個事務,則會丟擲一個錯誤:
1 |
“未捕獲錯誤:違反不變性:Dispatch.dispatch(…):不能在已排程中途再排程。” |
這是另一種讓事情變得可預測的方式,它迫使開發者構建資料資源無複雜互動的應用。
排程器還允許開發者通過使用waitFor方法,使得store在執行回撥方法前等待其他的store,從而指定各個store執行回撥方法的順序,如果程式碼中出現了兩個store互相等待的情況,排程器會丟擲一個詳實的錯誤。
在Flux的Facebook實現中,可以清楚地看到資料改變的原因,每個store都包含了其監聽的任務列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// ThreadStore.js // The case statement documents which actions this store listens to // ... ThreadStore.dispatchToken = ChatAppDispatcher.register(function(payload) { var action = payload.action; switch(action.type) { case ActionTypes.CLICK_THREAD: _currentID = action.threadID; _threads[_currentID].lastMessage.isRead = true; ThreadStore.emitChange(); break; case ActionTypes.RECEIVE_RAW_MESSAGES: ThreadStore.init(action.rawMessages); ThreadStore.emitChange(); break; default: // do nothing } }); // ... |
在這個例子中,ThreadStore 監聽 CLICK_THREAD 和 RECEIVE_RAW_MESSAGES 動作,如果store沒有像預期一樣更新,註冊器回撥方法會為我們提供啟動除錯的機會,基於此可以對它接收的所有動作做日誌記錄並堅持其資料的有效負載。
相似的,所有的元件也都維護了其監聽的所有store的列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// ThreadSection.react.js // Looking at the 'componentDidMount' will usually show // whic stores this component listens to. // ... function getStateFromStores() { return { threads: ThreadStore.getAllChrono(), currentThreadID: ThreadStore.getCurrentID(), unreadCount: UnreadThreadStore.getCount() }; } var ThreadSection = React.createClass({ getInitialState: function() { return getStateFromStores(); }, componentDidMount: function() { ThreadStore.addChangeListener(this._onChange); UnreadThreadStore.addChangeListener(this._onChange); }, componentWillUnmount: function() { ThreadStore.removeChangeListener(this._onChange); UnreadThreadStore.removeChangeListener(this._onChange); }, // ... |
上文中,我們看到了ThreadSection元件監聽ThreadStore和UnreadThreadStore變更,如果我們始終使用該方法為元件建立對store變更的監聽,那麼我們就可以確認沒有其他的store會影響到該元件的行為。
Flux分離了資料的接收和傳送環節,所以在除錯時,可以方便地跟蹤資料流以便發現錯誤之處。
在軟體工程領域,每一個選擇都是權衡之舉,Flux也不例外,下面是已經認識到的不利之處:
- 它牽扯到寫更多的樣板程式碼
- 遷移現有資源是一項大任務
- 在沒有良好的組織結構的情況下,單元測試非常困難
相比於普遍認為足夠處理資料流的數量的檔案和程式碼行數,Flux確實在應用中加入了更多,這種情況下,為新的資料資源書寫新程式碼要比在已存的Flux程式碼中新增新的內容要痛苦的多。未來我們可能通過引入程式碼生成器來快速建立Flux工程,使用Vim的snippet功能也能加快這個過程。
寫一個新的專案是體驗Flux最容易的方法。說服他人接受新的事物總是有挑戰的,本文、以及其他文件,還有來自Facebook的範例,可以為你提供教授他們的材料,你可以自信地認為,既然Facebook和其他公司都把Flux用於或大或小的生產環境,那麼它也自然能用在你的專案中。
在把已有應用遷移到Flux時,可以一次一個地嘗試將資料資源變為Flux架構。當考慮使用Flux管理應用中的一組資料時,要考慮到有多少元件使用了該資料。如果絕大部分的元件都使用了該資料,那麼把資料遷移到Flux管理之下可能會是個大工程,首次嘗試遷移到Flux時,從更孤立一些的資料開始。
使用Flux,你的元件開始依賴ActionCreator和Store,而且通常它們會互相依賴,這導致單元測試變得困難。把應用中與Store的互動重定向到頂層的“控制器”元件,那麼在執行子元件的單元測試時,就無需擔心Store的問題了。而為了測試那些確實需要傳送動作並監聽Store的元件,有一些成功的方法是偽造Store的方法,或偽造獲響應動作以及Store獲得資料的API。
你是否已經嘗試了Flux?
在Brigade,我們已經學習了轉向Flux的經驗,你是否已經嘗試了Flux?你是否在執行中遇到了任何困難?你是否解決了這些困難?我期待在未來能夠看到更多關於Flux的範例和討論。
如果你還沒有用過Flux,那麼我希望本文能夠讓你對構建Flux應用有深入的瞭解。