前言

最近陸續用 React + Redux 的組合,做了一些專案。有從頭到尾都用 Redux 的,也有把 Redux 接上現有的 Fluxxor 的。
目前 Redux 也算是相當夯阿,趁還沒忘記趕快來寫一下。

這個系列總共有三篇文章:

  • Hello Redux: 關於 Redux 的一些基本介紹,包括我認知的 redux 的一些概念,和我覺得他設計得很棒的地方。
  • Server Rendering: 介紹如何使用 Redux 和 react-router 做 server rendering
  • Unit Test: 在測 Redux 的 code 的時候我遇到的問題和解決的方法,還有怎麼在測試的時候和 webpack loaders 和平共處。

這篇文章是系列的第一篇,在這篇文章裡面我會介紹什麼是 Redux,Redux 怎麼運作的,還有一些有趣的東西。

我自己對於這邊文章的預期是,單看完這邊文章還是無法用 Redux 寫出一個 web app 的 (請不要立刻關掉 tab XD)。但看完這邊文章之後,再去讀 Redux 的 document,應該會更有一個全面的藍圖,在之後的開發需到問題應該也會比較有方向。

什麼是 Redux?

Redux 是一個在 javascript app 裡面用來控制 state 的 framework。根據他官網上寫的:

Redux is a predictable state container for JavaScript apps.

在一個 app 裡面,有著各式各樣會隨著時間、 user 行為或各式各樣其他原因而改變的的狀態 (state)。
我們可以把一個 app 視為一個改變 state 的過程,而 user 看到的東西(view),就是這些 state 的呈現。

好比說,在一個 Todo List 的 app 裡面,我們新增了一個 todo 項目,其實就是改變了這個 app 的 state, 從沒有 todo 項目到有一個 todo 項目;也因為 app 的 state 被改變了,又因為 view 是 state 的呈現,於是我們在畫面上(view)看到了一個我們剛才新增的 todo 項目。

如果說不幸地,我們的 app state 在某一個時候表現得和我們預期的不同,那 debug 的時間就到了。
例如說我們新增了一個 todo item,但我們的 state 卻增加了兩個而不是我們預期的一個,那這時候,我們就必須花時間去找究竟在哪一步 state 的改變發生了錯誤。
這件事情說來單純,但事情不是憨人所想得那麼簡單現實世界的狀況往往要來得複雜得多。有各種原因會讓這一切變得很可怕,包括寫程式的習慣不好、framework 設計的缺點、沒有好好寫測試等等。

Redux 企圖在 framework 的設計上去解決這個問題。更精準地說,Redux 是延伸了 Flux 的概念,然後簡化了一些重覆的東西而設計出來的 framework。就像他官網寫的:

Redux evolves the ideas of Flux, but avoids its complexity by taking cues from Elm.

所以本來的 framework 設計到底有什麼問題呢?

本來的 framework 其實有千千萬萬種風情萬種。但大致上可以歸納成 MVC 或者是 MVC 的延伸。而即使都是 MVC,各家 implementation 的方式也都略有不同。

Flux/Redux 想解決的點是:在 app 使用的過程中,model 被連續 update 而導致有 bug 很難 trace 的問題。

就像這張圖畫得一樣:


from Quora

在這張圖下面,任何 view 都有可能改動到任何 model, 而任何 model 也有可能影響到每一個 view。這樣的缺點就是,當 view 和 model 的數量都很多的時候,當有一個 model 的 state 變成我們預期之外的樣子的時候,我們無法有效率地去追溯是哪一個 view 或 model 造成的,因為可能性太多了。

Flux/Redux 的解法就是把 model (或是叫 store) 的 setter 去掉,讓 store 透過 action 來 update 自己的 state,(自己的 state 自己管)(就是 Flux 官網上寫的 unidirection data flow),並且在每一個 action dispatch 裡面,不能再重覆 dispatch 另一個 action。如此一來,store updating 從被動變成主動了;如果有什麼錯誤發生的話,要追溯只要看這個有問題的 store 他經歷的事件過程,就可以比較容易地發現 bug, 因為防守的面剩下一個了。

How It Works?

