redux,一種頁面狀態管理的優雅方案

積木村の研究所發表於2016-07-26

前端是工程能力比技術能力更重要的領域,而最近一兩年,前端在構建流程、元件化、同構渲染等方面有了深入的發展,其入行門檻也在逐步提高。

前端社群的活躍程度讓人驚歎,各種工具層出不窮,比如grunt、gulp、webpack、fis等,即便你沒有全部用過,也該瞭解過它們中的大部分,這些工具極大的解放了前端的生產力,解決了前端的構建流程問題。而React的出現將前端引入新的境界,它優雅的解決了前端UI層元件化的問題,使得元件化也成了前端專案的標配。為了提高頁面渲染速度,頁面首屏由後端直出,也已經有了很多解決方案。這裡我們探討一下容易被大家忽略的領域:頁面狀態的管理。

1. 頁面狀態

頁面上所有UI層的顯示都可以用對應的狀態描述,比如,比如當前的列表項、當前被選中的標籤等。如下圖所示:

Alt text

可以簡單的將前端專案抽象為對UI的管理和對狀態的管理,UI和狀態之間相互作用,處理它們之間的相互關係很複雜,行業內有不同的解決方案,比如以angularJS為代表的雙向繫結、以及flux提出的單向資料流。本文我們將抽象的理解facebook提出單項資料流方案flux,以及它的具體實現redux。

2. 單向資料流flux

flux是facebook提出的一種應用程式框架,其基本架構如下入所示,其核心理念是單向資料流,它完善了React對應用狀態的管理。

Alt text

上圖描述了頁面的啟動和執行原理:

1.通過dispatcher派發action,並利用store中的action處理邏輯更新狀態和view

2.而view也可以觸發新的action,從而進入新的步驟1

其中的action是用於描述動作的簡單物件,通常通過使用者對view的操作產生,包括動作型別和動作所攜帶的所需引數,比如描述刪除列表項的action:

{
    type: types.DELETE_ITEM,
    id: id
};

dispatcher用於對action進行分發,分發的目標就是註冊在store裡的事件處理函式:

dispatcher.register(function (action) {
  switch(action.type) {
    case 'DELETE_ITEM':
      sotre.deleteItem(action.id); //更新狀態
      store.emitItemDeleted(); //通知檢視更新
      break;
    default:
      // no op
  }
})

store包含了應用的所有狀態和邏輯,它有點像傳統的MVC模型中的model層,但又與之有明顯的區別,store包括的是一個應用特定功能的全部狀態和邏輯,它代表了應用的整個邏輯層;而不是像Model一樣包含的是資料庫中的一些記錄和與之對應的邏輯。

3. 一種對flux的實現,redux

隨著前端應用的複雜性指數級的提升,前端頁面需要管理的狀態也越來越多,flux給出了管理狀態的基本資料流,而redux對flux就是對它最好的實現之一,而且其對flux的理念進行了更進一步的擴充套件。

redux倡導三大原則:

1.一個物件儲存整個應用的狀態
2.狀態物件是隻讀的,只能通過action觸發改變
3.通過普通函式處理action的邏輯

其基本流程如下:

Alt text

其相對於flux有如下不同之處:

1. redux沒有dispacher,其通過普通函式處理action邏輯,並改變應用狀態
2. redux的狀態物件是immutable的,每一個action都會區域性地建立新的狀態物件

需要強調的是,redux不一定要和react搭配,它是一種應用狀態管理方案,不涉及UI層,你可以任意選擇自己的UI層;正因為redux脫離UI層,提供了整個應用狀態的管理,使得我們的開發流程有了顛覆性的改變。

我們可以在UI層ready之前,完成應用的邏輯設計和實現。比如我們將應用的邏輯設計如下:

Alt text

ADD_ITEM的action觸發todos列表狀態的改變,SET_FILTER的action觸發filter狀態的改變。因為每一個action都是簡單物件,我們可以輕易的模擬。這也就使得我們可以在UI層ready之前,對前端全部邏輯寫單獨的測試用例。

然後,在UI層ready之後,將UI層或網路層的事件對映為redux的action。比如將表單提交事件對映為ADD_ITEM,將標籤頁切換按鈕點選事件對映為SET_FILTER。UI層和邏輯層相互獨立,並僅僅通過事件與action的對映來建立聯絡,這種方案使得複雜的前端專案有了更清晰的架構。

