[譯]全棧Redux實戰

wywang2001 8年前發布 | 47K 次閱讀 Redux JavaScript開發

本文亂譯自一篇英文博文( Full-Stack Redux Tutorial ),本人英語能力不足,技術能力有限,如有錯誤,多多包涵。

關于Redux+React+Immutable的測試先行開發綜合指南

Redux是最近發生在js界令人興奮的事兒。它把眾多優秀的庫和框架中非常正確的特性保留了下來:簡單且可預測的模型,強調函數式編程和不可變數據,基于api的輕量級實現……你還有什么理由不喜歡呢?

Redux是一個非常小的代碼庫,掌握它所有的api并不困難,但對很多同學來講,它要求的:創建組件(blocks),自滿足的純函數和不可變數據會帶來不少別扭,那到底應該怎么辦呢?

這篇文章將會帶你創建一個全棧的Redux和Immutable-js應用。我們將詳細講解創建該應用的Node+Redu后端和React+Redux前端的所有步驟。本指南將使用ES6,Babel,Socket.io,Webpack和Mocha。這是一個非常令人著迷的技術棧選型,你肯定不及待的想要開始了。

目錄

(不翻譯)

你需要準備什么

這篇文章需要讀者具備開發js應用的能力,我們講使用Node,ES6,React,Webpack,和Babel,所以你最好能了解這些工具,這樣你才不會掉隊。

在上面提到的工具集中,你需要安裝Node和NPM,和一款你喜歡的編輯器。

應用

我們將要開發一款應用,它用來為聚會,會議,集會等用戶群提供實時投票功能。

這個點子來自于現實中我們經常需要為電影,音樂,編程語言等進行投票。該應用將所有選項兩兩分組,這樣用戶可以根據喜好進行二選一,最終拿到最佳結果。

舉個例子,這里拿Danny Boyle電影做例子來發起投票:

這個應用有兩類獨立的界面:用于投票的移動端界面,用于其它功能的瀏覽器界面。投票結果界面設計成有利于幻燈片或其它更大尺寸的屏幕顯示,它用來展示投票的實時結果。

架構

該系統應該有2部分組成:瀏覽器端我們使用React來提供用戶界面,服務端我們使用Node來處理投票邏輯。兩端通信我們選擇使用WebSockets。

我們將使用Redux來組織前后端的應用代碼。我們將使用Immutable數據結構來處理應用的state。

雖然我們的前后端存在許多相似性,例如都使用Redux。但是它們之間并沒有什么可復用代碼。這更像一個分布式系統,靠傳遞消息進行通信。

服務端應用

我們先來實現Node應用,這有助于我們專注于核心業務邏輯,而不是過早的被界面干擾。

實現服務端應用,我們需要先了解Redux和Immutable,并且明白它們如何協作。Redux常常被用在React開發中,但它并不限制于此。我們這里就要學習讓Redux如何在其它場景下使用。

我推薦大家跟著我們的指導一起寫出一個應用,但你也可以直接從 github 上下載代碼。

設計應用的狀態樹(State Tree)

設計一個Redux應用往往從思考應用的狀態樹數據結構開始,它是用來描述你的應用在任何時間點下狀態的數據結構。

任何的框架和架構都包含狀態。在Ember和Backbone框架里,狀態就是模型(Models)。在Anglar中,狀態常常用Factories和Services來管理。而在大多數Flux實現中,常常用Stores來負責狀態。那Redux又和它們有哪些不同之處呢?

最大的不同之處是,在Redux中,應用的狀態是全部存在一個單一的樹結構中的。換句話說,應用的所有狀態信息都存儲在這個包含map和array的數據結構中。

這么做很有意義,我們馬上就會感受到。最重要的一點是,這么做迫使你將應用的行為和狀態隔離開來。狀態就是純數據,它不包含任何方法或函數。

這么做聽起來存在局限,特別是你剛剛從面向對象思想背景下轉到Redux。但這確實是一種解放,因為這么做將使你專注于數據自身。如果你花一些時間來設計你的應用狀態,其它環節將水到渠成。

這并不是說你總應該一上來就設計你的實體狀態樹然后再做其它部分。通常你最終會同時考慮應用的所有方面。然而,我發現當你想到一個點子時,在寫代碼前先思考在不同解決方案下狀態樹的結構會非常有幫助。

所以,讓我們先看看我們的投票應用的狀態樹應該是什么樣的。應用的目標是可以針對多個選項進行投票,那么符合直覺的一種初始化狀態應該是包含要被投票的選項集合,我們稱之為條目[entries]:

當投票開始,還必須定位哪些選項是當前項。所以我們可能還需要一個vote條目,它用來存儲當前投票的數據對,投票項應該是來自entries中的:

除此之外,投票的計數也應該被保存起來:

每次用戶進行二選一后,未被選擇的那項直接丟棄,被選擇的條目重新放回entries的末尾,然后從entries頭部選擇下一對投票項:

我們可以想象一下,這么周而復始的投票,最終將會得到一個結果,投票也就結束了:

如此設計看起來是合情合理的。針對上面的場景存在很多不同的設計,我們當前的做法也可能不是最佳的,但我們暫時就先這么定吧,足夠我們進行下一步了。最重要的是我們在沒有寫任何代碼的前提下已經從最初的點子過渡到確定了應用的具體功能。

項目安排

是時候開始臟活累活了。開始之前,我們先創建一個項目目錄:

mkdir voting-server
cd voting-server
npm init         #所有提示問題直接敲回車即可

初始化完畢后,我們的項目目錄下將會只存在一個 package.json 文件。

我們將采用ES6語法來寫代碼。Node是從4.0.0版本后開始支持大多數ES6語法的,并且目前并不支持modules,但我們需要用到。我們將加入Babel,這樣我們就能將ES6直接轉換成ES5了:

npm install --save-dev babel

我們還需要些庫來用于寫單元測試:

npm install --save-dev mocha chai

Mocha 是一個我們將要使用的測試框架, Chai 是一個我們用來測試的斷言庫。

我們將使用下面的mocha命令來跑測試項:

./node_modules/mocha/bin/mocha --compilers js:babel/register --recursive

這條命令告訴Mocha遞歸的去項目中查找并執行所有測試項,但執行前先使用Babel進行語法轉換。

為了使用方便,可以在我們的 package.json 中添加下面這段代碼:

"scripts": {
      "test": "mocha --compilers js:babel/register --recursive"
},

這樣以后我們跑測試就只需要執行:

npm run test

另外,我們還可以添加 test:watch 命令,它用來監控文件變化并自動跑測試項:

"scripts": {
      "test": "mocha --compilers js:babel/register --recursive",
      "test:watch": "npm run test -- --watch"
},

我們還將用到一個庫,來自于非死book: Immutable ,它提供了許多數據結構供我們使用。下一小節我們再來討論Immutable,但我們在這里先將它加入到我們的項目中,附帶 chai-immutable 庫,它用來向Chai庫加入不可變數據結構比對功能:

npm install --save immutable npm install --save-dev chai-immutable

我們需要在所有測試代碼前先加入chai-immutable插件,所以我們來先創建一個測試輔助文件:

//test/test_helper.js

import chai from 'chai';
import chaiImmutable from 'chai-immutable';

chai.use(chaiImmutable);

然后我們需要讓Mocha在開始跑測試之前先加載這個文件,修改package.json:

"scripts": {
      "test": "mocha --compilers js:babel/register -- require ./test/test_helper.js --recursive",
      "test:watch": "npm run test -- --watch"
},

好了,準備的差不多了。

酸爽的Immutable

第二個值得重視的點是,Redux架構下狀態并非只是一個普通的tree,而是一棵不可變的tree。

回想一下前面我們設計的狀態tree,你可能會覺得可以直接在應用的代碼里直接更新tree:修改映射的值,或刪除數組元素等。然而,這并不是Redux允許的。

一個Redux應用的狀態樹是不可變的數據結構。這意味著,一旦你得到了一棵狀態樹,它就不會在改變了。任何用戶行為改變應用狀態,你都會獲取一棵映射應用改變后新狀態的完整狀態樹。

這說明任何連續的狀態(改變前后)都被分別存儲在獨立的兩棵樹。你通過調用一個函數來從一種狀態轉入下一個狀態。

這么做好在哪呢?第一,用戶通常想一個undo功能,當你誤操作導致破壞了應用狀態后,你往往想退回到應用的歷史狀態,而單一的狀態tree讓該需求變得廉價,你只需要簡單保存上一個狀態tree的數據即可。你也可以序列化tree并存儲起來以供將來重放,這對debug很有幫助的。

拋開其它的特性不談,不可變數據至少會讓你的代碼變得簡單,這非常重要。你可以用純函數來進行編程:接受參數數據,返回數據,其它啥都不做。這種函數擁有可預見性,你可以多次調用它,只要參數一致,它總返回相同的結果(冪等性)。測試將變的容易,你不需要在測試前創建太多的準備,僅僅是傳入參數和返回值。

不可變數據結構是我們創建應用狀態的基礎,讓我們花點時間來寫一些測試項來保證它的正常工作。

為了更了解不可變性,我們來看一個十分簡單的數據結構:假設我們有一個計數應用,它只包含一個計數器變量,該變量會從0增加到1,增加到2,增加到3,以此類推。

如果用不可變數據來設計這個計數器變量,則每當計數器自增,我們不是去改變變量本身。你可以想象成該計數器變量沒有“setters”方法,你不能執行 42.setValue(43) 。

