Flux與面向組件化開發
首先要明確的是,Flux并不是一個前端框架,而是前端的一個設計模式,其把前端的一個交互流程簡單的模擬成了一個單向數據流。
在上圖中,我們可以看到Flux的四個核心構成:
Action
一個交互動作,來源于用戶在頁面組件上的某個行為,如點擊,失焦,雙擊等等。其往往具有兩個組成:
* 交互類型 ,例如創建、刪除、更新等
* 交互體,或者說交互的攜帶信息, 例如創建的文本
Dispatcher
分發器,從上圖的數據流中,我們可以看到,用戶產生的一個交互行為將被送入Dispatcher,分發器對Action進行簡單的包裹之后分發該行為到所有 向其注冊了的Store中。
!注意,Dispatcher的這種廣播行為有別于Pub/Sub模型,在Pub/Sub模型中,需要聲明訂閱的消息類型,然后發布者會像訂閱者廣播特定類型的消息。而在Dispatcher中,Store向其注冊的任意回調接口都不要聲明訂閱的Action類型,當Dispatcher派發Action時,所有注冊到Dispatcher的callback都會得到相應?;卣{可以通過簡單工廠模式(通常是一個switch塊)來針對不對類型的Action做出不同的行為。
Store
數據存儲倉,其保存了我們某個前端App的數據,并封裝了對于數據的操作。Store會向其對應的Dispatcher注冊一個回調函數,其參數為一個交互。當Action被派發到Store時,該回調函數被調用,借由Action中描述的交互類型,Store進行不同處理(一個簡單工廠模式),這些處理都將被持久化到Store維護的數據對象上。
Store完成數據的變更后,由于Flux并不是雙向數據綁定的,所以此時,頁面組件的數據并未得到更新,組件也不會重新渲染。所以,為了告知組件去更新數據,Store會emit一個變更事件,并且監聽該事件。當監聽到變更事件產生時,注冊到這個事件上的回調(往往是我們App的狀態維護器的狀態更新函數)會被調用,從而更新各個組件的狀態。
View
顯而易見,這就是用戶所能看到的視圖,有別于傳統的MVC,在Flux中,View并不會和數據模型(Model)產生交互,其只會產生各種交互行為(Actions),這些行為將會被送到Dispatcher中,如下圖所示:
TODO栗子
下面我們分析一個用React+Flux實現的一個Flux栗子,其源碼托管在github上。
在項目實踐中,面向組件化開發的最佳場景我認為是 交互驅動型的開發,可能描述不夠準確,準確點說就是一旦一個完善的交互設計稿產生時,我們就可以去分割和分析組件了,我們現在來分析Todo的交互原型:
這是交互設計師的給我們的原稿,并且,原稿可能遠不止這樣一幅簡單的圖像,可能還包括更多的交互效果
我們將會把這個應用拆分為如下組件:
TodoApp
通常,在前端面向組件化的開發過程中,我們往往需要一個頂部容器包裹住我們的組件,一個頁面可以存在若干個這樣的頂部容器,這個容器類似一個集裝箱或者盒子,封裝了某個頁面應用的所有組件和狀態。例如,在某視頻網站中,視頻播放窗口可以作為一個頂部容器,其包裹了播放窗口,進度條,播放選項等各個組件,同時,評論部分也可以作為一個頂部容器,其包裹了評論列表,評論框等組件。
在Todo例子中,TodoApp作為一個頂部容器,包裹了所有Todo應用需要的組件,這樣,我們在應用入口只需要渲染TodoApp就完成了整個TodoApp的渲染。但更為重要的是,TodoApp將會封裝其下各個組件需要用到的狀態,通過數據流,各個組件將會收到狀態,并且在狀態改變時,重新渲染自己,最終更新頁面內容。
Header
這是一個頭部組件,根據交互設計,他除了將保有靜態的“todos”文字標題以外,還將會具有如下行為:
- 右側輸入框失焦或者相應回車鍵:創建新的任務
Footer
這是一個底部組件,它將顯示未完成任務數,并能刪除所有已完成任務,故而,首先他需要獲得如下狀態:
- 所有任務:
- 通過遍歷任務的完成情況,能獲得未完成任務數
- 通過遍歷任務的完成情況,統計已完成任務的信息
- 如果當前無任務,不現實Footer
并且,他具有如下行為:
- 單擊右側按鈕(Clear completed): 清除所有已完成任務
MainSection
該組件將會負責渲染所有的以創建任務,因而他需要維護的狀態為:
- 所有任務
其具有的行為:
- 點擊頂部左側圖標按鈕:完成/取消完成所有任務,具體根據所有任務是否都完成了決定
TodoItem
這是Todo項,其Todo對象來源于MainSection的迭代,并且該組件具有如下行為:
- 單擊左側按鈕:完成/取消完成該任務
- 單擊右側按鈕:刪除該Todo
- 雙擊Todo文本:進入如下的編輯模式
我們不難發現,“是否處于編輯模式”實際上可作為該組件的一個狀態,該狀態的切花直接影響了該組件的展示和行為,所以,組件應當維護一個狀態:
- 是否編輯模式
在編輯模式中,具有如下行為:
- 輸入框失焦或者相應回車鍵:更新任務
可以看到,在Header組件及TodoItem組件的輸入框組件具有一致的交互行為,所以,我們可以將其提出來作為單獨的組件,這也體現了,一份晚上的交互設計原型將預測到實現過程中的復用和抽象,避免了一些代碼重構的時間。
TodoTextInput
現在,我們抽象出一個可復用的輸入組件TodoTextInput,他具有如下行為:
- 輸入框失焦或者相應回車鍵:調用存儲過程(創建,更新等等)
綜上,我們以一個簡單的示意圖表示如上的劃分:

上圖藍色橢圓封裝的屬性, 黃色橢圓封裝的是狀態。 在每個TodoItem中,還需要單獨維護一個”是否可編輯狀態”,該狀態決定了TodoItem的行為和展示。
注意到,因為所有任務這個狀態會被多個組件共享(MainSection,Footer),所以,該狀態被提到了頂部容器TodoApp中進行維護,這樣,通過TodoApp的SetState()方法,所有綁定到TodoApp的組件都獲得了狀態更新,避免了組件間的相互引用,實現了組件解耦(唯一的耦合存在于組件與頂層容器),如下圖所示:

倘若我們在MainSection及Footer中分別維護這個狀態,由于MainSection與Footer屬于平級的組件,所以,當MainSection中的所有任務這一狀態發生改變時,為使Footer中的狀態也發生改變,為此,MainSection及Footer組件都要保存對方引用,二者將會是強耦合的,如下圖所示:

設想,如果以后還有更多的組件需要所有任務這一狀態,這一設計模式將會是十分糟糕的,任何一個組件的脫離將可能導致整個引用網絡的崩潰,如下圖所示:

封裝
其中app.js為應用的入口文件,通常,單頁面應用(SPA)都需要提供一個最初的文件,然后遞歸渲染DOM樹。
下面,開始實現我們的邏輯,順著Flux的單向數據流,逐個分析Todo例子中的實現。
Dispatcher
js/AppDispatcher.js
var Dispatcher = require('flux').Dispatcher;
module.exports = new Dispatcher();
可以看到,Dispatcher的實現主要依賴于官方的flux提供支持。我們可以看下flux中的Dispatcher源碼,所有解說都放在代碼注釋中:
首先看到Dispatcher的構造函數:
function Dispatcher() {
_classCallCheck(this, Dispatcher);
this._callbacks = {}; // 保存向Dispatcher注冊回調函數
this._isDispatching = false; // 是否正在分派Action
this._isHandled = {}; // 已經完成執行的回調列表
this._isPending = {}; // 正在執行中的回調列表
this._lastID = 1; // 回調Id的起始標志
}
再看注冊方法register(callback),每個向Dispatcher的注冊的回調(callback)都擁有唯一Id進行標識:
/**
* 向Dispatcher注冊回調函數,每個回調函數都有唯一id進行標識
* @param callback
* @returns {string} 注冊回調的id
*/
Dispatcher.prototype.register = function register(callback) {
var id = _prefix + this._lastID++;
this._callbacks[id] = callback;
return id;
};
/**
* 根據id刪除回調
*/
Dispatcher.prototype.unregister = function unregister(id) {
!this._callbacks[id] ? process.env.NODE_ENV !== 'production' ? invariant(false, 'Dispatcher.unregister(...): `%s` does not map to a registered callback.', id) : invariant(false) : undefined;
delete this._callbacks[id];
};
執行一個注冊了的回調函數將經歷如下過程:
- 標識當前正在執行的回調為進行中(Pending)狀態
- 將用戶行為(payload)送回調執行
- 執行完成,標識該回調已經完成(Handled)
/**
* 執行回調函數,該過程為:
* 1. 標識當前正在執行的回調為Pending狀態
* 2. 將payload送入回調執行
* 3. 執行完成,標識該回調已經完成
* @internal
*/
Dispatcher.prototype._invokeCallback = function _invokeCallback(id) {
this._isPending[id] = true;
this._callbacks[id](this._pendingPayload);
this._isHandled[id] = true;
};
派發dispatch(payload)指定的用戶行為payload到所有的callback將經歷如下過程:
首先,需要明確的是能夠進行派發的前提是當前Dispatcher為空閑狀態,接下來
-
派發前的預處理_startDispatching()
- 初始化所有回調的狀態
- 設置當前正在分發的payload
- 標識當前的Dispatcher狀態為"正在進行派發"
根據注冊順序依次執行回調_invokeCallback(id)
-
派發結束后的收尾工作_stopDispatching()
- 清除派發對象
- 標識當前的Dispatcher狀態為"結束派發"
/**
* 派發一個payload到所以已注冊的callback中
*/
Dispatcher.prototype.dispatch = function dispatch(payload) {
!!this._isDispatching ? process.env.NODE_ENV !== 'production' ? invariant(false, 'Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch.') : invariant(false) : undefined;
this._startDispatching(payload);
try {
for (var id in this._callbacks) {
if (this._isPending[id]) {
continue;
}
this._invokeCallback(id);
}
} finally {
this._stopDispatching();
}
};
/**
* 分發payload前的初始化:
* 1. 初始化所有回調的狀態
* 2. 設置當前正在分發的payload
* 3. 標識當前"正在進行派發"
* @internal
*/
Dispatcher.prototype._startDispatching = function _startDispatching(payload) {
for (var id in this._callbacks) {
this._isPending[id] = false;
this._isHandled[id] = false;
}
this._pendingPayload = payload;
this._isDispatching = true;
};
/**
* 結束派發時的收尾工作
* 1. 清除派發對象
* 2. 標識當前"結束派發"
* @internal
*/
Dispatcher.prototype._stopDispatching = function _stopDispatching() {
delete this._pendingPayload;
this._isDispatching = false;
};
waitFor
再看Dispatcher中一個很重要的方法:waitFor(ids), 顧名思義,該方法的作用是等待指定的回調的函數調用完成。因而,該方法主要保證了回調函數的執行的順序性。
例如,在一個航班訂票系統中,我們首先要選擇完國家(Country),才能選擇城市(City),所以,當一個類型為“更新選擇國家”的交互被送到CityStore所注冊的回調時,為了保證能正確的選擇更新后國家的城市
CityStore.dispatchToken = flightDispatcher.register(function(payload) {
if (payload.actionType === 'country-update') {
/*
* 如果不執行waitFor(),那么可同CityStore的回調先于ContryStore的回調執行
* 此時的國家尚未更新,得到的默認城市是錯誤的,而并不是最新的
* */
flightDispatcher.waitFor([CountryStore.dispatchToken]);
// waitFor()保證了ContryStore先響應了'country-update',即保證了國家更新先于城市更新
// 此時我們能正確的選擇該國家的城市
CityStore.city = getDefaultCityForCountry(CountryStore.country);
}
});
下面我們看waitFor()的源碼實現:
/**
* 等待指定的回調完成
*/
Dispatcher.prototype.waitFor = function waitFor(ids) {
!this._isDispatching ? process.env.NODE_ENV !== 'production' ? invariant(false, 'Dispatcher.waitFor(...): Must be invoked while dispatching.') : invariant(false) : undefined;
for (var ii = 0; ii < ids.length; ii++) {
var id = ids[ii];
if (this._isPending[id]) {
!this._isHandled[id] ? process.env.NODE_ENV !== 'production' ? invariant(false, 'Dispatcher.waitFor(...): Circular dependency detected while ' + 'waiting for `%s`.', id) : invariant(false) : undefined;
continue;
}
!this._callbacks[id] ? process.env.NODE_ENV !== 'production' ? invariant(false, 'Dispatcher.waitFor(...): `%s` does not map to a registered callback.', id) : invariant(false) : undefined;
this._invokeCallback(id);
}
};
Store實現
在js/stores/TodoStore.js中:
首先,我們維護我們的數據對象,并提供若干對于該數據的操作:
// 保存TODO列表
var _todos = {};
/**
* 創建一個 Todo
* @param text {string} Todo內容
*/
function create(text) {
// ...
}
/**
* 更新一個 TODO item
* @param id {string}
* @param updates {object} 待更新對象的屬性
*/
function update(id, updates) {
// ...
}
/**
* 根據一個更新屬性值對象更新所有 Todo
* @param updates {object}
*/
function updateAll(updates) {
// ...
}
/**
* 刪除 Todo
* @param id {string}
*/
function destroy(id) {
// ...
}
/**
* 刪除所有的已完成的 TODO items
*/
function destroyCompleted() {
// ...
}
然后導出一個全局單例,該單例提供了常用的外部訪問接口,并且通過node提供的EventEmitter來實現事件的派發和監聽:
var TodoStore = assign({}, EventEmitter.prototype, {
/**
* 是否所有TODO 都已完成
* @return {boolean}
*/
areAllComplete: function () {
// ...
},
/**
* 獲得所有的TODO
* @returns {object}
*/
getAll: function () {
// ...
},
/**
* 發送變更事件
*/
emitChange: function () {
// ...
},
/**
* 添加變更事件監聽
* @param callback
*/
addChangeListener: function (callback) {
// 一旦受到變更事件, 觸發回調
/*
* 例如, 當我們創建一條todo時,
* TodoStore將會發出一條變更事件,
* 上游的狀態維護器將會調用callback進行狀態更新
*/
this.on(CHANGE_EVENT, callback);
},
/**
* 刪除變更事件監聽
* @param callback
*/
removeChangeListener: function (callback) {
this.removeListener(CHANGE_EVENT, callback);
}
});
最后,我們需要向AppDispatcher注冊回調函數,以便在payload被分發到TodoStore時,TodoStore能做出相應:
AppDispatcher.register(function callback(action) {
var text;
// 根據不同的action類型(即不同的交互邏輯), 執行不同過程
switch (action.actionType) {
case TodoConstants.TODO_CREATE:
text = action.text.trim();
if( text!=='') {
create(text);
// 一旦變更,發出變更事件,
TodoStore.emitChange();
}
break;
case TodoConstants.TODO_TOGGLE_COMPLETE_ALL:
// ...
break;
case TodoConstants.TODO_UNDO_COMPLETE:
// ...
break;
case TodoConstants.TODO_COMPLETE:
// ...
break;
case TodoConstants.TODO_UPDATE_TEXT:
// ...
break;
case TodoConstants.TODO_DESTROY:
// ...
break;
case TodoConstants.TODO_DESTROY_COMPLETED:
// ...
break;
default:
// no op
}
});
!注意, 在回調執行過程中,如果發生狀態的變動,需要發出變更事件,以便上游注冊的回調函數能夠獲得相應并更新狀態到下游。
Actions
我們將TodoApp中常見的Action都封裝到了js/TodoActions.js中, 通過其中的AppDispatcher單例,我們可以將Action派發出去:
var TodoActions = {
/**
* 創建行為
* @param text {string}
*/
create: function (text) {
// 將創建行為送到Dispatcher, Dispatcher派發這個行為(action對象)到各個Store
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_CREATE,
text: text
});
},
/**
* 更新行為
* @param id {string}
* @param text {string}
*/
updateText: function (id, text) {
// ...
},
/**
* 全部設置為完成
* @param todo
*/
toggleComplete: function (todo) {
// ...
},
/**
* 標記所有的Todo為已完成
*/
toggleCompleteAll: function () {
// ...
},
/**
*
* @param id
*/
destroy: function (id) {
// ...
},
/**
* 刪除所有已完成的Todo
*/
destroyCompleted: function() {
// ...
}
};
Components
下面開始實現各個組件, 個人偏向的流程是先在組件目錄下創建好各個組件文件,并以如下內容先導出,亦即,我們先創建空白組件,之后再依序進行裝填
var React = require('react');
var Header = React.createClass({
render: function () {
// TODO::render
},
});
module.exports = Header;
裝填順序我會選擇先裝填頂部容器(此例中即為TodoApp),之后按照DOM樹自底向上的進行裝填:
TodoApp.react.js:
var Footer = require('./Footer.react');
var Header = require('./Header.react');
var MainSection = require('./MainSection.react');
var React = require('react');
var TodoStore = require('../stores/TodoStore');
// 在根DOM下維護狀態,
// 這樣的狀態往往是共享狀態(會向下傳遞的狀態)
function getTodoState() {
return {
allTodos: TodoStore.getAll(),
areAllComplete: TodoStore.areAllComplete()
};
}
var TodoApp = React.createClass({
getInitialState: function () {
return getTodoState();
},
/**
* 綁定生命期--掛載
*/
componentDidMount: function () {
// 掛載時再為TodoStore添加監聽器
TodoStore.addChangeListener(this._onChange);
},
componentWillUnmount: function () {
TodoStore.removeChangeListener(this._onChange);
},
render: function () {
return (
<div>
<Header />
<MainSection
allTodos={this.state.allTodos}
areAllComplete={this.state.areAllComplete}
/>
<Footer allTodos={this.state.allTodos}/>
</div>
);
},
/**
* Event handler for 'change' events coming from the TodoStore
*/
_onChange: function() {
this.setState(getTodoState());
}
});
module.exports = TodoApp;
為了方便,TodoApp不僅維護allTodos(所有任務)這個狀態,還維護areAllComplete(是否所有任務都已完成),該狀態主要服務于MainSection中的---”完成所有/取消完成所有任務“這一用例,避免重復遍歷allTodos的開銷。
我們可以看到,TodoApp提供了一個_onChange()方法作為TodoStore的change事件的回調,當TodoStore發出change事件時,TodoApp將刷新狀態,借此通知其下組件如MainSection等重新渲染。通過這樣一個頂層組件,我們不用把對Store的事件監聽和俘獲進行集中化處理,避免在更多的組件的中監聽Store的事件。
更多組件的實現不再贅述。下面著重介紹flux的工作流程
工作流程
我們以創建新的Todo這一工作流程為例展示Flux的工作過程。在Flux中,該流程如下圖所示:
- 我們在創建Todo的輸入框中敲入數據,在輸入框上,我們監聽了失焦(onBlur)和按下鍵盤按鍵(onKeyDown)的事件
// js/components/TodoTextInput.react.js
/**
* @return {object}
*/
render: function() /*object*/ {
return (
<input
className={this.props.className}
id={this.props.id}
placeholder={this.props.placeholder}
onBlur={this._save}
onChange={this._onChange}
onKeyDown={this._onKeyDown}
value={this.state.value}
autoFocus={true}
/>
);
},
當事件發生時,調用_save()方法進行處理:
_save: function() {
this.props.onSave(this.state.value);
this.setState({
value: ''
});
},
- 注意,我們通過給TodoTextInput設定onSave屬性來指定事件發生后的回調,在Header組件中,我們通過屬性指定了這個回調,使得我們在失焦或回車按下后,能夠像Dispatch請求派發(dispatch)一個“創建行為”
// js/components/Header.react.js
/**
* @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>
);
},
/**
* 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);
}
}
我們之所以不再TodoTextInput中創建Action主要是考慮到靈活性,其save后的回調通過綁定onSave而不是寫死在save()中,可以派發種類更多的Action
- 在TodoActions.create()中,我們會請求Dispatcher派發一個Todo創建行為到TodoStore:
// js/actions/TodoActions.js
/**
* @param {string} text
*/
create: function(text) {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_CREATE,
text: text
});
},
- TodoStore在接收到Dispatcher派發來的Action之后,其注冊的回調被調用, 并且在持久化這個TODO之后,引起了全局維護的_todos的改變,所以TodoStore會發射出一個change事件:
// js/stores/TodoStore.js
AppDispatcher.register(function(action) {
var text;
switch(action.actionType) {
case TodoConstants.TODO_CREATE:
text = action.text.trim();
if (text !== '') {
create(text);
TodoStore.emitChange();
}
break;
// ...
default:
// no op
}
});
- 由于TodoApp向TodoStore注冊了一個回調監聽change事件
// js/components/TodoApp.react.js
componentDidMount: function() {
TodoStore.addChangeListener(this._onChange);
},
此時,change事件發生, 回調_onChange()被觸發, TodoApp維護的狀態得到更新:
/**
* Event handler for 'change' events coming from the TodoStore
*/
_onChange: function() {
this.setState(getTodoState());
}
- 由于MainSection及Footer等組件中的屬性綁定了TodoApp維護的狀態,所以在TodoApp刷新狀態后,二者將會重新渲染。