Redux 可以分成下面幾個部份:

  • store:管理 state 的中心,主要有一個 dispatch 的 method 用來 dispatch action在 Redux app 裡面,整個 app 的 state 可以透過 store.getState() 來拿到。
  • action:一些單純的 object (plain javascript object)。可以視為一些 用來改變 state 的指令.
  • reducer:state 改變的入口。功能是接到 action之後,負責決定 state 要怎麼改變。reducer 是一些 function,它改變 state 的方法是把 action 當成參數接進來,然後 return 出新的 state。
  • middleware:介在 store.dispatch()reducer 之間的協調者。它的作用是可以攔截被 dispatch 出來的 action, 在它們抵達 reducer 之前,去修改甚至取消這些 action

像上面的圖所示,假設我們今天要在 Redux 的架構下新增一個 todo item, 我們會建一個 action 帶有某一種 type(ADD_TODO),然後用 store.dispatch() 把這個 action dispatch 出去。

// actionCreator

export function addTodo(text) {
  return { type: types.ADD_TODO, text };
}

store.dispatch(addTodo({ text: 'Clean bedroom' });

接著這個 action 就會進入 middleware,最後進到 reducer。而在 reducer 裡面,則會根據不同的 action type 來處理 state 的轉換:

// reducer

function todos(state, action) {
  switch(action.type) {
    case 'ADD_TODO':
      // handle action and return new state here

  }
}

這樣我們就完成了最基本的 state updating 的行為了。

如果是在一個比較複雜的 app 裡面,通常我們會需要把 store 的 state 切成一些不同的區塊,就像是 namespace 那樣。
在 Redux 要做到這件事情的作法是,建立不同的 reducers 分別去管理不同區塊的 state,然後再用 combineReducers 把它們合起來。

如果延續上面的圖示,就會長這樣:

Async & middleware

在剛才我們已經介紹了最近本的 state updating 的行為。但在一個前端的 app 裡面並不是一切都那麼單純地!
這邊要介紹的是,如何在 Redux 下完成 asynchronous(非同步) 的行為呢? 在下面我用一個發 http request 的行為作為例子說明和 async 有關的作法。

情境設定

假設我們的 app 會呈現一個 questions 的列表。我們在某個時候(好比說 user 按下了按鈕之類的)要發一個 request 到 server 去拿 questions 的 data 下來;在發送 request 的過程中,我們要在 store 裡面表達 sending 的資訊,如果 request 成功了,就要把 questions 的 data 放在 store 裡面,如果失敗了,我們要在 store 裡面表達 request 失敗的訊息。

天真的解法

要達成上面的行為,最直覺的作法就是:在不同的時候分別 dispatch 不同的事件:
在下面的例子,我用 superagent 來作為發 request 的工具

import request from 'superagent';

const SENDING_QUESTIONS = 'SENDING_QUESTIONS';
const LOAD_QUESTIONS_SUCCESS = 'LOAD_QUESTIONS_SUCCESS';
const LOAD_QUESTIONS_FAILED = 'LOAD_QUESTIONS_FAILED';

store.dispatch({ type: SENDING_QUESTIONS });
request.get('/questions')
  .end((err, res)=> {
    if (err) {
      store.dispatch({
        type: LOAD_QUESTIONS_FAILED,
        error: err
      });
    } else {
      store.dispatch({
        type: LOAD_QUESTIONS_SUCCESS,
        questions: res.response
      });
    }
  });

這樣我們就可以在 Redux 下面達成 async 的行為了。但是這個作法最主要的缺點是:如果我們有很多地方要發 http request, 我們必須在每一個地方都加入 async 的行為,這樣一來會大大提高 code 的維護成本,並且對 code 的測試也是相當不利,因為 async 的 code 還是比較難懂和比較難測阿。再者,如果加入了 React 之後,這樣的作法會讓我們被迫把這樣的邏輯放在 React Component 裡面。

比較理想的作法是我們可以把性質類似的 async 的行為抽象出來放在同一個地方,於是我們要來介紹 middleware 惹!

什麼是 middleware?

Redux 的 middleware 是介於 store.dispatchreducers 之間的中間人。更精準地說,我們 call 的 store.dispatch() 其實是由一層一層的 middleware 所組成,到最後一層才會進到 reducer。可以用下面這張圖來表示:

Redux-middleware.jpg

透過 middleware, 我們可以把上面的 async api request 的行為抽象出來,放在同一個 middleware 裡面。意思是說,我們在需要發 api request 的地方 dispatch 一種特定的 action,然後在 middleware 去攔截這種 action 來發相對應的 api request。所以我們可以把 middleware 寫成這樣:

// middlewares/api.js

import superAgent from 'superagent';

export const CALL_API = Symbol('CALL_API');

export default store => next => action => {
  if ( ! action[CALL_API] ) {
    return next(action);
  }
  let request = action[CALL_API];
  let { method, path, query, failureType, successType, sendingType } = request;
  let { dispatch } = store;

  dispatch({ type: sendingType });
  superAgent[method](path)
    .query(query)
    .end((err, res)=> {
      if (err) {
        dispatch({
          type: failureType,
          response: err
        });
      } else {
        dispatch({
          type: successType,
          response: res.body
        });
      }
    });
};

在上面的 code 裡面,這個 middleware 會攔截有 CALL_API 這個 key 的 action,然後根據裡面的 method, path, query, successType 等,去決定怎麼發送 api request。

如此一來,我們在真正需要發 request 的地方就不需要特別處理 async 的邏輯,而只要 dispatch 出一個帶有 CALL_API 的 action 就行了。

import { CALL_API} from 'middlewares/api';

const SENDING_QUESTIONS = 'SENDING_QUESTIONS';
const LOAD_QUESTIONS_SUCCESS = 'LOAD_QUESTIONS_SUCCESS';
const LOAD_QUESTIONS_FAILED = 'LOAD_QUESTIONS_FAILED';

store.dispatch({
  [CALL_API]: {
    method: 'get',
    path: '/questions',
    sendingType: SENDING_QUESTIONS,
    successType: LOAD_QUESTION_SUCCESS,
    failureType: LOAD_QUESTION_FAILED
  }
});

這樣把 async 的行為集中在 middleware 裡面的作法,除了讓要發送 request 的點不用寫重覆的邏輯之外,同時也讓測試變得非常單純:由於 "dispatch 帶有 CALL_APIaction" 這件事情現在是一個 synchronous(同步) 的行為了,因此避開了 async 的測試。

我覺得 Redux middleware 的設計,是這個 framework 之所以這麼優雅的主要原因之一。透過 middleware 我們可以把各種本來會重覆的邏輯抽象出來,像是上面舉例的 async api request, 或者是 log 等等的行為。這個概念我覺得和 Chain of Responsibility Pattern 有點像。同樣的概念也出現在 Express middleware 和 Rack middleware

咦? React 呢?

在介紹完這麼長一串和 Redux 有關的內容之後,突然發現 咦?還沒提到 React!!

沒錯!Redux 本身確實和 React 沒有直接的關係。
Redux 的角色是控制 app 裡面 state 的轉換。而 React 則是根據這些 state 來把 view render 出來。

React 的強項是透過 virtual dom 讓開發者可以在 state 改變的時候,把整包新的 state (在 React 裡面叫 props)丟給 React 重新 render,React 會有效率的完成 re-render 這件事情。也就是說,app 的開發者不用管 state 的轉換究竟是哪一部份的 state 被改變了,只要把整包都請 React 重新 render 就可以了。

在 Redux 的架構下,和 React 結合的方法就是,我們找到某些 "比較上層的 React Components", 在這些 components 裡面把 Redux 的 state 設成 component 的 state。然後在這些 state 改變的時候用我們熟悉的 component setState 來 trigger re-render。如此一來我們就把 Redux 的 state 和 React 結合在一起了。

有了這些 "比較上層的" components 之後,接著我們就可以把這些 components 進一步再切割成更小的 components,並且把 Redux 的 state 以 React 的 props 去建立這些 "更小的 components"。而這些"更小的 components" 其實和 Redux 就又沒有直接關係了,因為它們的行為完全由 props 來決定。對於這些 "更小的 components" 來說,它們並不在乎這些 prop 究竟是從 Redux 來的或者從其他地方來的。

上面提到的 "比較上層的 component" 其實在 Redux 裡的正式名字叫 Smart Component,而 "更小的 components" 叫作 Dumb Component。其中把 Redux state 和 Smart component 接起來的工作,則是由 React-Redux 裡提供的 connect function 來負責。

所以 React + Redux 的 app 揪竟長得怎樣呢?

下面的例子是 Redux 官方的 TodoMVC 範例,程式的進入點 index.js 長這樣:

import 'babel-core/polyfill';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import App from './containers/App';
import configureStore from './store/configureStore';
import 'todomvc-app-css/index.css';

const store = configureStore();

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

上面我們可以看到,我們的 app component App 被一個叫作 Provider 的 component 包起來,並且這個 Provider component 還用 prop 的方式來指向我們 app 的 state: store

我們的 App component containers/App.js:

import React, { Component, PropTypes } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Header from '../components/Header';
import MainSection from '../components/MainSection';
import * as TodoActions from '../actions/todos';

class App extends Component {
  render() {
    const { todos, actions } = this.props;
    return (
      <div>
        <Header addTodo={actions.addTodo} />
        <MainSection todos={todos} actions={actions} />
      </div>
    );
  }
}

App.propTypes = {
  todos: PropTypes.array.isRequired,
  actions: PropTypes.object.isRequired
};

function mapStateToProps(state) {
  return {
    todos: state.todos
  };
}

function mapDispatchToProps(dispatch) {
  return {
    actions: bindActionCreators(TodoActions, dispatch)
  };
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App);

這邊我們發現 App 其實也只是一個單純的 React component, 和 Redux 沒有直接的關係。但這個檔案最後 export 出去的東西並不是 App component, 而是 connect 之後的結果。
也就是在這步 connect 的過程中,讓我們的 App component 真正和 Redux 連結起來:

connect 這步,我們把 mapStateToPropsmapDispatchToProps 傳進去。
其中第一個參數 mapStateToProps 的功能是把 Redux 的 state (store.getState()) 的結果 filter 出這個 Smart component 所需要的,然後用 prop 的方式 pass 給這個 component。
以上面的例子為例,這個 mapStateToProps return 的東西是

{
  todos: state.todos
};

因此我們在 connect 過後的 App component 裡就可以用 this.props.todos 來拿到 store.getState().todos

而第二個參數 mapDispatchToProps 則是負責把本來只是單純 return action object 的 action creator 加上 dispatch 的功能,並且也用 prop 的方式傳給 component。

用上面的例子說明的話,本來的 TodoActions.addTodo 只是單純地回傳一個 action object:

// actions/todos.js
export function addTodo(text) {
  return { type: types.ADD_TODO, text };
}

但透過上面的 connect,我們在 connect 過後的 App component 就可以得到一個 this.props.action.addTodo, 而這個 function 的功能則是 "dispatch 本來 action creator 回傳的 action object",相當於:

dispatch(ActionCreators.addTodo(...))

connect 是怎麼拿到 store 的呢?

從剛才的 containers/App.js 我們可以發現,從頭到尾沒出現 store!!
那這樣的話,connect 要把 component 和 Redux state 連結起來的時候,他是怎麼拿到 store 的呢?

答案就在一開始 index.js 那邊的 Provider

index.js 我們把 storeprop 的方式傳進 Provider,而在 Provider 裡面,則會把這個 store 設成它自己的 React context (註1)。這樣一來,所有 Provider 的 child components 都可以用 context 的方式拿到這個 store。所以 connect 之後的 App 就是用 context 的方式拿到 store 的。

結論

我覺得 Redux 有很多部份的設計非常優雅。和之前用的 Fluxxor 比較起來可以少寫很多很多重覆的 code 和邏輯。
其中我最喜歡的部份是 middleware 的部份。在讀了 middleware 這邊的原始碼之後,發現他用很 functional 的方式來處理。我本身對於 functional programming 並不是很熟悉,所以對我來說算是開了眼界,也讓我一個有重新認識 javascript 這個語言的起點。

另外把和 React 結合的部份拆出來也是一個很棒的作法。
對於測試來說,和 view 相關的測試通常比較複雜一點。而 Redux 這樣的作法也讓測試有一個很容易的切入點:由於 Redux 把和動態 data 直接相關的部份關在 smart component,如此一來 dumb components 的行為就完全只和 props 有相關。如此一來要測試這些 dumb component 就變得單純很多。

註1:

在 React 0.13 以前,contextowner-based 的。而在 0.14 開始全面改成 parent-based。這也就是為什麼和 React 0.13 以前的版本一起用的時候,Provider 的進入點會需要在 App 外面多包一層 function, 目的是為了避開 parent-based and owner-based contexts differ 的錯誤

<Provider store={store}>
    { ()=> {<App />} }
  </Provider>