About

Programmer
鼓手
人夫
孩子的爹

Posts Tagged “ react ”

Hello Redux 2/3 Server Rendering

前言

這幾個月身邊有好多的 projects 開始使用 React, 這一兩個月開始接觸到 Redux,覺得簡直驚為天人 XD。仔細讀了 Redux 的 source code 就發現他好強阿!覺得好像有更上一層樓的感覺。

這陣子用 Redux 做的東西有:

  • 一個 desktop app (with Electron)
  • 一個 universal webapp
  • 把一個用 fluxxor 做的 universal webapp 接上 Redux, 讓之後的功能可以用 Redux 做,但又不用把之前的重寫。

我打算把一些東西整理一下寫幾篇文章, 目前預計把 Redux 相關的東西整理成三篇:

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

本文來了

我打算從第二個部份 Server Rendering 開始,原因是我覺得 Redux 的介紹應該很多了。然後 Server rendering 這個部份比較有趣一點。

Server Rendering (universal/isomorphic)?

在講 Server Rendering 之前,我們應該先了解在這個東西出現之前我們遇到了什麼問題。

Single Page App

當瀏覽器的效能越來越好之後,漸漸地在做網站的時候,越來越多的邏輯從 server side 移到 client side, 也就是我們常聽到的 webapp 或者是 single-page app,javascript 的明星也由早期的 jQuery 變成了 Backbone.js, AngularJS, EmberJS 到最近的 React。

Single Page App 讓網站的角色變成一個獨立的 app,就像是 iOS 或者是 android app 那樣,用 API 來跟 server 溝通。如此一來有很多好處:從 user 的角度切入,user 不再需要一直 reload 網頁,大大增進的 user experience。從 loading 的角度切入,本來集中在 server 的 rendering work 現在移到了每一個 end user 的瀏覽器裡面。從開發流程的角度切入,server 和 client 從此有了統一的介面(API),不會再因為改動了 database 裡面的一個欄位就讓前端的 code 大壞。前端的 app 變成一個 self-contained 的東西,在維護上也大大地減低了困難。

但是 Single Page App 並不是完全沒有缺點。最主要的問題有二:

  • SEO 的困難。因為 single page app 的 html element 都是由前端的 javascript render 出來的,搜尋引擎的 crawer 無法看到這些,所以對於 crawer 來說,看到的總是一個空空如也的 html body。(但最近這個問題好像有了轉機)。

  • initial loading 很慢。Single Page App 要等 javascript 先下來之後,才由 javascript 再把 html render 出來。所以 user 一開始要等比較久才可以看到網頁的內容。

所以我們才要 Server Rendering 阿!

在這邊我們把整個 architecture 分三塊:一塊是 API server 負責提供 data, 再者是 web server 負責和 client share code 和 render html(就是我們下面指的 server), 最後是 client,也就是在瀏覽器裡面執行的 code。

architecture 可以參考 airbnb 的 blog
在這邊先用一張圖來呈現:
圖片來源:Airbnb (http://nerds.airbnb.com/isomorphic-javascript-future-web-apps/)
(圖片來源:airbnb)

Server Rendering 的意思是說,讓一部份的 code 先在 server 執行,然後當 server 把畫出一開始的畫面所需要的 data 拿到之後,把 initial page 的 html 先 render 好,並且把這包 html 和剛才拿到的 data 打包起來一起送給 client。
當 client 拿到了 initial page html (所以同時解決了 SEO 和一開始畫面要等很久的問題)和這包 data 之後,再接續著剛才 server 完成的進度繼續讓 app 運作下去。這個概念其實相當直覺,但 React 出現之後才重新被認真討論的原因是,因為 React 本身的設計可以讓這件事情可以更優雅地被完成。

簡而言之呢,要做到 Server Rendering 可以分成三個部份:

  • 讓 server 拿到 render initial page 所需要的 data
  • 利用這些 data 把 html render 出來
  • 把這些 data 本身打包送到 client 去

終於要進入 Redux 了的主題惹

在下面我不會帶到全部的 code, 但下面的 code 都放在這邊

情境設定

假設我們現在有一個動態的頁面叫作 Question,我們要 call 一個 API 把目前的 questions 拿下來然後把他 render 在畫面上。
我們有:

  • 一個 questions reducer
  • 一個 loadQuestions action,會在 load 成功之後把 data 放進 questions reducer

Redux app 的進入點大概長這樣:

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

containers/Question.js 大概長得像這樣:

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';

class Question extends Component {
  componentDidMount() {
    this.props.loadQuestions();
  }
  render() {
    return (
      <div>
        <h2>Question</h2>
        {
          _.map(this.props.questions, (q)=> {
            return (
              <p key={q.id}> { q.content }</p>
            );
          })
        }

      </div>
    );
  }
}

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

export { Question };
export default connect(mapStateToProps, { loadQuestions })(Question);

actions/questions.js 大概長這樣:

export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
  return function(getState, dispatch) {
    request.get('http://localhost:3000/questions')
      .end(function(err, res) {
        if (!err) {
          dispatch({ type: LOADED_QUESTIONS, response: res.body });
        }
      })
  }
}