每當變化發生,我們將獲得一個新的變量,它的值是之前的那個變量的值加1等到的。我們可以為此寫一個純函數,它接受一個參數代表當前的狀態,并返回一個值表示新的狀態。記住,調用它并會修改傳入參數的值。這里看一下函數實現和測試代碼:

//test/immutable_spec.js
import {expect} from 'chai';
describe('immutability', () => {
    describe('a number', () => {
      function increment(currentState) {
          return currentState + 1;
      }
      it('is immutable', () => {
          let state = 42;
          let nextState = increment(state);
          expect(nextState).to.equal(43);
          expect(state).to.equal(42);
      });
    });
});

可以看到當 increment 調用后 state 并沒有被修改,這是因為 Numbers 是不可變的。

我們接下來要做的是讓各種數據結構都不可變,而不僅僅是一個整數。

利用Immutable提供的 Lists ,我們可以假設我們的應用擁有一個電影列表的狀態,并且有一個操作用來向當前列表中添加新電影,新列表數據是添加前的列表數據和新增的電影條目合并后的結果,注意,添加前的舊列表數據并沒有被修改哦:

//test/immutable_spec.json

import {expect} from 'chai';
import {List} from 'immutable';

describe('immutability', () => {

      // ...

      describe('A List', () => {

        function addMovie(currentState, movie) {
              return currentState.push(movie);
        }

        it('is immutable', () => {
              let state = List.of('Trainspotting', '28 Days Later');
              let nextState = addMovie(state, 'Sunshine');

              expect(nextState).to.equal(List.of(
                'Trainspotting',
                '28 Days Later',
                'Sunshine'
              ));
              expect(state).to.equal(List.of(
                'Trainspotting',
                '28 Days Later'
              ));
        });
      });
});

如果我們使用的是原生態js數組,那么上面的 addMovie 函數并不會保證舊的狀態不會被修改。這里我們使用的是Immutable List。

真實軟件中,一個狀態樹通常是嵌套了多種數據結構的:list,map以及其它類型的集合。假設狀態樹是一個包含了 movies 列表的hash map,添加一個電影意味著我們需要創建一個新的map,并且在新的map的 movies 元素中添加該新增數據:

//test/immutable_spec.json
import {expect} from 'chai';
import {List, Map} from 'immutable';
describe('immutability', () => {
    // ...
    describe('a tree', () => {
      function addMovie(currentState, movie) {
          return currentState.set(
            'movies',
              currentState.get('movies').push(movie)
          );
      }
      it('is immutable', () => {
          let state = Map({
            movies: List.of('Trainspotting', '28 Days Later')
          });
          let nextState = addMovie(state, 'Sunshine');
          expect(nextState).to.equal(Map({
            movies: List.of(
                'Trainspotting',
                '28 Days Later',
                'Sunshine'
            )
          }));
          expect(state).to.equal(Map({
              movies: List.of(
                'Trainspotting',
                '28 Days Later'
            )
          }));
      });
    });
});

該例子和前面的那個類似,主要用來展示在嵌套結構下Immutable的行為。

針對類似上面這個例子的嵌套數據結構,Immutable提供了很多輔助函數,可以幫助我們更容易的定位嵌套數據的內部屬性,以達到更新對應值的目的。我們可以使用一個叫 update 的方法來修改上面的代碼:

//test/immutable_spec.json

function addMovie(currentState, movie) {
      return currentState.update('movies', movies => movies.push(movie));
}

現在我們很好的了解了不可變數據,這將被用于我們的應用狀態。 Immutable API 提供了非常多的輔助函數,我們目前只是學了點皮毛。

不可變數據是Redux的核心理念,但并不是必須使用Immutable庫來實現這個特性。事實上, 官方Redux文檔 使用的是原生js對象和數組,并通過簡單的擴展它們來實現的。

這個教程中,我們將使用Immutable庫,原因如下:

  • 該庫將使得實現不可變數據結構變得非常簡單;
  • 我個人偏愛于將盡可能的使用不可變數據,如果你的數據允許直接修改,遲早會有人踩坑;
  • 不可變數據結構更新是持續的,意味著很容易產生性能平靜,特別維護是非常龐大的狀態樹,使用原生js對象和數組意味著要頻繁的進行拷貝,很容易導致性能問題。

基于純函數實現應用邏輯

根據目前我們掌握的不可變狀態樹和相關操作,我們可以嘗試實現投票應用的邏輯。應用的核心邏輯我們拆分成:狀態樹結構和生成新狀態樹的函數集合。

加載條目

首先,之前說到,應用允許“加載”一個用來投票的條目集。我們需要一個 setEntries 函數,它用來提供應用的初始化狀態:

//test/core_spec.js

import {List, Map} from 'immutable';
import {expect} from 'chai';

import {setEntries} from '../src/core';

describe('application logic', () => {

  describe('setEntries', () => {

    it('adds the entries to the state', () => {
      const state = Map();
      const entries = List.of('Trainspotting', '28 Days Later');
      const nextState = setEntries(state, entries);
      expect(nextState).to.equal(Map({
        entries: List.of('Trainspotting', '28 Days Later')
      }));
    });
  });
});

我們目前 setEntries 函數的第一版非常簡單:在狀態map中創建一個 entries 鍵,并設置給定的條目List。

//src/core.js

export function setEntries(state, entries) {
    return state.set('entries', entries);
}

為了方便起見,我們允許函數第二個參數接受一個原生js數組(或支持iterable的類型),但在狀態樹中它應該是一個Immutable List:

//test/core_spec.js

it('converts to immutable', () => {
  const state = Map();
  const entries = ['Trainspotting', '28 Days Later'];
  const nextState = setEntries(state, entries);
  expect(nextState).to.equal(Map({
    entries: List.of('Trainspotting', '28 Days Later')
  }));
});

為了達到要求,我們需要修改一下代碼:

//src/core.js

import {List} from 'immutable';

export function setEntries(state, entries) {
  return state.set('entries', List(entries));
}

開始投票

當state加載了條目集合后,我們可以調用一個 next 函數來開始投票。這表示,我們到了之前設計的狀態樹的第二階段。

next 函數需要在狀態樹創建中一個投票map,該map有擁有一個 pair 鍵,值為投票條目中的前兩個元素。

這兩個元素一旦確定,就要從之前的條目列表中清除:

//test/core_spec.js

import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next} from '../src/core';

describe('application logic', () => {

  // ..

  describe('next', () => {

    it('takes the next two entries under vote', () => {
      const state = Map({
        entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
      });
      const nextState = next(state);
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List.of('Sunshine')
      }));
    });
  });
});

next 函數實現如下:

//src/core.js

import {List, Map} from 'immutable';

// ...

export function next(state) {
  const entries = state.get('entries');
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}

投票

當用戶產生投票行為后,每當用戶給某個條目投了一票后, vote 將會為這個條目添加 tally 信息,如果對應的

條目信息已存在,則需要則增:

//test/core_spec.js

import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next, vote} from '../src/core';

describe('application logic', () => {

  // ...

  describe('vote', () => {

    it('creates a tally for the voted entry', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 1
          })
        }),
        entries: List()
      }));
    });

    it('adds to existing tally for the voted entry', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 3,
            '28 Days Later': 2
          })
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 4,
            '28 Days Later': 2
          })
        }),
        entries: List()
      }));
    });
  });
});

為了讓上面的測試項通過,我們可以如下實現 vote 函數:

//src/core.js

export function vote(state, entry) {
  return state.updateIn(
    ['vote', 'tally', entry],
    0,
    tally => tally + 1
  );
}

updateIn 讓我們更容易完成目標。

它接受的第一個參數是個表達式,含義是“定位到嵌套數據結構的指定位置,路徑為:[‘vote’, ‘tally’, ‘Trainspotting’]”,

并且執行后面邏輯:如果路徑指定的位置不存在,則創建新的映射對,并初始化為0,否則對應值加1。

可能對你來說上面的語法太過于晦澀,但一旦你掌握了它,你將會發現用起來非常的酸爽,所以花一些時間學習并適應它是非常值得的。

繼續投票

每次完成一次二選一投票,用戶將進入到第二輪投票,每次得票最高的選項將被保存并添加回條目集合。我們需要添加

這個邏輯到 next 函數中:

//test/core_spec.js

describe('next', () => {

  // ...

  it('puts winner of current vote back to entries', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting')
    }));
  });

  it('puts both from tied vote back to entries', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 3,
          '28 Days Later': 3
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
    }));
  });
});

我們需要一個 getWinners 函數來幫我們選擇誰是贏家:

//src/core.js

function getWinners(vote) {
  if (!vote) return [];
  const [a, b] = vote.get('pair');
  const aVotes = vote.getIn(['tally', a], 0);
  const bVotes = vote.getIn(['tally', b], 0);
  if      (aVotes > bVotes)  return [a];
  else if (aVotes < bVotes)  return [b];
  else                       return [a, b];
}

export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}

投票結束

當投票項只剩一個時,投票結束:

//test/core_spec.js

describe('next', () => {

  // ...

  it('marks winner when just one entry left', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List()
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      winner: 'Trainspotting'
    }));
  });
});

我們需要在 next 函數中增加一個條件分支,用來匹配上面的邏輯:

//src/core.js

export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  if (entries.size === 1) {
    return state.remove('vote')
                .remove('entries')
                .set('winner', entries.first());
  } else {
    return state.merge({
      vote: Map({pair: entries.take(2)}),
      entries: entries.skip(2)
    });
  }
}

我們可以直接返回 Map({winner: entries.first()}) ,但我們還是基于舊的狀態數據進行一步一步的

