牛骨文教育服务平台(让学习变的简单)

本教程剩余的部分就是写一个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的预备代码。我们需要创建通常在浏览器端被提供的documentwindow对象。
并且将它们声明为全局对象,这样才能被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模块热替换能力。该能力并不是默认加载的,所以上面我们才需要在pluginsdevServer
中手动加载。

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

如果你现在重启开发服务器,你将看到一个在终端看到Hot Module Replacement已开启的消息提醒。我们可以
开始写我们的第一个组件了。

实现投票界面

应用的投票界面非常简单:一旦投票启动,它将现实2个按钮,分别用来表示2个可选项,当投票结束,它显示最终结果。

我们之前都是以测试先行的开发方式,但是在react组件开发中我们将先实现组件,再进行测试。这是因为
webpack和react-hot-loader提供了更加优良的反馈机制
而且,也没有比直接看到界面更加好的测试UI手段了。

让我们假设有一个Voting组件,在之前的入口文件index.html#appdiv中加载它。由于我们的代码中
包含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更常见的使用场景。

和在服务端一样,我们先来思考一下应用的状态。客户端的状态和服务端会非常的类似。

我们有两个界面,并在其中需要显示成对的用于投票的条目:

此外,结果页面需要显示票数:

投票组件还需要记录当前用户已经投票过的选项:

结果组件还需要记录胜利者:

注意这里除了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]包来做:](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不需要绑定任何数据;
  • VoteWinner组件只使用父组件传递来的数据,所以它们也不需要绑定;
  • 剩下的组件(VotingResults)目前都是使用的硬编码数据,我们现在需要将其绑定到Store上。

让我们从Voting组件开始。使用react-redux我们得到一个叫connect的函数:

connect(mapStateToProps)(SomeComponent);

该函数的作用就是将Redux Store中的状态数据映射到props对象中。这个props对象将会用于连接到的组件中。
在我们的Voting场景中,我们需要从状态中拿到pairwinner值:

//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_STATEaction即可:

//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"
  }));
});

为了更严谨,我们应该考虑一种情况:不管什么原因,当VOTEaction传递了一个不存在的选项时我们的应用该怎么做:

//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_STATEaction的处理逻辑中
检查是否用户是否已经投票,如果还没,我们应该删除掉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 (act