於是當 Question mount 之後,就會送一個 API 給 server 把 data 拉下來, 進而 update view 了。

On Server Side

但是當我們要在 server 上 render 的時候,事情就變得比較複雜了。
我們預期最後做好的樣子是,server 的架構完成之後,是不需要隨著前端的邏輯改動而改變的。也就是說,這邊的 server 是不知道 component 的 business logic 的
回顧一下我們要在 server 上做的三件事:

  • 讓 server 拿到 render initial page 所需要的 data
  • 利用這些 data 把 html render 出來
  • 把這些 data 本身打包送到 client 去

首先要解決的問題是,當一個 request 進來的時候,我們怎麼知道要 call 哪一個 api 或者是要怎麼準備 state 呢?
第二個問題是,我們在 call 了 async 的 API 之後,要怎麼知道什麼時候 data 才準備好了呢?

第一個問題其實是和 routing 有關。以上面的 Question component 為例,真正拿 data 的點是在它的 componentDidMount() 裡面。在大部份的情況,我們會需要一個 router 來控制 url 和對應 component 的關係。所以我的做法是,在每個 routing 的 leaf node (如果有巢狀 routing 的話,就是最裡面的那個) 放一個 static method fetchData(), 這樣的話,在 server 我們就可以用 react-router 去 match 最後被 route 到的 component, 進而得到 data 的進入點。

第二個問題我的解法是用 promise, 意思是說,讓上述的 fetchData() return 一個 promise, 當這個 promise resolve 的時候就代表所有 async 的動作都完成了,也就是 data 都 ready 了的意思

這個部份在 server 的 code 大概長這樣:

import { RoutingContext, match } from 'react-router'
import createMemoryHistory from 'history/lib/createMemoryHistory';
import Promise from 'bluebird';
import Express from 'express';

let server = new Express();

server.get('*', (req, res)=> {
  let history = createMemoryHistory();
  let store = configureStore();

  let routes = crateRoutes(history);

  let location = createLocation(req.url)

  match({ routes, location }, (error, redirectLocation, renderProps) => {
    if (redirectLocation) {
      res.redirect(301, redirectLocation.pathname + redirectLocation.search)
    } else if (error) {
      res.send(500, error.message)
    } else if (renderProps == null) {
      res.send(404, 'Not found')
    } else {
      let [ getCurrentUrl, unsubscribe ] = subscribeUrl();
      let reqUrl = location.pathname + location.search;

      getReduxPromise().then(()=> {
        let reduxState = escape(JSON.stringify(store.getState()));
        let html = ReactDOMServer.renderToString(
          <Provider store={store}>
            { <RoutingContext {...renderProps}/> }
          </Provider>
        );
        res.render('index', { html, reduxState });
      });
      function getReduxPromise () {
        let { query, params } = renderProps;
        let comp = renderProps.components[renderProps.components.length - 1].WrappedComponent;
        let promise = comp.fetchData ?
          comp.fetchData({ query, params, store, history }) :
          Promise.resolve();

        return promise;
      }
    }
  });
  
});

server view template index.ejs

<!DOCTYPE html>
<html>
  <head>
    <title>Redux real-world example</title>
  </head>
  <body>
    <div id="root"><%- html %></div>
    <script type="text/javascript" charset="utf-8">
      window.__REDUX_STATE__ = '<%= reduxState %>';
    </script>
    <script src="http://localhost:3001/static/bundle.js"></script>
  </body>
</html>

然後在 containers/Question.js 裡的加一個 fetchData() static method:

class Question extends Component {
  static fetchData({ store }) {
    // return a promise here

  }
  // ...

}