操作最終得到結果,這么做是為將來做打算。因為應用將來可能還會有很多其它狀態數據在Map中,這是一個寫測試項的好習慣。

所以我們以后要記住,不要重新創建一個狀態數據,而是從舊的狀態數據中生成新的狀態實例。

到此為止我們已經有了一套可以接受的應用核心邏輯實現,表現形式為幾個獨立的函數。我們也有針對這些函數的

測試代碼,這些測試項很容易寫:No setup, no mocks, no stubs。這就是純函數的魅力,我們只需要調用它們,

并檢查返回值就行了。

提醒一下,我們目前還沒有安裝redux哦,我們就已經可以專注于應用自身的邏輯本身進行實現,而不被所謂的框架所干擾。這真的很不錯,對吧?

初識Actions和Reducers

我們有了應用的核心函數,但在Redux中我們不應該直接調用函數。在這些函數和應用之間還存在這一個中間層:Actions。

Action是一個描述應用狀態變化發生的簡單數據結構。按照約定,每個action都包含一個 type 屬性,

該屬性用于描述操作類型。action通常還包含其它屬性,下面是一個簡單的action例子,該action用來匹配

前面我們寫的業務操作:

{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}

{type: 'NEXT'}

{type: 'VOTE', entry: 'Trainspotting'}

actions的描述就這些,但我們還需要一種方式用來把它綁定到我們實際的核心函數上。舉個例子:

// 定義一個action
let voteAction = {type: 'VOTE', entry: 'Trainspotting'}
// 該action應該觸發下面的邏輯
return vote(state, voteAction.entry);

我們接下來要用到的是一個普通函數,它用來根據action和當前state來調用指定的核心函數,我們稱這種函數叫:reducer:

//src/reducer.js

export default function reducer(state, action) {
  // Figure out which function to call and call it
}

我們應該測試這個reducer是否可以正確匹配我們之前的三個actions:

//test/reducer_spec.js

import {Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_ENTRIES', () => {
    const initialState = Map();
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });

  it('handles NEXT', () => {
    const initialState = fromJS({
      entries: ['Trainspotting', '28 Days Later']
    });
    const action = {type: 'NEXT'};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later']
      },
      entries: []
    }));
  });

  it('handles VOTE', () => {
    const initialState = fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later']
      },
      entries: []
    });
    const action = {type: 'VOTE', entry: 'Trainspotting'};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      },
      entries: []
    }));
  });
});

我們的reducer將根據action的type來選擇對應的核心函數,它同時也應該知道如何使用action的額外屬性:

//src/reducer.js

import {setEntries, next, vote} from './core';

export default function reducer(state, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

注意,如果reducer沒有匹配到action,則應該返回當前的state。

reducers還有一個需要特別注意的地方,那就是當傳遞一個未定義的state參數時,reducers應該知道如何

初始化state為有意義的值。我們的場景中,初始值為Map,因此如果傳給reducer一個 undefined state的話,

reducers將使用一個空的Map來代替:

//test/reducer_spec.js

describe('reducer', () => {

  // ...

  it('has an initial state', () => {
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(undefined, action);
    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });
});

之前在我們的 cores.js 文件中,我們定義了初始值:

//src/core.js

export const INITIAL_STATE = Map();

所以在reducer中我們可以直接導入它:

//src/reducer.js

import {setEntries, next, vote, INITIAL_STATE} from './core';

export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

事實上,提供一個action集合,你可以將它們分解并作用在當前狀態上,這也是為什么稱它們為reducer的原因:它完全適配reduce方法:

//test/reducer_spec.js

it('can be used with reduce', () => {
  const actions = [
    {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
    {type: 'NEXT'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'VOTE', entry: '28 Days Later'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'NEXT'}
  ];
  const finalState = actions.reduce(reducer, Map());

  expect(finalState).to.equal(fromJS({
    winner: 'Trainspotting'
  }));
});

相比直接調用核心業務函數,這種批處理或稱之為重放一個action集合的能力主要依賴于狀態轉換的action/reducer模型。

舉個例子,你可以把actions序列化成json,并輕松的將它發送給Web Worker去執行你的reducer邏輯。或者

直接通過網絡發送到其它地方供日后執行!

注意我們這里使用的是普通js對象作為actions,而并非不可變數據類型。這是Redux提倡我們的做法。

嘗試Reducer協作

目前我們的核心函數都是接受整個state并返回更新后的整個state。

這么做在大型應用中可能并不太明智。如果你的應用所有操作都要求必須接受完整的state,那么這個項目維護起來就是災難。日后如果你想進行state結構的調整,你將會付出慘痛的代價。

其實有更好的做法,你只需要保證組件操作盡可能小的state片段即可。我們這里提到的就是模塊化思想:提供給模塊僅它需要的數據,不多不少。

我們的應用很小,所以這并不是太大的問題,但我們還是選擇改善這一點:沒有必要給 vote 函數傳遞整個state,它只需要 vote
部分。讓我們修改一下對應的測試代碼:

//test/core_spec.js

describe('vote', () => {

  it('creates a tally for the voted entry', () => {
    const state = Map({
      pair: List.of('Trainspotting', '28 Days Later')
    });
    const nextState = vote(state, 'Trainspotting')
    expect(nextState).to.equal(Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 1
      })
    }));
  });

  it('adds to existing tally for the voted entry', () => {
    const state = Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 3,
        '28 Days Later': 2
      })
    });
    const nextState = vote(state, 'Trainspotting');
    expect(nextState).to.equal(Map({
      pair: List.of('Trainspotting', '28 Days Later'),
      tally: Map({
        'Trainspotting': 4,
        '28 Days Later': 2
      })
    }));
  });
});

看,測試代碼更加簡單了。

vote 函數的實現也需要更新:

//src/core.js

export function vote(voteState, entry) {
  return voteState.updateIn(
    ['tally', entry],
    0,
    tally => tally + 1
  );
}

最后我們還需要修改 reducer ,只傳遞需要的state給 vote 函數:

//src/reducer.js

export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return state.update('vote',
                        voteState => vote(voteState, action.entry));
  }
  return state;
}

這個做法在大型項目中非常重要:根reducer只傳遞部分state給下一級reducer。我們將定位合適的state片段的工作從對應的更新操作中分離出來。

Redux的reducers文檔 針對這一細節

介紹了更多內容,并描述了一些輔助函數的用法,可以在更多長場景中有效的使用。

初識Redux Store

現在我們可以開始了解如何將上面介紹的內容使用在Redux中了。

如你所見,如果你有一個actions集合,你可以調用 reduce ,獲得最終的應用狀態。當然,通常情況下不會如此,actions

將會在不同的時間發生:用戶操作,遠程調用,超時觸發器等。

針對這些情況,我們可以使用Redux Store。從名字可以看出它用來存儲應用的狀態。

Redux Store通常會由一個reducer函數初始化,如我們之前實現的:

import {createStore} from 'redux';

const store = createStore(reducer);

接下來你就可以向這個Store指派actions了。Store內部將會使用你實現的reducer來處理action,并負責傳遞給reducer應用的state,最后負責存儲reducer返回的新state:

store.dispatch({type: 'NEXT'});

任何時刻你都可以通過下面的方法獲取當前的state:

store.getState();

我們將會創建一個 store.js 用來初始化和導出一個Redux Store對象。讓我們先寫測試代碼吧:

//test/store_spec.js

import {Map, fromJS} from 'immutable';
import {expect} from 'chai';

import makeStore from '../src/store';

describe('store', () => {

  it('is a Redux store configured with the correct reducer', () => {
    const store = makeStore();
    expect(store.getState()).to.equal(Map());

    store.dispatch({
      type: 'SET_ENTRIES',
      entries: ['Trainspotting', '28 Days Later']
    });
    expect(store.getState()).to.equal(fromJS({
      entries: ['Trainspotting', '28 Days Later']
    }));
  });
});

在創建Store之前,我們先在項目中加入Redux庫:

npm install --save redux

然后我們新建 store.js 文件,如下:

//src/store.js

import {createStore} from 'redux';
import reducer from './reducer';

export default function makeStore() {
  return createStore(reducer);
}

Redux Store負責將應用的所有組件關聯起來:它持有應用的當前狀態,并負責指派actions,且負責調用包含了業務邏輯的reducer。

應用的業務代碼和Redux的整合方式非常引人注目,因為我們只有一個普通的reducer函數,這是唯一需要告訴Redux的事兒。其它部分全部都是我們自己的,沒有框架入侵的,高便攜的純函數代碼!

現在我們創建一個應用的入口文件 index.js :

//index.js

import makeStore from './src/store';

export const store = makeStore();

現在我們可以開啟一個 Node REPL (例如babel-node),

載入 index.js 文件來測試執行了。

配置Socket.io服務

我們的應用服務端用來為一個提供投票和顯示結果瀏覽器端提供服務的,為了這個目的,我們需要考慮兩端通信的方式。

這個應用需要實時通信,這確保我們的投票者可以實時查看到所有人的投票信息。為此,我們選擇使用WebSockets作為

通信方式。因此,我們選擇 Socket.io 庫作為跨終端的websocket抽象實現層,它在客戶端

不支持websocket的情況下提供了多種備選方案。

讓我們在項目中加入Socket.io:

npm install --save socket.io

現在,讓我新建一個 server.js 文件:

//src/server.js

import Server from 'socket.io';

export default function startServer() {
const io = new Server().attach(8090);
}

