我們先來實現Node應用,這有助于我們專注于核心業務邏輯,而不是過早的被界面干擾。
實現服務端應用,我們需要先了解Redux和Immutable,并且明白它們如何協作。Redux常常被用在React開發中,但它并不限制于此。我們這里就要學習讓Redux如何在其它場景下使用。
我推薦大家跟著我們的指導一起寫出一個應用,但你也可以直接從[github](https://github.com/teropa/redux-voting-server)上下載代碼。
[TOC]
### 設計應用的狀態樹(State Tree)
設計一個Redux應用往往從思考應用的狀態樹數據結構開始,它是用來描述你的應用在任何時間點下狀態的數據結構。
任何的框架和架構都包含狀態。在Ember和Backbone框架里,狀態就是模型(Models)。在Anglar中,狀態常常用Factories和Services來管理。而在大多數Flux實現中,常常用Stores來負責狀態。那Redux又和它們有哪些不同之處呢?
最大的不同之處是,在Redux中,應用的狀態是全部存在一個單一的樹結構中的。換句話說,應用的所有狀態信息都存儲在這個包含map和array的數據結構中。
這么做很有意義,我們馬上就會感受到。最重要的一點是,這么做迫使你將應用的行為和狀態隔離開來。狀態就是純數據,它不包含任何方法或函數。
這么做聽起來存在局限,特別是你剛剛從面向對象思想背景下轉到Redux。但這確實是一種解放,因為這么做將使你專注于數據自身。如果你花一些時間來設計你的應用狀態,其它環節將水到渠成。
這并不是說你總應該一上來就設計你的實體狀態樹然后再做其它部分。通常你最終會同時考慮應用的所有方面。然而,我發現當你想到一個點子時,在寫代碼前先思考在不同解決方案下狀態樹的結構會非常有幫助。
所以,讓我們先看看我們的投票應用的狀態樹應該是什么樣的。應用的目標是可以針對多個選項進行投票,那么符合直覺的一種初始化狀態應該是包含要被投票的選項集合,我們稱之為條目[entries]:
[](http://teropa.info/images/vote_server_tree_entries.png)
當投票開始,還必須定位哪些選項是當前項。所以我們可能還需要一個vote條目,它用來存儲當前投票的數據對,投票項應該是來自entries中的:
[](http://teropa.info/images/vote_server_tree_pair.png)
除此之外,投票的計數也應該被保存起來:
[](http://teropa.info/images/vote_server_tree_tally.png)
每次用戶進行二選一后,未被選擇的那項直接丟棄,被選擇的條目重新放回entries的末尾,然后從entries頭部選擇下一對投票項:
[](http://teropa.info/images/vote_server_tree_next.png)
我們可以想象一下,這么周而復始的投票,最終將會得到一個結果,投票也就結束了:
[](http://teropa.info/images/vote_server_tree_winner.png)
如此設計看起來是合情合理的。針對上面的場景存在很多不同的設計,我們當前的做法也可能不是最佳的,但我們暫時就先這么定吧,足夠我們進行下一步了。最重要的是我們在沒有寫任何代碼的前提下已經從最初的點子過渡到確定了應用的具體功能。
### 項目安排
是時候開始臟活累活了。開始之前,我們先創建一個項目目錄:
~~~
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](https://mochajs.org/)是一個我們將要使用的測試框架,[Chai](http://chaijs.com/)是一個我們用來測試的斷言庫。
我們將使用下面的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"
},
~~~
我們還將用到一個庫,來自于facebook:[Immutable](http://facebook.github.io/immutable-js/),它提供了許多數據結構供我們使用。下一小節我們再來討論Immutable,但我們在這里先將它加入到我們的項目中,附帶[chai-immutable](https://github.com/astorije/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應用的狀態樹是不可變的數據結構。這意味著,一旦你得到了一棵狀態樹,它就不會在改變了。任何用戶行為改變應用狀態,你都會獲取一棵映射應用改變后新狀態的完整狀態樹。
這說明任何連續的狀態(改變前后)都被分別存儲在獨立的兩棵樹。你通過調用一個函數來從一種狀態轉入下一個狀態。
[](http://teropa.info/images/vote_state_succession.png)
這么做好在哪呢?第一,用戶通常想一個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](https://facebook.github.io/immutable-js/docs/#/Listf),我們可以假設我們的應用擁有一個電影列表的狀態,并且有一個操作用來向當前列表中添加新電影,新列表數據是添加前的列表數據和新增的電影條目合并后的結果,注意,添加前的舊列表數據并沒有被修改哦:
~~~
//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](https://facebook.github.io/immutable-js/docs/#/)提供了非常多的輔助函數,我們目前只是學了點皮毛。
不可變數據是Redux的核心理念,但并不是必須使用Immutable庫來實現這個特性。事實上,[官方Redux文檔](http://rackt.github.io/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](https://facebook.github.io/immutable-js/docs/#/Map/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文檔](http://rackt.github.io/redux/docs/basics/Reducers.html)針對這一細節
介紹了更多內容,并描述了一些輔助函數的用法,可以在更多長場景中有效的使用。
### 初識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](http://segmentfault.com/a/1190000002673137)(例如babel-node),
載入`index.js`文件來測試執行了。
### 配置Socket.io服務
我們的應用服務端用來為一個提供投票和顯示結果瀏覽器端提供服務的,為了這個目的,我們需要考慮兩端通信的方式。
這個應用需要實時通信,這確保我們的投票者可以實時查看到所有人的投票信息。為此,我們選擇使用WebSockets作為
通信方式。因此,我們選擇[Socket.io](http://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事件](http://socket.io/docs/server-api/#server#emit)來實現。
我們如何得知什么時候發生變化?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'});
~~~
那么接下來我們就來看看如何實現客戶端。