然後到這裡,我們就遇到了第三個問題…
就是fetchData()裡面,我們要怎麼 reuse 本來的 action (loadQuestions()) 同時又 return 一個 promise 呢?

這個問題我的解法是做一個 middleware, 在這個 middleware 裡面去 call api,然後從這個 middleware return 一個 promise 給 fetchData()

這個 middleware 大概長這樣:
middleware/api.js

import { camelizeKeys } from 'humps';
import superAgent from 'superagent';
import Promise from 'bluebird';
import _ from 'lodash';

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 { getState } = store;
  let deferred = Promise.defer();
  // handle 401 and auth here

  let { method, url, successType } = request;
  superAgent[method](url)
    .end((err, res)=> {
      if ( !err ) {
        next({
          type: successType,
          response: res.body
        });

        if (_.isFunction(request.afterSuccess)) {
          request.afterSuccess({ getState });
        }

      }
      deferred.resolve();
    });

  return deferred.promise;
};

如此一來,本來的 action 就可以改成:
actions/questions.js

export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
  return {
    [CALL_API]: {
      method: 'get',
      url: 'http://localhost:3000/questions',
      successType: LOADED_QUESTIONS
    }
  };
}

然後 fetchData() 就變成這樣:
containers/Question.js

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';

class Question extends Component {
  static fetchData({ store }) {
    return store.dispatch(loadQuestions());
  }
  //...

}

然後我們就搞定了,YA!

總結

這陣子做 universal rendering 的感想是,universal rendering 的效果確實是蠻不錯的。就是在一開始 load 網頁的時候真的會有 "哇好快!"的感覺。但是另一方面,在開發的時候確實也因為這樣,會要在很多地方多花一些工夫設計和繞開一些東西 (像是 webpack 的各種帥氣 loader 等等)。
我個人的感覺是,綜合各種 trade-off 下來之後,做 universal rendering 還算是一個划算的選擇。

註:上面的 code 可以到這邊去看,跑起來之後請找 http://localhost://3000/q/1/hello。耶!

Hello Redux 3/3 Unit Test

前言

最近陸續用 Redux 做了一些 projects,想要把一些心得整理出來。

目前預計整理三篇:

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

這篇文章是這個系列的第三部份,Unit Test。

關於 Unit Test

Unit Test 整個主題其實涵蓋的主題很廣;從為什麼要做 unit test,到測試的環境設定,到把程式的架構設計成可以被測試的狀態等。在大部份的狀況下,testing code 會要根據 production code 選用的 library 或 framework 找到合適的點來切入。好比說我們的 app 如果是用 React + Redux 寫的,那要測這個 app 的做法和要測一個 AngularJS 的 app 的方法就會有很大的不同。

接下來我會大致上介紹一些測試的基本概念,然後花比較多篇幅在 Redux 和 React 相關的東西上面。

什麼是 Unit Test? 和其他種類的測試差在哪?

Unit Test 是自動化測試裡面最貼近程式的一種。意思是說,unit test 的測試對象通常是一個 class, 一個 function 或者一個 React component 等可以用 code 來切開的單位;用另一個角度來說,unit test 是給 developer 看的。
如果用比較高階的 acceptance test 來比較的話,acceptance test 就離程式碼本身比較遠一點,acceptance test 的測試對象可能是一個功能 (例如說 signup 的能功,從按按鈕到建立一個 user 等等)。也可以說,acceptance test 是寫給用嘴巴不會寫 code 的人看的。(像是 PM XD)

舉例來說,一個 unit test 可能是:

確保 questionReducer 在接到 QUESTION_LOADED 事件的時候,可以 return 一個新的 question 作為 state

而一個 acceptance test 可能是:

確保當 user 按下 question link 的時候,他會被帶到 question 的頁面並且看到 render 好的 question 內容

為什麼要寫 unit test?

在 unit test 裡面,我們的目的是要確保我們寫的 code 和我們預期的行為一樣。

當同一份 code 越長越大,越來越多人加入一起開發之後,我們幾乎不太可能再用純人工去檢查每一個 function, 或每個 class 都如我們預期一般的運作。這時候如果有一個自動的測試幫我們在每次改動的時候,重覆地去檢查並且立刻回報給我們知道,那就可以大大地降低我們 debug 的時間。這代表參與開發的每一個人,都可以很大膽地去改動任何東西,因為只要 test 都跑過了,那幾乎就代表其他的東西沒有被不小心改壞。
這意味著 developer 可以放心地在任何時間去 refactor 程式的架構,久而久之就會形成一個良性的循環,讓這份 code 變得越來越穩定,也因為架構變好了,要加新功能或修改也可以再更短的時間內完成。總之就是好處多多潮爽der。