4.redux與react的配合

redux只負責應用的邏輯層,而通過使用react-redux模組,其可以天衣無縫的和react配合。

4.1 經典案例

我們簡單瞭解一下,如何使用react+redux實現經典的todolist案例。案例的原始碼在此。最終的介面如下:

Alt text

(1) 邏輯層設計

前端應用本質上是:通過事件觸發應用狀態的改變。而redux包辦了應用狀態(state)、事件(action)、和事件處理函式(reducer),使得我們可以拋開UI層,先設計應用的邏輯層。redux使用單一物件儲存整個應用的狀態,todolist應用的狀態(state)樹如下:

{
  filter: 'show_all'
  todos: [
    {
     id: 1,
      text: 'todo1',
      marked: true
    }
  ]
}

其中filter為列表過濾策略,用於標誌底部三個按鈕選中的狀態,而todos作為代辦事項列表。上文提到過state是immutable的,只能通過action觸發對state的改變,一個編輯todo條目的action如下:

var editTodo = function (id, text) {
    return {
        type: types.EDIT_TODO,
        id: id,
        text: text
    };
}

相應的,redux提倡用使用簡單函式(又稱作reducer)處理action,如下為EDIT_TODO的處理邏輯:

module.todo = function(state, action) {
    state = state || [];
    switch (action.type) {

        case types.EDIT_TODO:
            return state.map(function(todo) {
                return todo.id === action.id ?
                    assign({}, todo, { text: action.text }) :
                    todo
            });

        default:
            return state;
    }
}

每一個action處理函式都是將action作用在old state上,從而產生new state。state、action、reducer太零散,通過createStore可以將它統一在store中,而store則代表了整個應用的邏輯。

var store = createStore(reducer);

(2) UI層設計

本案例採用react作為UI層的元件化方案,在這裡不再詳述。需要注意的是為了配合redux,在寫react元件時,我們需要對元件類別進行劃分,將其劃分為展示型元件和容器型元件。redux只需要和容器型元件通訊,而不用管理展示型元件。用一個表格可以很好的說明它們和區別:

Alt text

(3) 組合邏輯層與UI層

上文提到,redux只關注react的容器型元件,而且容器型元件可以由react-redux動態生成,以防止state注入容器型元件時的硬編碼。比如我們使用如下程式碼將應用狀態注入一個容器型元件TodoApp中:

var App = React.createClass({
    render: function() {
        return (
            <Provider store={store}>
                {function() { return <TodoApp />; }}
            </Provider>
        );
    }
});

module.exports = App;

上述程式碼通過Provider將store注入容器型元件TodoApp中。在TodoApp元件中,我們可以通過this.props來獲取store中儲存的應用狀態了:

var TodoApp = React.createClass({
    render: function () {
        var todos = this.props.todos;
        var filter = this.props.filter;
        return (
            <div>
                ......
            </div>
        );
    },
});
function mapStateToProps(state) {
    return state;
}
module.exports = connect(mapStateToProps)(TodoApp);

其中connect函式用於動態的建立容器型元件。藉助於Provider和容器型元件,我們就將應用的邏輯層和UI層組合在一起了。

4.2 高階特性

瞭解了react和redux結合的基本思路以後,讓我們一起看一看redux的高階特性。

(1) 狀態樹分治

redux提倡用一個物件儲存整個應用的狀態,而複雜應用的狀態物件是很大的,這樣會不會有效能問題?各個容器型元件都對整個應用狀態物件進行操作,會不會引起混亂?對此redux有充分的考慮。首選在邏輯層設計時,我們就應該充分的考慮到狀態樹的分治,比如在設計action的處理函式(reducer)時,針對狀態樹的不同部分,將其對應的actions處理函式儲存在不同的檔案中,redux通過combineReducers對此提供了支援。比如

var todos = require('../reducers/todos');
var filter = require('../reducers/filter');
combineReducers({filter: filter, todos: todos});

其次,在UI層我們也可以很方便的只將部分狀態樹注入某個容器型元件,redux在使用connect生成容器型元件時,接收一個函式(mapStateToProps)作為引數,該函式可以只返回整個狀態樹的部分狀態,因此,connect生成的容器型元件也就只能感知到部分狀態樹。這種方式,避免了應用狀態樹過大的混亂,通過分治降低了複雜度。如下程式碼,建立了一個只關注整個狀態樹中state.todos的容器型元件:

var TodoApp = React.createClass({
    render: function () {
        var todos = this.props.todos;
        return (
            <div>
                ......
            </div>
        );
    },
});

function mapStateToProps(state) {
    return state.todos;
}
module.exports = connect(mapStateToProps)(TodoApp);

(2) 非同步action

一般來說,非同步action並不能算是高階特性,因為它太常見了。比如傳送一個網路請求,這是再尋常不過的需求了。只是用redux觸發非同步action並不是那麼直接。我們需要首先了解redux的中介軟體概念,它可以用於在action被觸發和action到達處理函式reducer之前,對action進行處理。

Alt text

可以在建立store時,通過applyMiddleware函式提供redux的中介軟體:

createStore( todosApp,applyMiddleware(someMiddleWare))

一個典型的redux中介軟體是redux-logger,它在控制檯中記錄每一次action作用前後的應用狀態變化,非常適合在開發階段進行除錯。

var fetchTodos = function () {
    return function (dispatch) {
        return fetch('/todos');
    }
}

我們可以使用dispach函式像派發普通action一樣,派發非同步函式,非同步函式的返回值還可以是Promise,其返回值會透傳過dispch函式。

dispach(fetchTodos)
  .then(function(json){
      //handle response
  })
  .catch(function(error){
      //handle error
  });

通過網路載入資料,並在資料到達時更新應用狀態是一種比較常見的應用場景,對於這種場景,一種最優雅的方案:

1. 派發非同步函式,用於進行網路請求
2. 在網路請求完成時,派發同步action用於更新應用狀態

可以用如下程式碼表示:

var fetchTodos = function () {
    return function (dispatch) {
        return fetch('/todos')
            .then(function (json) {
                //派發同步aciton,用於更新應用狀態,初始化todo列表
                dispatch(initTodos(json.data || []));
            }).catch(function () {
                //派發同步action,用於更新應用狀態,設定載入失敗標誌
                dispatch(failLoadedTodos());
            });
    }
}

(3) 同構渲染

前後端同構,應用首屏由後端直出是近年來比較流行的效能優化方案,redux對此也有完善的支援。基本流程是:

1. 服務端初始化state
2. 將服務端state傳遞到應用的頁面端
3. 頁面端用服務端傳遞的狀態初始化應用state

在遵從這個基本流程的情況下,服務端和頁面端的使用方法開發方法基本一致,如下是服務端程式碼:

 store.dispatch(todoActions.loadInitTodos()).then(function () {
        var contentHtml = React.renderToString(
            <Provider store={store}>
                {function () {
                    return <TodoApp />;
                }}
            </Provider>
        );
        var initialState = JSON.stringify(store.getState());
        res.render('index.ejs', {contentHtml: contentHtml, initialState: initialState});
    }).catch(function(error){
        res.json({errMsg: 'internal error'})
    });

上述服務端程式碼通過派發初始化非同步函式更新應用狀態,該非同步函式返回一個Promise,Promise物件會透傳過dispach函式。在Promise處理完成後,我們得到應用的最新狀態。最後我們將由React輸出的HTML字串contentHtml和初始化應用狀態initialState,傳遞到模板檔案index.ejs中,模板檔案如下:

<html>
  <head>
    <title>Redux TodoMVC</title>
  </head>
  <body>
    <div class="todoapp" id="root"><%-contentHtml%></div>
  </body>
  <script>
    window.__INITIAL_STATE__ =  <%-initialState%>;
  </script>
</html>

通過瀏覽器的window物件,我們將服務端的初始狀態傳遞到了頁面端。

var todosApp = combineReducers({filter: filter, todos: todos});
var store = createStore(  todosApp,
                          window.__INITIAL_STATE__,
                          applyMiddleware(thunkMiddleware, reduxLogger())
                        );

本文同時發表在我的部落格積木村の研究所http://foio.github.io/redux-state-manage/

本文案例原始碼: https://github.com/foio/react-redux-isomorphic-todolist

參考文獻:

http://redux.js.org/

https://facebook.github.io/flux/docs/overview.html

https://github.com/gaearon/redux-devtools

https://github.com/foio/react-redux-isomorphic-todolist

相關文章