這里我們創建了一個Socket.io 服務,綁定8090端口。端口號是我隨意選的,你可以更改,但后面客戶端連接時要注意匹配。

現在我們可以在 index.js 中調用這個函數:

//index.js

import makeStore from './src/store';
import startServer from './src/server';

export const store = makeStore();
startServer();

我們現在可以在 package.json 中添加 start 指令來方便啟動應用:

//package.json
"scripts": {
    "start": "babel-node index.js",
    "test": "mocha --compilers js:babel/register --require ./test/test_helper.js --recursive",
    "test:watch": "npm run test --watch"
},

這樣我們就可以直接執行下面命令來開啟應用:

npm run start

用Redux監聽器傳播State

我們現在擁有了一個Socket.io服務,也建立了Redux狀態容器,但它們并沒有整合在一起,這就是我們接下來要做的事兒。

我們的服務端需要讓客戶端知道當前的應用狀態(例如:“正在投票的項目是什么?”,“當前的票數是什么?”,

“已經出來結果了嗎?”)。這些都可以通過每當變化發生時 觸發Socket.io事件 來實現。

我們如何得知什么時候發生變化?Redux對此提供了方案:你可以訂閱Redux Store。這樣每當store指派了action之后,在可能發生變化前會調用你提供的指定回調函數。

我們要修改一下 startServer 實現,我們先來調整一下index.js:

//index.js

import makeStore from './src/store';
import {startServer} from './src/server';

export const store = makeStore();
startServer(store);

接下來我們只需監聽store的狀態,并把它序列化后用socket.io事件傳播給所有處于連接狀態的客戶端。

//src/server.js

import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe(
    () => io.emit('state', store.getState().toJS())
  );
}

目前我們的做法是一旦狀態有改變,就發送整個state給所有客戶端,很容易想到這非常不友好,產生大量流量損耗,更好的做法是只傳遞改變的state片段,但我們為了簡單,在這個例子中就先這么實現吧。

除了狀態發生變化時發送狀態數據外,每當新客戶端連接服務器端時也應該直接發送當前的狀態給該客戶端。

我們可以通過監聽Socket.io的 connection 事件來實現上述需求:

//src/server.js

import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe( () => io.emit('state', store.getState().toJS()) );

  io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); });
}

接受遠程調用Redux Actions

除了將應用狀態同步給客戶端外,我們還需要接受來自客戶端的更新操作:投票者需要發起投票,投票組織者需要發起下一輪投票的請求。

我們的解決方案非常簡單。我們只需要讓客戶端發布“action”事件即可,然后我們直接將事件發送給Redux Store:

//src/server.js

import Server from 'socket.io';

export function startServer(store) {
  const io = new Server().attach(8090);

  store.subscribe( () => io.emit('state', store.getState().toJS()) );

  io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); socket.on('action', store.dispatch.bind(store)); });
}

這樣我們就完成了遠程調用actions。Redux架構讓我們的項目更加簡單:actions僅僅是js對象,可以很容易用于網絡傳輸,我們現在實現了一個支持多人投票的服務端系統,很有成就感吧。

現在我們的服務端操作流程如下:

  1. 客戶端發送一個action給服務端;
  2. 服務端交給Redux Store處理action;
  3. Store調用reducer,reducer執行對應的應用邏輯;
  4. Store根據reducer的返回結果來更新狀態;
  5. Store觸發服務端監聽的回調函數;
  6. 服務端觸發“state”事件;
  7. 所有連接的客戶端接受到新的狀態。

在結束服務端開發之前,我們載入一些測試數據來感受一下。我們可以添加 entries.json 文件:

//entries.json

[
  "Shallow Grave",
  "Trainspotting",
  "A Life Less Ordinary",
  "The Beach",
  "28 Days Later",
  "Millions",
  "Sunshine",
  "Slumdog Millionaire",
  "127 Hours",
  "Trance",
  "Steve Jobs"
]

我們在 index.json 中加載它然后發起 next action來開啟投票:

//index.js

import makeStore from './src/store';
import {startServer} from './src/server';

export const store = makeStore();
startServer(store);

store.dispatch({
  type: 'SET_ENTRIES',
  entries: require('./entries.json')
});
store.dispatch({type: 'NEXT'});

那么接下來我們就來看看如何實現客戶端。

客戶端應用

本教程剩余的部分就是寫一個React應用,用來連接服務端,并提供投票給使用者。

在客戶端我們依然使用Redux。這是更常見的搭配:用于React應用的底層引擎。我們已經了解到Redux如何使用。現在我們將學習它是如何結合并影響React應用的。

我推薦大家跟隨本教程的步驟完成應用,但你也可以從 github 上獲取源碼。

客戶端項目創建

第一件事兒我們當然是創建一個新的NPM項目,如下:

mkdir voting-client
cd voting-client
npm init            # Just hit enter for each question

我們的應用需要一個html主頁,我們放在 dist/index.html :

//dist/index.html

<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

這個頁面包含一個id為app的 <div> ,我們將在其中插入我們的應用。在同級目錄下還需要一個 bundle.js 文件。

我們為應用新建第一個js文件,它是系統的入口文件。目前我們先簡單的添加一行日志代碼:

//src/index.js
console.log('I am alive!');

為了給我們客戶端開發減負,我們將使用 Webpack ,讓我們加入到項目中:

npm install --save-dev webpack webpack-dev-server

接下來,我們在項目根目錄新建一個Webpack配置文件:

//webpack.config.js

module.exports = {
  entry: [
    './src/index.js'
  ],
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

配置表明將找到我們的 index.js 入口,并編譯到 dist/bundle.js 中。同時把 dist 目錄當作開發服務器根目錄。

你現在可以執行Webpack來生成 bundle.js :

webpack

你也可以開啟一個開發服務器,訪問localhost:8080來測試頁面效果:

webpack-dev-server

由于我們將使用ES6語法和React的 JSX語法 ,我們需要一些工具。

Babel是一個非常合適的選擇,我們需要Babel庫:

npm install --save-dev babel-core babel-loader

我們可以在Webpack配置文件中添加一些配置,這樣webpack將會對 .jsx 和 .js 文件使用Babel進行處理:

//webpack.config.js
module.exports = {
    entry: [
        './src/index.js'
    ],
    module: {
        loaders: [{
            test: /\.jsx?$/,
            exclude: /node_modules/,
            loader: 'babel'
        }]
    },
    resolve: {
        extensions: ['', '.js', '.jsx']
    },
    output: {
        path: __dirname + '/dist',
        publicPath: '/',
        filename: 'bundle.js'
    },
    devServer: {
        contentBase: './dist'
    }
};

單元測試支持

我們也將會為客戶端代碼編寫一些單元測試。我們使用與服務端相同的測試套件:

npm install --save-dev mocha chai

我們也將會測試我們的React組件,這就要求需要一個DOM庫。我們可能需要像 Karma

庫一樣的功能來進行真實web瀏覽器測試。但我們這里準備使用一個node端純js的dom庫:

npm install --save-dev jsdom@3

在用于react之前我們需要一些jsdom的預備代碼。我們需要創建通常在瀏覽器端被提供的 document 和 window 對象。

并且將它們聲明為全局對象,這樣才能被React使用。我們可以創建一個測試輔助文件做這些工作:

//test/test_helper.js

import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

此外,我們還需要將jsdom提供的 window 對象的所有屬性導入到Node.js的全局變量中,這樣使用這些屬性時

就不需要 window. 前綴,這才滿足在瀏覽器環境下的用法:

//test/test_helper.js

import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

我們還需要使用Immutable集合,所以我們也需要參照后段配置添加相應的庫:

npm install --save immutable npm install --save-dev chai-immutable

現在我們再次修改輔助文件:

//test/test_helper.js

import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

chai.use(chaiImmutable);

最后一步是在 package.json 中添加指令:

//package.json

"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js 'test/**/*.@(js|jsx)'"
},

這幾乎和我們在后端做的一樣,只有兩個地方不同:

  • Babel的編譯器名稱:在該項目中我們使用 babel-core 代替 babel
  • 測試文件設置:服務端我們使用 --recursive ,但這么設置無法匹配 .jsx 文件,所以我們需要使用
    glob

為了實現當代碼發生修改后自動進行測試,我們依然添加 test:watch 指令:

//package.json

"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch"
},

React和react-hot-loader

最后我們來聊聊React!

使用React+Redux+Immutable來開發應用真正酷斃的地方在于:我們可以用純組件(有時候也稱為蠢組件)思想實現任何東西。這個概念與純函數很類似,有如下一些規則:

  1. 一個純組件利用props接受所有它需要的數據,類似一個函數的入參,除此之外它不會被任何其它因素影響;
  2. 一個純組件通常沒有內部狀態。它用來渲染的數據完全來自于輸入props,使用相同的props來渲染相同的純組件多次,
    將得到相同的UI。不存在隱藏的內部狀態導致渲染不同。

這就帶來了 一個和使用純函數一樣的效果

我們可以根據輸入來預測一個組件的渲染,我們不需要知道組件的其它信息。這也使得我們的界面測試變得很簡單,

與我們測試純應用邏輯一樣簡單。

如果組件不包含狀態,那么狀態放在哪?當然在不可變的Store中啊!我們已經見識過它是怎么運作的了,其最大的特點就是從界面代碼中分離出狀態。

在此之前,我們還是先給項目添加React:

npm install --save react

我們同樣需要 react-hot-loader 。它讓我們的開發

變得非常快,因為它提供了我們在不丟失當前狀態的情況下重載代碼的能力:

npm install --save-dev react-hot-loader

我們需要更新一下 webpack.config.js ,使其能熱加載:

//webpack.config.js

var webpack = require('webpack');

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080',
    'webpack/hot/only-dev-server',
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'react-hot!babel'
    }],
  }
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

在上述配置的 entry 里我們包含了2個新的應用入口點:webpack dev server和webpack hot module loader。

它們提供了webpack模塊熱替換能力。該能力并不是默認加載的,所以上面我們才需要在 plugins 和 devServer

中手動加載。

配置的 loaders 部分我們在原先的Babel前配置了 react-hot 用于 .js 和 .jsx 文件。

如果你現在重啟開發服務器,你將看到一個在終端看到Hot Module Replacement已開啟的消息提醒。我們可以開始寫我們的第一個組件了。

實現投票界面

應用的投票界面非常簡單:一旦投票啟動,它將現實2個按鈕,分別用來表示2個可選項,當投票結束,它顯示最終結果。

我們之前都是以測試先行的開發方式,但是在react組件開發中我們將先實現組件,再進行測試。這是因為

webpack和react-hot-loader提供了更加優良的 反饋機制

而且,也沒有比直接看到界面更加好的測試UI手段了。

讓我們假設有一個 Voting 組件,在之前的入口文件 index.html 的 #app div中加載它。由于我們的代碼中

包含JSX語法,所以需要把 index.js 重命名為 index.jsx :

//src/index.jsx

import React from 'react';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

React.render(
  <Voting pair={pair} />, document.getElementById('app') );

Voting 組件將使用 pair 屬性來加載數據。我們目前可以先硬編碼數據,稍后我們將會用真實數據來代替。

組件本身是純粹的,并且對數據來源并不敏感。

注意,在 webpack.config.js 中的入口點文件名也要修改:

//webpack.config.js

entry: [
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  './src/index.jsx'
],

如果你此時重啟webpack-dev-server,你將看到缺失Voting組件的報錯。讓我們修復它:

//src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

你將會在瀏覽器上看到組件創建的2個按鈕。你可以試試修改代碼感受一下瀏覽器自動更新的魅力,沒有刷新,沒有頁面加載,一切都那么迅雷不及掩耳盜鈴。

現在我們來添加第一個單元測試:

//test/components/Voting_spec.jsx

import Voting from '../../src/components/Voting';

describe('Voting', () => {

});

測試組件渲染的按鈕,我們必須先看看它的輸出是什么。要在單元測試中渲染一個組件,我們需要 react/addons 提供

的輔助函數 renderIntoDocument

//test/components/Voting_spec.jsx

import React from 'react/addons';
import Voting from '../../src/components/Voting';

const {renderIntoDocument} = React.addons.TestUtils;

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
  });
});

一旦組件渲染完畢,我就可以通過react提供的另一個輔助函數 scryRenderedDOMComponentsWithTag

來拿到 button 元素。我們期望存在兩個按鈕,并且期望按鈕的值是我們設置的:

//test/components/Voting_spec.jsx

import React from 'react/addons';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

const {renderIntoDocument, scryRenderedDOMComponentsWithTag}
  = React.addons.TestUtils;

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

    expect(buttons.length).to.equal(2);
    expect(buttons[0].getDOMNode().textContent).to.equal('Trainspotting');
    expect(buttons[1].getDOMNode().textContent).to.equal('28 Days Later');
  });
});

如果我們跑一下測試,將會看到測試通過的提示:

npm run test

當用戶點擊某個按鈕后,組件將會調用回調函數,該函數也由組件的prop傳遞給組件。

讓我們完成這一步,我們可以通過使用React提供的測試工具 Simulate

來模擬點擊操作:

//test/components/Voting_spec.jsx

import React from 'react/addons';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

const {renderIntoDocument, scryRenderedDOMComponentsWithTag, Simulate}
  = React.addons.TestUtils;

describe('Voting', () => {

  // ...

  it('invokes callback when a button is clicked', () => {
    let votedWith;
    const vote = (entry) => votedWith = entry;

    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]}
              vote={vote}/>
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
    Simulate.click(buttons[0].getDOMNode());

    expect(votedWith).to.equal('Trainspotting');
  });
});

要想使上面的測試通過很簡單,我們只需要讓按鈕的 onClick 事件調用 vote 并傳遞選中條目即可:

//src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

這就是我們在純組件中常用的方式:組件不需要做太多,只是回調傳入的參數即可。

注意,這里我們又是先寫的測試代碼,我發現業務代碼的測試要比測試UI更容易寫,所以后面我們會保持這種方式:UI測試后行,業務代碼測試先行。

一旦用戶已經針對某對選項投過票了,我們就不應該允許他們再次投票,難道我們應該在組件內部維護某種狀態么?

不,我們需要保證我們的組件是純粹的,所以我們需要分離這個邏輯,組件需要一個 hasVoted 屬性,我們先硬編碼

傳遞給它:

//src/index.jsx

import React from 'react';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

React.render(
  <Voting pair={pair} hasVoted="Trainspotting" />, document.getElementById('app') );

我們可以簡單的修改一下組件即可:

//src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

讓我們再為按鈕添加一個提示,當用戶投票完畢后,在選中的項目上添加標識,這樣用戶就更容易理解:

//src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

投票界面最后要添加的,就是獲勝者樣式。我們可能需要添加新的props:

//src/index.jsx

import React from 'react';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

React.render(
  <Voting pair={pair} winner="Trainspotting" />, document.getElementById('app') );

我們再次修改一下組件:

//src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.props.winner ?
        <div ref="winner">Winner is {this.props.winner}!</div> :
        this.getPair().map(entry =>
          <button key={entry}
                  disabled={this.isDisabled()}
                  onClick={() => this.props.vote(entry)}>
            <h1>{entry}</h1>
            {this.hasVotedFor(entry) ?
              <div className="label">Voted</div> :
              null}
          </button>
        )}
    </div>;
  }
});

目前我們已經完成了所有要做的,但是 render 函數看著有點丑陋,如果我們可以把勝利界面獨立成新的組件

可能會好一些:

//src/components/Winner.jsx

import React from 'react';

export default React.createClass({
  render: function() {
    return <div className="winner">
      Winner is {this.props.winner}!
    </div>;
  }
});

這樣投票組件就會變得很簡單,它只需關注投票按鈕邏輯即可:

//src/components/Vote.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

最后我們只需要在 Voting 組件做一下判斷即可:

//src/components/Voting.jsx

import React from 'react';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  render: function() {
    return <div> {this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <Vote {...this.props} />} </div>;
  }
});

注意這里我們為勝利組件添加了 ref ,這是因為我們將在單元測試中利用它獲取DOM節點。

這就是我們的純組件!注意目前我們還沒有實現任何邏輯:我們并沒有定義按鈕的點擊操作。組件只是用來渲染UI,其它什么都不需要做。后面當我們將UI與Redux Store結合時才會涉及到應用邏輯。

繼續下一步之前我們要為剛才新增的特性寫更多的單元測試代碼。首先, hasVoted 屬性將會使按鈕改變狀態:

//test/components/Voting_spec.jsx

it('disables buttons when user has voted', () => { const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} hasVoted="Trainspotting" /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons.length).to.equal(2); expect(buttons[0].getDOMNode().hasAttribute('disabled')).to.equal(true); expect(buttons[1].getDOMNode().hasAttribute('disabled')).to.equal(true); });

被 hasVoted 匹配的按鈕將顯示 Voted 標簽:

//test/components/Voting_spec.jsx

it('adds label to the voted entry', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons[0].getDOMNode().textContent).to.contain('Voted');
});

當獲勝者產生,界面將不存在按鈕,取而代替的是勝利者元素:

//test/components/Voting_spec.jsx

it('renders just the winner when there is one', () => { const component = renderIntoDocument( <Voting winner="Trainspotting" /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons.length).to.equal(0); const winner = React.findDOMNode(component.refs.winner); expect(winner).to.be.ok; expect(winner.textContent).to.contain('Trainspotting'); });

不可變數據和純粹渲染

我們之前已經討論了許多關于不可變數據的紅利,但是,當它和react結合時還會有一個非常屌的好處:如果我們創建純react組件并傳遞給它不可變數據作為屬性參數,我們將會讓react在組件渲染檢測中得到最大性能。

這是靠react提供的 PureRenderMixin 實現的。

當該mixin添加到組件中后,組件的更新檢查邏輯將會被改變,由深比對改為高性能的淺比對。

我們之所以可以使用淺比對,就是因為我們使用的是不可變數據。如果一個組件的所有參數都是不可變數據,那么將大大提高應用性能。

我們可以在單元測試里更清楚的看見差別,如果我們向純組件中傳入可變數組,當數組內部元素產生改變后,組件并不會重新渲染:

//test/components/Voting_spec.jsx

it('renders as a pure component', () => {
  const pair = ['Trainspotting', '28 Days Later'];
  const component = renderIntoDocument(
    <Voting pair={pair} />
  );

  let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting');

  pair[0] = 'Sunshine';
  component.setProps({pair: pair});
  firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting');
});

如果我們使用不可變數據,則完全沒有問題:

//test/components/Voting_spec.jsx

import React from 'react/addons';
import {List} from 'immutable';
import Voting from '../../src/components/Voting';
import {expect} from 'chai';

const {renderIntoDocument, scryRenderedDOMComponentsWithTag, Simulate}
  = React.addons.TestUtils;