另外一層用意就是,測試是不會說謊的 document
相信大家都有發生兩個星期之後回來看 code, 結果一邊看一邊罵說這誰寫的鬼,結果 git blame 一下發現靠腰是自己寫的。

Unit test 這時候扮演的角色就是,當你忘了這個 function 是幹麻用的,或者忘了要怎麼用它的時候,你可以看一下 testing code 他就會 demo 給你看。

說好的 Redux 呢?

要在一個 redux app 加上測試,大概可以分成幾個部份:

  1. 選一個 testing framework 和 assertion, mocking 的 library。 像是 mocha, jasmine 等等,並關把一些相關的設定弄好。
  2. 實際開始寫測試,這個部份又可以切成:
    • Action Test
    • Reducer Test
    • middleware Test
    • Component Test
  3. 該如何面對 webpack

在下面的介紹我會略過第一部份的細節,詳細的設定可以在這邊找到。並且假設大家對於 mocha 和 chai 的 api 都有基本的認識。

選一個好用的 testing framework 和設定它

我選了 mocha + chai 的組合,然後讓測試在 nodejs 的環境下執行。在這之前我有試過用 karma 當作 test runner 讓測試跑在瀏覽器上面,最後把 karma 捨棄的原因是因為 webpack 每次的 build 還是有點慢,讓我要 TDD 的時候變得很痛苦。這個故事就比較長一點,之後有機會再說。

在下面的 code 裡面,我選用的 stack 有:

該來寫實際的測試了吧

還沒 XD
在寫測試之前,我們可以把這個過程切成幾個步驟:

  • 確定要測試的對象
  • 確定要測試的行為
  • 在測試的環境裡面(setup) 去執行上面的那個行為 (execute)
  • 驗証結果跟我們預期的一樣 (verify)

在下面我不會帶到全部的 code, 但下面的 code 都放在這邊

Action Test

在 Redux 的設計下,action 其實相對單純。從外面看 action 的話,action 的行為其實只有 return 一個 action object 而已

假設我們有一個 actions/questions.js 長這樣,然後我們想測試他的 loadQuestions 的行為:

import { CALL_API } from 'middleware/api';

export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
  return {
    [CALL_API]: {
      method: 'get',
      url: 'http://localhost:3000/questions',
      successType: LOADED_QUESTIONS
    }
  };
}

套上上述的步驟:

  • 確定要測試的對象: question action creator
  • 確定要測試的行為:
    • loadQuestions() 會回傳某個帶有 CALL_API key 的 object,裡面有我們預期的內容

我們的測試會長成這樣:
spec/actions/questions.test.js

import { CALL_API } from 'middleware/api';

// setup

import * as actionCreator from 'actions/questions';
import * as ActionType from 'actions/questions';

describe('Action::Question', function(){
  describe('#loadQuestions()', function(){
    it('returns action `CALL_API` info', function(){
      // execute

      let action = actionCreator.loadQuestions();

      // verify

      expect(action[CALL_API]).to.deep.equal({
        method: 'get',
        url: 'http://localhost:3000/questions',
        successType: ActionType.LOADED_QUESTIONS
      });
    });
  });
});

Reducer Test

Redux 的 reducer 扮演的角色是一個 function,它接原來的 state 和一個 action,然後 return 新的 state。意思是說, reducer 是根據現有的 state 和接到的 action,來決定這個 state 要什麼樣的改變。

假設我們有一個 reducer reducers/questions.js 長這樣:

import * as ActionType from 'actions/questions';

function questionsReducer (state = [], action) {
  switch(action.type) {
    case ActionType.LOADED_QUESTIONS:
      return action.response;
      break;
    default:
      return state;
  }
}

export default questionsReducer;

套上上述的步驟:

  • 確定要測試的對象: question reducer
  • 確定要測試的行為:
    • 在接到 LOADED_QUESTIONS 的 action 的時候,會把 action.response 當成新的 state
    • 遇到不認識的 action type 的時候,會回傳空的 array

於是我們得到的 reducer 的 testing spec/reducers/questions.test.js:

import questionReducer from 'reducers/questions';
import * as ActionType from 'actions/questions';

