無標題文章

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中,如下圖所示:

Action被送入Dispatcher

TODO栗子

下面我們分析一個用React+Flux實現的一個Flux栗子,其源碼托管在github上。

在項目實踐中,面向組件化開發的最佳場景我認為是 交互驅動型的開發,可能描述不夠準確,準確點說就是一旦一個完善的交互設計稿產生時,我們就可以去分割分析組件了,我們現在來分析Todo的交互原型:

Todo交互

這是交互設計師的給我們的原稿,并且,原稿可能遠不止這樣一幅簡單的圖像,可能還包括更多的交互效果

我們將會把這個應用拆分為如下組件:

TodoApp

TodoApp

通常,在前端面向組件化的開發過程中,我們往往需要一個頂部容器包裹住我們的組件,一個頁面可以存在若干個這樣的頂部容器,這個容器類似一個集裝箱或者盒子,封裝了某個頁面應用的所有組件和狀態。例如,在某視頻網站中,視頻播放窗口可以作為一個頂部容器,其包裹了播放窗口,進度條,播放選項等各個組件,同時,評論部分也可以作為一個頂部容器,其包裹了評論列表,評論框等組件。

在Todo例子中,TodoApp作為一個頂部容器,包裹了所有Todo應用需要的組件,這樣,我們在應用入口只需要渲染TodoApp就完成了整個TodoApp的渲染。但更為重要的是,TodoApp將會封裝其下各個組件需要用到的狀態,通過數據流,各個組件將會收到狀態,并且在狀態改變時,重新渲染自己,最終更新頁面內容。

Header

TodoHeader

這是一個頭部組件,根據交互設計,他除了將保有靜態的“todos”文字標題以外,還將會具有如下行為:

  • 右側輸入框失焦或者相應回車鍵:創建新的任務

Footer

TodoFooter

這是一個底部組件,它將顯示未完成任務數,并能刪除所有已完成任務,故而,首先他需要獲得如下狀態:

  • 所有任務:
    • 通過遍歷任務的完成情況,能獲得未完成任務數
    • 通過遍歷任務的完成情況,統計已完成任務的信息
    • 如果當前無任務,不現實Footer

并且,他具有如下行為:

  • 單擊右側按鈕(Clear completed): 清除所有已完成任務

MainSection

MainSection

該組件將會負責渲染所有的以創建任務,因而他需要維護的狀態為:

  • 所有任務

其具有的行為:

  • 點擊頂部左側圖標按鈕:完成/取消完成所有任務,具體根據所有任務是否都完成了決定

TodoItem

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];
};

執行一個注冊了的回調函數將經歷如下過程:

  1. 標識當前正在執行的回調為進行中(Pending)狀態
  2. 將用戶行為(payload)送回調執行
  3. 執行完成,標識該回調已經完成(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為空閑狀態,接下來

  1. 派發前的預處理_startDispatching()

    1. 初始化所有回調的狀態
    2. 設置當前正在分發的payload
    3. 標識當前的Dispatcher狀態為"正在進行派發"
  2. 根據注冊順序依次執行回調_invokeCallback(id)

  3. 派發結束后的收尾工作_stopDispatching()

    1. 清除派發對象
    2. 標識當前的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工作流程
  1. 我們在創建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: ''
    });
 },
  1. 注意,我們通過給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

  1. TodoActions.create()中,我們會請求Dispatcher派發一個Todo創建行為到TodoStore:
// js/actions/TodoActions.js
 /**
   * @param  {string} text
   */
  create: function(text) {
    AppDispatcher.dispatch({
      actionType: TodoConstants.TODO_CREATE,
      text: text
    });
  },
  1. 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
  }
});
  1. 由于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());
  }
  1. 由于MainSection及Footer等組件中的屬性綁定了TodoApp維護的狀態,所以在TodoApp刷新狀態后,二者將會重新渲染。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • ##Flux與面向組件化開發首先要明確的是,Flux并不是一個前端框架,而是前端的一個設計模式,其把前端的一個交互...
    吳小蛆閱讀 328評論 0 0
  • 標簽 如果要配置的標簽,那么必須要先配置標簽,代表的包的概念。 包含的屬性 name包的名稱,要求是唯一的,管理a...
    偷偷得路過閱讀 1,400評論 0 0
  • 1.要做一個盡可能流暢的ListView,你平時在工作中如何進行優化的? ①Item布局,層級越少越好,使用hie...
    fozero閱讀 758評論 0 0
  • 伴隨著天天小朋友的成長,我和先生的生活重心越來越多地偏向到她這里,似乎沒有了自己的生活。 分分鐘鐘我們就變成了孩兒...
    清涼閱讀 294評論 2 8