describe('Voting', () => {

  // ...

  it('does update DOM when prop changes', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const component = renderIntoDocument(
      <Voting pair={pair} /> );

    let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting');

    const newPair = pair.set(0, 'Sunshine');
    component.setProps({pair: newPair});
    firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.getDOMNode().textContent).to.equal('Sunshine');
  });
});

如果你跑上面的兩個測試,你將會看到非預期的結果:因為實際上UI在兩種場景下都更新了。那是因為現在組件依然使用的是深比對,這正是我們使用不可變數據想極力避免的。

下面我們在組件中引入mixin,你就會拿到期望的結果了:

//src/components/Voting.jsx

import React from 'react/addons';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  // ...
});



//src/components/Vote.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  // ...
});



//src/components/Winner.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  // ...
});

投票結果頁面和路由實現

投票頁面已經搞定了,讓我們開始實現投票結果頁面吧。

投票結果頁面依然會顯示兩個條目,并且顯示它們各自的票數。此外屏幕下方還會有一個按鈕,供用戶切換到下一輪投票。

現在我們根據什么來確定顯示哪個界面呢?使用URL是個不錯的主意:我們可以設置根路徑 #/ 去顯示投票頁面,

使用 #/results 來顯示投票結果頁面。

我們使用 react-router 可以很容易實現這個需求。讓我們加入項目:

npm install --save react-router

我們這里使用的react-router的0.13版本,它的1.0版本官方還沒有發布,如果你打算使用其1.0RC版,那么下面的代碼

你可能需要做一些修改,可以看 router文檔

我們現在可以來配置一下路由路徑,Router提供了一個 Route 組件用來讓我們定義路由信息,同時也提供了 DefaultRoute
組件來讓我們定義默認路由:

//src/index.jsx

import React from 'react';
import {Route, DefaultRoute} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

const routes = <Route handler={App}> <DefaultRoute handler={Voting} /> </Route>;

React.render(
  <Voting pair={pair} />, document.getElementById('app') );

我們定義了一個默認的路由指向我們的 Voting 組件。我們需要定義個 App 組件來用于Route使用。

根路由的作用就是為應用指定一個根組件:通常該組件充當所有子頁面的模板。讓我們來看看 App 的細節:

//src/components/App.jsx

import React from 'react';
import {RouteHandler} from 'react-router';
import {List} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');

export default React.createClass({
  render: function() {
    return <RouteHandler pair={pair} /> } });

這個組件除了渲染了一個 RouteHandler 組件并沒有做別的,這個組件同樣是react-router提供的,它的作用就是

每當路由匹配了某個定義的頁面后將對應的頁面組件插入到這個位置。目前我們只定義了一個默認路由指向 Voting ,

所以目前我們的組件總是會顯示 Voting 界面。

注意,我們將我們硬編碼的投票數據從 index.jsx 移到了 App.jsx ,當你給 RouteHandler 傳遞了屬性值時,

這些參數將會傳給當前路由對應的組件。

現在我們可以更新 index.jsx :

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const routes = <Route handler={App}> <DefaultRoute handler={Voting} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Root />, document.getElementById('app') ); });

run 方法會根據當前瀏覽器的路徑去查找定義的router來決定渲染哪個組件。一旦確定了對應的組件,它將會被

當作指定的 Root 傳給 run 的回調函數,在回調中我們將使用 React.render 將其插入DOM中。

目前為止我們已經基于React router實現了之前的內容,我們現在可以很容易添加更多新的路由到應用。讓我們把投票結果頁面添加進去吧:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={Voting} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Root />, document.getElementById('app') ); });

這里我們用使用 <Route> 組件定義了一個名為 /results 的路徑,并綁定 Results 組件。

讓我們簡單的實現一下這個 Results 組件,這樣我們就可以看一下路由是如何工作的了:

//src/components/Results.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  render: function() {
    return <div>Hello from results!</div> } });

如果你在瀏覽器中輸入 http://localhost:8080/#/results ,你將會看到該結果組件。

而其它路徑都對應這投票頁面,你也可以使用瀏覽器的前后按鈕來切換這兩個界面。

接下來我們來實際實現一下結果組件:

//src/components/Results.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
        </div>
      )}
    </div>;
  }
});

結果界面除了顯示投票項外,還應該顯示它們對應的得票數,讓我們先硬編碼一下:

//src/components/App.jsx

import React from 'react/addons';
import {RouteHandler} from 'react-router';
import {List, Map} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');
const tally = Map({'Trainspotting': 5, '28 Days Later': 4});

export default React.createClass({
  render: function() {
    return <RouteHandler pair={pair} tally={tally} /> } });

現在,我們再來修改一下結果組件:

//src/components/Results.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
          <div className="voteCount">
            {this.getVotes(entry)}
          </div>
        </div>
      )}
    </div>;
  }
});

現在我們來針對目前的界面功能編寫測試代碼,以防止未來我們破壞這些功能。

我們期望組件為每個選項都渲染一個div,并在其中顯示選項的名稱和票數。如果對應的選項沒有票數,則默認顯示0:

//test/components/Results_spec.jsx

import React from 'react/addons';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';

const {renderIntoDocument, scryRenderedDOMComponentsWithClass}
  = React.addons.TestUtils;

describe('Results', () => {

  it('renders entries with vote counts or zero', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const tally = Map({'Trainspotting': 5});
    const component = renderIntoDocument(
      <Results pair={pair} tally={tally} /> );
    const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
    const [train, days] = entries.map(e => e.getDOMNode().textContent);

    expect(entries.length).to.equal(2);
    expect(train).to.contain('Trainspotting');
    expect(train).to.contain('5');
    expect(days).to.contain('28 Days Later');
    expect(days).to.contain('0');
  });
});

接下來,我們看一下”Next”按鈕,它允許用戶切換到下一輪投票。

我們的組件應該包含一個回調函數屬性參數,當組件中的”Next”按鈕被點擊后,該回調函數將會被調用。我們來寫一下這個操作的測試代碼:

//test/components/Results_spec.jsx

import React from 'react/addons';
import {List, Map} from 'immutable';
import Results from '../../src/components/Results';
import {expect} from 'chai';

const {renderIntoDocument, scryRenderedDOMComponentsWithClass, Simulate}
  = React.addons.TestUtils;

describe('Results', () => {

  // ...

  it('invokes the next callback when next button is clicked', () => {
    let nextInvoked = false;
    const next = () => nextInvoked = true;

    const pair = List.of('Trainspotting', '28 Days Later');
    const component = renderIntoDocument(
      <Results pair={pair}
               tally={Map()}
               next={next}/>
    );
    Simulate.click(React.findDOMNode(component.refs.next));

    expect(nextInvoked).to.equal(true);
  });
});

寫法和之前的投票按鈕很類似吧。接下來讓我們更新一下結果組件:

//src/components/Results.jsx

import React from 'react/addons';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return <div className="results">
      <div className="tally">
        {this.getPair().map(entry =>
          <div key={entry} className="entry">
            <h1>{entry}</h1>
            <div class="voteCount"> {this.getVotes(entry)}
            </div>
          </div>
        )}
      </div>
      <div className="management">
        <button ref="next"
                className="next"
                onClick={this.props.next}>
          Next
        </button>
      </div>
    </div>;
  }
});

最終投票結束,結果頁面和投票頁面一樣,都要顯示勝利者:

//test/components/Results_spec.jsx
it('renders the winner when there is one', () => { const component = renderIntoDocument( <Results winner="Trainspotting" pair={["Trainspotting", "28 Days Later"]} tally={Map()} /> ); const winner = React.findDOMNode(component.refs.winner); expect(winner).to.be.ok; expect(winner.textContent).to.contain('Trainspotting'); });

我們可以想在投票界面中那樣簡單的實現一下上面的邏輯:

//src/components/Results.jsx

import React from 'react/addons';
import Winner from './Winner';

export default React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

到目前為止,我們已經實現了應用的UI,雖然現在它們并沒有和真實數據和操作整合起來。這很不錯不是么?我們只需要一些占位符數據就可以完成界面的開發,這讓我們在這個階段更專注于UI。

接下來我們將會使用Redux Store來將真實數據整合到我們的界面中。

初識客戶端的Redux Store

Redux將會充當我們UI界面的狀態容器,我們已經在服務端用過Redux,之前說的很多內容在這里也受用。現在我們已經準備好要在React應用中使用Redux了,這也是Redux更常見的使用場景。

和在服務端一樣,我們先來思考一下應用的狀態。客戶端的狀態和服務端會非常的類似。

我們有兩個界面,并在其中需要顯示成對的用于投票的條目:

此外,結果頁面需要顯示票數:

投票組件還需要記錄當前用戶已經投票過的選項:

結果組件還需要記錄勝利者:

[譯]全棧Redux實戰

注意這里除了 hasVoted 外,其它都映射著服務端狀態的子集。

接下來我們來思考一下應用的核心邏輯,actions和reducers應該是什么樣的。

我們先來想想能夠導致應用狀態改變的操作都有那些?狀態改變的來源之一是用戶行為。我們的UI中存在兩種可能的用戶操作行為:

  • 用戶在投票頁面點擊某個投票按鈕;
  • 用戶點擊下一步按鈕。

另外,我們知道我們的服務端會將應用當前狀態發送給客戶端,我們將編寫代碼來接受狀態數據,這也是導致狀態改變的來源之一。

我們可以從服務端狀態更新開始,之前我們在服務端設置發送了一個 state 事件。該事件將攜帶我們之前設計的客戶端