describe('Reducer::Question', function(){
  it('returns an empty array as default state', function(){
    // setup

    let action = { type: 'unknown' };

    // execute

    let newState = questionReducer(undefined, { type: 'unknown' });

    // verify

    expect(newState).to.deep.equal([]);
  });

  describe('on LOADED_QUESTIONS', function(){
    it('returns the `response` in given action', function(){
      // setup

      let action = {
        type: ActionType.LOADED_QUESTIONS,
        response: { responseKey: 'responseVal' }
      };

      // execute

      let newState = questionReducer(undefined, action);

      // verify

      expect(newState).to.deep.equal(action.response);
    });
  });
});

Middleware test:

Middlware 在 Redux 裡面負責的行為是,在 "action 被 dispatch 出去之後,在到達 reducer 之前" 這個過程中去攔截 action,進而改變本來 action 原有的行為。Middleware 本身是一個 function,他的 signature 長這樣:

function(store) {
  return function(next) {
    return function(action) {
      // middleware behavior...

    };
  };
}

如果用 ES6 的語法來表達,看起來會稍微乾淨一點,但基本上他的本體還一樣複雜:

store => next => action => {
  // middleware behavior...

}

這個部份我覺得是 Redux 設計得最帥氣的部份之一。之後在 Redux 的介紹文章會詳細說明。現在還是先讓我們先搞定測試再說。
假設我們有一個 api 的 middleware middleware/api.js 長這樣:

import { camelizeKeys } from 'humps';
import superAgent from 'superagent';
import Promise from 'bluebird';
import _ from 'lodash';

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 { getState } = store;
  let deferred = Promise.defer();
  let { method, url, successType } = request;
  superAgent[method](url)
    .end((err, res)=> {
      if ( !err ) {
        next({
          type: successType,
          response: res.body
        });
      }
      deferred.resolve();
    });

  return deferred.promise;
};

這個 middleware 做的事情是:

  • 攔截有 CALL_API 這個 key 的 action,
  • 然後根據這個 CALL_API 的 value (假設它叫 request) 裡面的 urlmethod 去發一個 http api call 給 server。
  • 當這個 api call 成功之後,再 dispatch 一個 request.successType 的 action 出去。
  • 這個 middleware 本身會 return 一個 promise, 這個 promise 會在 api call 成功之後被 resolve (比較完整的版本應該要有相對應的 error handling 才對, 但在這邊為了讓事情單純一點就先省略)

如果套上上面的步驟的話就會變成:

  • 確定要測試的對象: api middleware
  • 確定要測試的行為:
    • middleware 會放過沒有 CALL_API 的 action
    • middleware 會根據 action[CALL_API] 去送一個 api call 給 server
    • middleware 在 request 成功之後,會dispatch 一個 action[CALL_API].successType 的 event
    • middleware 在 request 成功之後,會 resolve middleware return 的 promise

所以我們的 test code 就會長這樣 spec/middleware/api.test.js:

import nock from 'nock';
import apiMiddleware, { CALL_API } from 'middleware/api';

describe('Middleware::Api', function(){
  let store, next;
  let action;
  let successType = 'ON_SUCCESS';
  let url = 'http://the-url/path';

  beforeEach(function(){
    store = {};
    next = sinon.stub();
    action = {
      [CALL_API]: {
        method: 'get',
        url,
        successType
      }
    };
  });

  describe('when action is without CALL_API', function(){
    it('passes the action to next middleware', function(){
      action = { type: 'not-CALL_API' };
      apiMiddleware(store)(next)(action);
      expect(next).to.have.been.calledWith(action);
    });
  });

  describe('when action is with `CALL_API`', function(){
    let nockScope;
    beforeEach(function(){
      nockScope = nock(`http://the-url`)

                    .get('/path');
    });
    afterEach(function(){
      nock.cleanAll();
    });
    it('sends request to `path` with query and body', function(){
      nockScope = nockScope.reply(200, { status: 'ok' });

      apiMiddleware(store)(next)(action);

      nockScope.done();
    });

    it('resolves returned promise when response when success', function(){
      nockScope = nockScope.reply(200, { status: 'ok' });

      let promise = apiMiddleware(store)(next)(action);

      return expect(promise).to.be.fulfilled;
    });
    it('dispatch successType with response when success', function(done){
      nockScope = nockScope.reply(200, { status: 'ok' });
      let promise = apiMiddleware(store)(next)(action);

      promise.then(()=> {
        expect(next).to.have.been.calledWith({
          type: successType,
          response: { status: 'ok' }
        });
        done();
      });
    });
  });
});

