我们先来实现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]:
[![](https://box.kancloud.cn/2015-10-19_562479aca370c.png)](http://teropa.info/images/vote_server_tree_entries.png)
当投票开始,还必须定位哪些选项是当前项。所以我们可能还需要一个vote条目,它用来存储当前投票的数据对,投票项应该是来自entries中的:
[![](https://box.kancloud.cn/2015-10-19_562479acc37a5.png)](http://teropa.info/images/vote_server_tree_pair.png)
除此之外,投票的计数也应该被保存起来:
[![](https://box.kancloud.cn/2015-10-19_562479acdd973.png)](http://teropa.info/images/vote_server_tree_tally.png)
每次用户进行二选一后,未被选择的那项直接丢弃,被选择的条目重新放回entries的末尾,然后从entries头部选择下一对投票项:
[![](https://box.kancloud.cn/2015-10-19_562479ad0756d.png)](http://teropa.info/images/vote_server_tree_next.png)
我们可以想象一下,这么周而复始的投票,最终将会得到一个结果,投票也就结束了:
[![](https://box.kancloud.cn/2015-10-19_562479ad21e06.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应用的状态树是不可变的数据结构。这意味着,一旦你得到了一棵状态树,它就不会在改变了。任何用户行为改变应用状态,你都会获取一棵映射应用改变后新状态的完整状态树。
这说明任何连续的状态(改变前后)都被分别存储在独立的两棵树。你通过调用一个函数来从一种状态转入下一个状态。
[![](https://box.kancloud.cn/2015-10-19_562479ad31d42.png)](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'});
~~~
那么接下来我们就来看看如何实现客户端。