狀態樹的狀態數據。我們的客戶端reducer將通過一個action來將服務器端的狀態數據合并到客戶端狀態樹中,

這個action如下:

{
  type: 'SET_STATE',
  state: {
    vote: {...}
  }
}

讓我們先寫一下reducer測試代碼,它應該接受上面定義的那種action,并合并數據到客戶端的當前狀態中:

//test/reducer_spec.js

import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({Trainspotting: 1})
        })
      })
    };
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }));
  });
});

這個renducers接受一個來自socket發送的原始的js數據結構,這里注意不是不可變數據類型哦。我們需要在返回前將其轉換成不可變數據類型:

//test/reducer_spec.js

it('handles SET_STATE with plain JS payload', () => {
  const initialState = Map();
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

reducer同樣應該可以正確的處理 undefined 初始化狀態:

//test/reducer_spec.js

it('handles SET_STATE without initial state', () => {
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(undefined, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

現在我們來看一下如何實現滿足上面測試條件的reducer:

//src/reducer.js

import {Map} from 'immutable';

export default function(state = Map(), action) {

  return state;
}

reducer需要處理 SET_STATE 動作。在這個動作的處理中,我們應該將傳入的狀態數據和現有的進行合并,

使用Map提供的 merge 將很容易來實現這個操作:

//src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  }
  return state;
}

注意這里我們并沒有單獨寫一個核心模塊,而是直接在reducer中添加了個簡單的 setState 函數來做業務邏輯。

這是因為現在這個邏輯還很簡單~

關于改變用戶狀態的那兩個用戶交互:投票和下一步,它們都需要和服務端進行通信,我們一會再說。我們現在先把redux添加到項目中:

npm install --save redux

index.jsx 入口文件是一個初始化Store的好地方,讓我們暫時先使用硬編碼的數據來做:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import reducer from './reducer';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={Voting} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Root />, document.getElementById('app') ); });

那么,我們如何在react組件中從Store中獲取數據呢?

讓React從Redux中獲取數據

我們已經創建了一個使用不可變數據類型保存應用狀態的Redux Store。我們還擁有接受不可變數據為參數的

無狀態的純React組件。如果我們能使這些組件從Store中獲取最新的狀態數據,那真是極好的。當狀態變化時,

React會重新渲染組件,pure render mixin可以使得我們的UI避免不必要的重復渲染。

相比我們自己手動實現同步代碼,我們更推薦使用[react-redux][ https://github.com/rackt/react-redux]包來做:

npm install --save react-redux

這個庫主要做的是:

  1. 映射Store的狀態到組件的輸入props中;
  2. 映射actions到組件的回調props中。

為了讓它可以正常工作,我們需要將頂層的應用組件嵌套在react-redux的 Provider 組件中。

這將把Redux Store和我們的狀態樹連接起來。

我們將讓Provider包含路由的根組件,這樣會使得Provider成為整個應用組件的根節點:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

接下來我們要考慮一下,我們的那些組件需要綁定到Store上。我們一共有5個組件,可以分成三類:

  • 根組件 App 不需要綁定任何數據;
  • Vote 和 Winner 組件只使用父組件傳遞來的數據,所以它們也不需要綁定;
  • 剩下的組件( Voting 和 Results )目前都是使用的硬編碼數據,我們現在需要將其綁定到Store上。

讓我們從 Voting 組件開始。使用react-redux我們得到一個叫 connect 的函數:

connect(mapStateToProps)(SomeComponent);

該函數的作用就是將Redux Store中的狀態數據映射到props對象中。這個props對象將會用于連接到的組件中。

在我們的 Voting 場景中,我們需要從狀態中拿到 pair 和 winner 值:

//src/components/Voting.jsx

import React from 'react/addons';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

const Voting = React.createClass({
  mixins: [React.addons.PureRenderMixin],
  render: function() {
    return <div> {this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <Vote {...this.props} />} </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

connect(mapStateToProps)(Voting);

export default Voting;

在上面的代碼中, connect 函數并沒有修改 Voting 組件本身, Voting 組件依然保持這純粹性。而 connect
返回的是一個 Voting 組件的連接版,我們稱之為 VotingContainer :

//src/components/Voting.jsx

import React from 'react/addons';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

export const Voting = React.createClass({
  mixins: [React.addons.PureRenderMixin],
  render: function() {
    return <div> {this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <Vote {...this.props} />} </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(mapStateToProps)(Voting);

這樣,這個模塊現在導出兩個組件:一個純 Voting 組件,一個連接后的 VotingContainer 版本。

react-redux官方稱前者為“蠢”組件,后者則稱為”智能”組件。我更傾向于用“pure”和“connected”來描述它們。

怎么稱呼隨你便,主要是明白它們之間的差別:

  • 純組件完全靠給它傳入的props來工作,這非常類似一個純函數;
  • 連接組件則封裝了純組件和一些邏輯用來與Redux Store協同工作,這些特性是redux-react提供的。

我們得更新一下路由表,改用 VotingContainer 。一旦修改完畢,我們的投票界面將會使用來自Redux Store的數據:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

而在對應的測試代碼中,我們則需要使用純 Voting 組件定義:

//test/components/Voting_spec.jsx

import React from 'react/addons';
import {List} from 'immutable';
import {Voting} from '../../src/components/Voting';
import {expect} from 'chai';

其它地方不需要修改了。

現在我們來如法炮制投票結果頁面:

//src/components/Results.jsx

import React from 'react/addons';
import {connect} from 'react-redux';
import Winner from './Winner';

export const Results = React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
      </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(mapStateToProps)(Results);

同樣我們需要修改 index.jsx 來使用新的 ResultsContainer :

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

不要忘記修改測試代碼啊:

//test/components/Results_spec.jsx

import React from 'react/addons';
import {List, Map} from 'immutable';
import {Results} from '../../src/components/Results';
import {expect} from 'chai';

現在你已經知道如何讓純react組件與Redux Store整合了。

對于一些只有一個根組件且沒有路由的小應用,直接連接根組件就足夠了。根組件會將狀態數據傳遞給它的子組件。

而對于那些使用路由,就像我們的場景,連接每一個路由指向的處理函數是個好主意。但是分別為每個組件編寫連接代碼并

不適合所有的軟件場景。我覺得保持組件props盡可能清晰明了是個非常好的習慣,因為它可以讓你很容易清楚組件需要哪些數據,

你就可以更容易管理那些連接代碼。

現在讓我們開始把Redux數據對接到UI里,我們再也不需要那些 App.jsx 中手寫的硬編碼數據了,這樣我們的 App.jsx 將會變得簡單:

//src/components/App.jsx

import React from 'react';
import {RouteHandler} from 'react-router';

export default React.createClass({
  render: function() {
    return <RouteHandler /> } });

設置socket.io客戶端

現在我們已經創建好了客戶端的Redux應用,我們接下來將討論如何讓其與我們之前開發的服務端應用進行對接。

服務端已經準備好接受socket連接,并為其進行投票數據的發送。而我們的客戶端也已經可以使用Redux Store很方便的接受數據了。我們剩下的工作就是把它們連接起來。

我們需要使用socket.io從瀏覽器向服務端創建一個連接,我們可以使用 socket.io-client庫 來完成

這個目的:

npm install --save socket.io-client

這個庫賦予了我們連接Socket.io服務端的能力,讓我們連接之前寫好的服務端,端口號8090(注意使用和后端匹配的端口):

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const socket = io(`${location.protocol}//${location.hostname}:8090`);

const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

你必須先確保你的服務端已經開啟了,然后在瀏覽器端訪問客戶端應用,并檢查網絡監控,你會發現創建了一個WebSockets連接,并且開始傳輸Socket.io的心跳包了。

接受來自服務器端的actions

我們雖然已經創建了個socket.io連接,但我們并沒有用它獲取任何數據。每當我們連接到服務端或服務端發生

狀態數據改變時,服務端會發送 state 事件給客戶端。我們只需要監聽對應的事件即可,我們在接受到事件通知后

只需要簡單的對我們的Store指派 SET_STATE action即可:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch({type: 'SET_STATE', state})
);

const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

注意我們移除了 SET_STATE 的硬編碼,我們現在已經不需要偽造數據了。

審視我們的界面,不管是投票還是結果頁面,它們都會顯示服務端提供的第一對選項。服務端和客戶端已經連接上了!

從react組件中指派actions

我們已經知道如何從Redux Store獲取數據到UI中,現在來看看如何從UI中提交數據用于actions。

思考這個問題的最佳場景是投票界面上的投票按鈕。之前在寫相關界面時,我們假設 Voting 組件接受一個回調函數props。

當用戶點擊某個按鈕時組件將會調用這個回調函數。但我們目前并沒有實現這個回調函數,除了在測試代碼中。

當用戶投票后應該做什么?投票結果應該發送給服務端,這部分我們稍后再說,客戶端也需要執行一些邏輯:

組件的 hasVoted 值應該被設置,這樣用戶才不會反復對同一對選項投票。

這是我們要創建的第二個客戶端Redux Action,我們稱之為 VOTE :

//test/reducer_spec.js

it('handles VOTE by setting hasVoted', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Trainspotting'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  }));
});

為了更嚴謹,我們應該考慮一種情況:不管什么原因,當 VOTE action傳遞了一個不存在的選項時我們的應用該怎么做:

//test/reducer_spec.js

it('does not set hasVoted for VOTE on invalid entry', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Sunshine'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

下面來看看我們的reducer如何實現的:

//src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

hasVoted 并不會一直保存在狀態數據中,每當開始一輪新的投票時,我們應該在 SET_STATE action的處理邏輯中

檢查是否用戶是否已經投票,如果還沒,我們應該刪除掉 hasVoted :

//test/reducer_spec.js

it('removes hasVoted on SET_STATE if pair changes', () => {
  const initialState = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  });
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Sunshine', 'Slumdog Millionaire']
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Sunshine', 'Slumdog Millionaire']
    }
  }));
});

根據需要,我們新增一個 resetVote 函數來處理 SET_STATE 動作:

//src/reducer.js

import {List, Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

function resetVote(state) {
  const hasVoted = state.get('hasVoted');
  const currentPair = state.getIn(['vote', 'pair'], List());
  if (hasVoted && !currentPair.includes(hasVoted)) {
    return state.remove('hasVoted');
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return resetVote(setState(state, action.state));
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

我們還需要在修改一下連接邏輯:

//src/components/Voting.jsx

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

現在我們依然需要為 Voting 提供一個 vote 回調函數,用來為Sotre指派我們新增的action。我們依然要盡力保證

Voting 組件的純粹性,不應該依賴任何actions或Redux。這些工作都應該在react-redux的 connect 中處理。

除了連接輸入參數屬性,react-redux還可以用來連接output actions。開始之前,我們先來介紹一下另一個Redux的核心概念:Action creators。

如我們之前看到的,Redux actions通常就是一個簡單的對象,它包含一個固有的 type 屬性和其它內容。我們之前都是直接

利用js對象字面量來直接聲明所需的actions。其實可以使用一個factory函數來更好的生成actions,如下:

function vote(entry) {
  return {type: 'VOTE', entry};
}

這類函數就被稱為action creators。它們就是個純函數,用來返回action對象,別的沒啥好介紹得了。但是你也可以

在其中實現一些內部邏輯,而避免將每次生成action都重復編寫它們。使用action creators可以更好的表達所有需要分發

的actions。

讓我們新建一個用來聲明客戶端所需action的action creators文件:

//src/action_creators.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    type: 'VOTE',
    entry
  };
}

我們當然也可以為action creators編寫測試代碼,但由于我們的代碼邏輯太簡單了,我就不再寫測試了。

現在我們可以在 index.jsx 中使用我們剛新增的 setState action creator了:

//src/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

使用action creators還有一個非常優雅的特點:在我們的場景里,我們有一個需要 vote 回調函數props的

Vote 組件,我們同時擁有一個 vote 的action creator。它們的名字和函數簽名完全一致(都接受一個用來表示

選中項的參數)。現在我們只需要將action creators作為react-redux的 connect 函數的第二個參數,即可完成

自動關聯:

//src/components/Voting.jsx

import React from 'react/addons';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';
import * as actionCreators from '../action_creators';

export const Voting = React.createClass({
  mixins: [React.addons.PureRenderMixin],
  render: function() {
    return <div> {this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <Vote {...this.props} />} </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(
  mapStateToProps,
  actionCreators
)(Voting);

這么配置后,我們的 Voting 組件的 vote 參數屬性將會與 vote aciton creator關聯起來。這樣當點擊

某個投票按鈕后,會導致觸發 VOTE 動作。

使用Redux Middleware發送actions到服務端

最后我們要做的是把用戶數據提交到服務端,這種操作一般發生在用戶投票,或選擇跳轉下一輪投票時發生。

讓我們討論一下投票操作,下面列出了投票的邏輯:

  • 當用戶進行投票, VOTE action將產生并分派到客戶端的Redux Store中;
  • VOTE actions將觸發客戶端reducer進行 hasVoted 狀態設置;
  • 服務端監控客戶端通過socket.io投遞的 action ,它將接收到的actions分派到服務端的Redux Store;
  • VOTE action將觸發服務端的reducer,其會創建vote數據并更新對應的票數。

這樣來說,我們似乎已經都搞定了。唯一缺少的就是讓客戶端發送 VOTE action給服務端。這相當于兩端的

Redux Store相互分派action,這就是我們接下來要做的。

那么該怎么做呢?Redux并沒有內建這種功能。所以我們需要設計一下何時何地來做這個工作:從客戶端發送action到服務端。

Redux提供了一個通用的方法來封裝action: Middleware

Redux中間件是一個函數,每當action將要被指派,并在對應的reducer執行之前會被調用。它常用來做像日志收集,異常處理,修整action,緩存結果,控制何時以何種方式來讓store接收actions等工作。這正是我們可以利用的。

注意,一定要分清Redux中間件和Redux監聽器的差別:中間件被用于action將要指派給store階段,它可以修改action對store將帶來的影響。而監聽器則是在action被指派后,它不能改變action的行為。

我們需要創建一個“遠程action中間件”,該中間件可以讓我們的action不僅僅能指派給本地的store,也可以通過socket.io連接派送給遠程的store。

讓我們創建這個中間件,It is a function that takes a Redux store, and returns another function that takes a “next” callback. That function returns a third function that takes a Redux action. The innermost function is where the middleware implementation will actually go(譯者注:這句套繞口,請看官自行參悟):

//src/remote_action_middleware.js

export default store => next => action => {

}

上面這個寫法看著可能有點滲人,下面調整一下讓大家好理解:

export default function(store) {
    return function(next) {
        return function(action) {

        }
    }
}

這種嵌套接受單一參數函數的寫法成為 currying

這種寫法主要用來簡化中間件的實現:如果我們使用一個一次性接受所有參數的函數( function(store, next, action) { } ),

那么我們就不得不保證我們的中間件具體實現每次都要包含所有這些參數。

上面的 next 參數作用是在中間件中一旦完成了action的處理,就可以調用它來退出當前邏輯:

//src/remote_action_middleware.js

export default store => next => action => {
  return next(action);
}

如果中間件沒有調用 next ,則該action將丟棄,不再傳到reducer或store中。

讓我們寫一個簡單的日志中間件:

//src/remote_action_middleware.js

export default store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

我們將上面這個中間件注冊到我們的Redux Store中,我們將會抓取到所有action的日志。中間件可以通過Redux

提供的 applyMiddleware 函數綁定到我們的store中:

//src/components/index.jsx

import React from 'react';
import Router, {Route, DefaultRoute} from 'react-router';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import remoteActionMiddleware from './remote_action_middleware';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware
)(createStore);
const store = createStoreWithMiddleware(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>;

Router.run(routes, (Root) => {
  React.render(
    <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });

如果你重啟應用,你將會看到我們設置的中間件會抓到應用觸發的action日志。

那我們應該怎么利用中間件機制來完成從客戶端通過socket.io連接發送action給服務端呢?在此之前我們肯定需要先

有一個連接供中間件使用,不幸的是我們已經有了,就在 index.jsx 中,我們只需要中間件可以拿到它即可。

使用currying風格來實現這個中間件很簡單:

//src/remote_action_middleware.js

export default socket => store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

這樣我們就可以在 index.jsx 中傳入需要的連接了:

//src/index.jsx

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWithMiddleware(reducer);

注意跟之前的代碼比,我們需要調整一下順序,讓socket連接先于store被創建。

一切就緒了,現在就可以使用我們的中間件發送 action 了:

//src/remote_action_middleware.js

export default socket => store => next => action => {
  socket.emit('action', action);
  return next(action);
}

打完收工。現在如果你再點擊投票按鈕,你就會看到所有連接到服務端的客戶端的票數都會被更新!

還有個很嚴重的問題我們要處理:現在每當我們收到服務端發來的 SET_STATE action后,這個action都將會直接回傳給

服務端,這樣我們就造成了一個死循環,這是非常反人類的。

我們的中間件不應該不加處理的轉發所有的action給服務端。個別action,例如 SET_STATE ,應該只在客戶端做

處理。我們在action中添加一個標識位用于識別哪些應該轉發給服務端:

//src/remote_action_middleware.js

export default socket => store => next => action => {
  if (action.meta && action.meta.remote) {
    socket.emit('action', action);
  }
  return next(action);
}

我們同樣應該修改相關的action creators:

//src/action_creators.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

讓我們重新審視一下我們都干了什么:

  1. 用戶點擊投票按鈕, VOTE action被分派;
  2. 遠程action中間件通過socket.io連接轉發該action給服務端;
  3. 客戶端Redux Store處理這個action,記錄本地 hasVoted 屬性;
  4. 當action到達服務端,服務端的Redux Store將處理該action,更新所有投票及其票數;
  5. 設置在服務端Redux Store上的監聽器將改變后的狀態數據發送給所有在線的客戶端;
  6. 每個客戶端將觸發 SET_STATE action的分派;
  7. 每個客戶端將根據這個action更新自己的狀態,這樣就保持了與服務端的同步。

為了完成我們的應用,我們需要實現下一步按鈕的邏輯。和投票類似,我們需要將數據發送到服務端:

//src/action_creator.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

export function next() {
  return {
    meta: {remote: true},
    type: 'NEXT'
  };
}

ResultsContainer 組件將會自動關聯action creators中的next作為props:

//src/components/Results.jsx

import React from 'react/addons';
import {connect} from 'react-redux';
import Winner from './Winner';
import * as actionCreators from '../action_creators';

export const Results = React.createClass({
  mixins: [React.addons.PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next()}>
            Next
          </button>
        </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(
  mapStateToProps,
  actionCreators
)(Results);

徹底完工了!我們實現了一個功能完備的應用。

課后練習

(不翻譯)

 

來自:http://blog.kazaff.me/2015/10/08/[譯]全棧Redux實戰/

 

Save

 本文由用戶 wywang2001 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!