這邊的測試行為比之前的複雜一些, 其中 nock 是一個用來測試 nodejs 上面 http request 行為的一個 library, 這個部份有點超過這篇文章的 scope 所以在這邊就假設大家都很熟悉 XD。除了 nock 之外,還有幾個可以詳細說明的點:

首先是 test 的巢狀 describebeforeEach,這樣的做法可以讓每一個 describe 下面的內容有一個 context,再者也可以利用在 describe function 下面的 local variable, 讓同一個 describe 下面的測試可以 share 同樣的 variable。(像是 nockScope 就只有 when action is with CALL_API 這個 describe block 下面的 code 可以 access 到)

再來是要怎麼在測試的環境裡執行 middleware 呢?
因為 middleware 本身是一個 function,只是被包了很多層而已,所以要執行它的方法就是一層一層地去 call 他:

apiMiddleware(store)(next)(action);

最後是 dispatch successType with response when success 這邊有 async(非同步) 的行為。Mocha 最基本的行為是不會等 async 的 code 執行完的。也就是說,在 async 的 code 被執行到之前,mocha 的 test case (it())就會先結束掉。這樣我們就會無法在 async 的行為完成之後再驗証一些行為。這個部份 mocha 的做法是在 it 後面跟的 function 帶一個參數(done),而 mocha 會等到這個 done 在 test case 裡面被執行到之後才結束這個 test case。如此一來,我們就可以在 async 的動作執行完之後再呼叫 done,這樣就可以確保 mocha 會在我們預期的時間點才結束 test case 了。

Component Test

在 Redux 下面,我們把 Ract 的 Component 分成兩種:一種是 smart component, 另一種是 stupiddumb component。Smart component 指的是和 Redux 有連結的那些 component, 而 dumb component 則是完全根據 props 來動作的那些。
Dumb Component 的測試嚴格來說和 Redux 就比較沒有直接的關係了。因為它們就是一般的 React component。Component 的測試常常會和 mock 有關係,所以就在這邊作一個基本的介紹。

Smart component 在實作上面,其實和 dump component 差不多,只是多了 connect 的行為,

  1. 把 store 的 state 利用一個 function (mapStateToProps) 選擇一部份需要用到的 keys inject 進去變成 component 的 props
  2. 把部份的 action 也 inject 進去變成 component 的 props

在測試 smart component 的時候,根據 Redux document 上面的建議是,繞過 connect 的行為,直接測 component 的部份。
假設我們的 comopnent 長成這樣: containers/Question.js

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import { Link } from 'react-router';
import _ from 'lodash';

class Question extends Component {
  static fetchData({ store }) {
    return store.dispatch(loadQuestions());
  }

  componentDidMount() {
    this.props.loadQuestions();
  }
  render() {
    return (
      <div>
        <h2>Question</h2>
        {
          _.map(this.props.questions, (q)=> {
            return (
              <p key={q.id}> { q.content }</p>
            );
          })
        }

        <Link to="/">Back to Home</Link>
      </div>
    );
  }
}

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

export { Question };
export default connect(mapStateToProps, { loadQuestions })(Question);

在這個 component 裡面,我們把 Question 特別 export 出來,這樣的話我們在測試的時候,就可以繞過 connect 完之後的 smart component, 而單純地測試 Question

一樣套上上面的步驟:

  • 確定要測試的對象: Qestion Component
  • 確定要測試的行為:
    • component 會根據 props.questions render 出對應的 elements
    • component 會 render 出一個指向 /Link

於是我們的測試會長成這樣: spec/containers/Question.test.js

import Container, { Question } from 'containers/Question';
import React from 'react';
import TestUtils from 'react-addons-test-utils';

describe.only('Container::Question', function(){
  let props;
  let Link;
  beforeEach(function(){
    props = {
      loadQuestions: sinon.stub(),
      questions: [
        { id: 1, content: 'question content 1' },
        { id: 2, content: 'question content 1' }
      ]
    };
    Link = React.createClass({
      render() {
        return (<div>MOCK COMPONENT CLASS</div>)
      }
    });
    Container.__Rewire__('Link', Link);
  });

  it('renders questions according to `props.questions`', function(){
    let doc = TestUtils.renderIntoDocument(<Question {...props} />);
    let questionElements = TestUtils.scryRenderedDOMComponentsWithTag(doc, 'p');

    expect(questionElements.length).to.equal(props.questions.length);
  });
  it('renders a link back to `/`', function(){
    let doc = TestUtils.renderIntoDocument(<Question {...props} />);

    let link = TestUtils.findRenderedComponentWithType(doc, Link);

    expect(link).not.to.be.undefined;
    expect(link.props.to).to.equal('/');
  });
});

在這邊我們用 react-addons-test-utils 來 query component 裡面的東西並且加以驗証。
其中我們要測試的第二個行為:"component 會 render 出一個指向 /Link" 比較需要說明一下。
在這個測試裡面,因為我們的測試對象是 Question Component, 所以我們並不在乎別的 component 內部是怎麼運作的。我們只在乎 Question component 和別的 component 之間的介面關係而已。Question 為例,在它的測試裡面我們並不想要直接 render 別的 component (Link),原因是因為別的 component 在 render 的時候有可能會衍生出其他的問題,當這種狀況發生的時候,我們很難確定究竟是 Question 的問題還是其他 component 的問題。
這樣的狀況在 component 的測試時常發生,因為大多數的時候,我們會在一個 component 去 render 其他很多不同的 component。

面對這種問題我的解決方法是*在測試裡面,用 __Rewire__ 把其他 component mock 掉 *

Link = React.createClass({
  render() {
    return (<div>MOCK COMPONENT CLASS</div>)
  }
});
Container.__Rewire__('Link', Link);

這樣一來,Question 在測試裡面被 render 的時候,他看到的 Link 就會是我們提供的假的 component 而不是真正的 Link component 了。接著我們就可以進一步去測試 Question component 和 Link component 之間的介面關係:

let doc = TestUtils.renderIntoDocument(<Question {...props} />);
let link = TestUtils.findRenderedComponentWithType(doc, Link);

expect(link).not.to.be.undefined;
expect(link.props.to).to.equal('/');

該如何面對 webpack

webpack 有很多很神妙的 loaders,可以讓我們在 javascript 裡面 require 各種不是 javascript 的東西,像是圖片、stylesheet 等等。在選用這樣的 loaders 的時候,當中會牽涉到一些取捨。
選用 loader 的好處是,所有的東西都被打包在一起了;但它的壞處是,用到這些 loader 的 code 就只能在 webpack 的環境下執行了
如果說我們的 app 只會在瀏覽器上跑,那用各種 loader 基本上是沒什麼問題。但如果同一份 code 也要在 server 上執行的話 (universal rendering),那選用 loader 就代表在 server 上跑的 code 要用 webpack 分開打包,也代表著我們會需要兩份 webpack config。
我自己的偏好是,如果要做 universal rendering, 我會避開這類的 loader, 讓 server 的 code 可以不用透過 webpack 來 build,對我來說這樣比較單純。

下面我提供的例子是一個只有在瀏覽器裡執行的例子(不在這個範例裡面),其中用到了 url loader 來 require 圖片。

let SignupModal = React.createClass({
  render() {
    let cmLogo = require('Icons/white/icon_codementor.png');
    
    ...
  }
})

這個 component 會在 render 的時候,去 require 一個圖片,然後在 nodejs 的環境下測試就壞掉惹。
我的解法是,用一個 function 把 require image 的行為包起來,這樣一來我們就可以在測試的時候去 mock 這個 function。

於是我們的 component 變成這樣:

import requireImage from 'lib/requireImage';

let SignupModal = React.createClass({
  render() {
    let cmLogo = requireImage('Icons/white/icon_codementor.png');
    
    ...
  }
})

其中 requireImage 就只是單純地去 require 而已: lib/requireImage.js:

export default function(path) {
  return require(path);
}

這樣一來,我們就可以在測試裡面 mock 掉 requireImage 了:

describe('Component SignupModal', function(){
  let requireImage;
  beforeEach(function() {
    requireImage = function() {}
    SignupModal.__Rewire__('requireImage', requireImage);
  });
  
  it('can be rendered', function() {
    // now we can render here

  });
});

結論

測試是一件需要花力氣去做的事情,同時也需要很多練習。但是透過這個過程,我們可以更了解整份 code 的運作方式。
在大部份的時候,容易測試的 code 也會是有好的結構的 code。
當一個 project 變得越來越大,開發者越來越多,沒有測試的 code 到後來幾乎會被 bug 壓垮。而當我們越來越熟練之後,其實寫測試的時間絕對是遠小於 debug 的時間的。

Redux 的設計讓測試變得很單純,我覺得這也是它很精妙的地方之一。
好測試,不寫嗎?

Hello Redux 1/3 Redux 教學

前言

最近陸續用 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>

如何與 Webpack Environment Variables 成為好友

在 React 的開發工具當中, Webpack 可以說是相當詭譎神妙的一員。
他在某些方面取代了很多本來用 gulp/grunt 做的事情,像是 pre-process, post-process javascript/css 等,但同時也因為它的設定比較複雜,有時候蠻令人頭大的。

我想在這篇文章分享我自己在使用 webpack 的時候,遇到關於 environment variables (環境變數) 的問題和解法。

情境設定:

在開發的時候,我們常常會把一些設定參數、thrid-party service keys 等等的東西放在環境變數裡面 (而不是直接 commit 進去 repository)。像是 API_HOST 的 url,或者是 AWS 的 access key 等等。
所以在我們的程式裡面可能會有以下這樣的做法:

// app.js

var config = require('./config');
var agent = require('superagent');

agent.get(config.apiHost + '/the-api-path');

而其中的 config 檔可能長這樣:

// config.js

module.exports = {
  apiHost: process.env['API_HOST'] || 'http://localhost:3000'
}

如此一來,我們就可以把不同環境的 API_HOST 設定在 environment variable ($API_HOST)裡面,並且給定一個預設的值 (http://localhost:3000)。

但是在把上面的 code 用 webpack build 起來之後,我們發現事情並不是憨人所想的那麼簡單沒有那麼單純。

Webpack v.s. Environment Variables

為了方便說明,我們可以先暫時把上面的 app.js 改成這樣:

var config = require('./config');
console.log(config.apiHost);

這樣我們就可以直接執行 build 好的 code 然後把結果 log 出來。

直接 build 下去

我們如果把上面的 code 直接 build 起來:

$ webpack app.js --output-filename bundle.js

然後接著執行 bundle.js:

$ API_HOST=http://production-api.com node bundle.js

這邊我們預期要得到的結果應該是 http://production-api.com
但實際上的結果卻是 http://localhost:3000

也就是說,bundle.js 在執行的時候,完全不管我們 pass 進去的 environment variable 阿!

在 build 的時候 pass 環境變數

接著我們試著在 build 的當下就 pass environment variable 進去。其實這樣的做法比較接近實際上 deployment 的狀況 (像是在 Heroku 上面設定好相對應的 environment variables, 然後在 server 上面把 code build 起來)

$ API_HOST=http://production-api.com webpack app.js --output-filename bundle.js

然後同樣執行 build 好的 bundle.js

$ API_HOST=http://production-api.com node bundle.js

什麼?!!結果還是一樣阿靠!

執行的結果還是一樣是 http://localhost:3000 而不是我們想要的 http://production-api.com

在直接把 bundle.js 打開來看之後,我們發現他裡面寫著:

process.env = {};

難怪 pass 什麼東西進去都沒用阿。

DefinePlugin

在經過一番 google 之後,發現原來可以用 webpack 的 DefinePlugin 來解決這個問題。我們新加一個檔案 webpack.config.js

// webpack.config.js

var webpack = require('webpack');
var envToBeInjected = {
  API_HOST: process.env.API_HOST
};

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env': JSON.stringify(envToBeInjected)
    })
  ]
};

然後再執行上面的 build script:

$ API_HOST=http://production-api.com webpack app.js --output-filename bundle.js

然後再試一下:

$ node bundle.js

耶!終於出現夢寐以求的 http://production-api.com 了。

How It works

上面我們在 webpack.config.js 裡面,用 DefinePluginprocess.env 這個變數用

{ API_HOST: process.env.API_HOST }

代換進去。

webpack.config.js 是在 build 時候被執行,這就是為什麼我們要在 build 的時候 pass API_HOST=http://production-api.com 的原因。而在 webpack.config.js 裡特別把 API_HOST 抽出來而不是直接 pass 整個 process.env 的原因是因為,在 server 上面的 environment variables 裡面可能還有其他的東西像是其他 service 要用到的 key 等等。這樣東西還是不要一起 build 進去比較妥當 XD

而 webpack build 好的 bundle.js 是直接把 build 的當下的 environment variable 寫進去,這也是為什麼我們在最後執行 bundle.js 的時候,不需要在另外 pass API_HOST 的原因。