客户端应用

最后更新于:2022-04-01 04:15:53

本教程剩余的部分就是写一个React应用,用来连接服务端,并提供投票给使用者。 在客户端我们依然使用Redux。这是更常见的搭配:用于React应用的底层引擎。我们已经了解到Redux如何使用。 现在我们将学习它是如何结合并影响React应用的。 我推荐大家跟随本教程的步骤完成应用,但你也可以从[github](https://github.com/teropa/redux-voting-client)上获取源码。 ### 客户端项目创建 第一件事儿我们当然是创建一个新的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](http://webpack.github.io/),让我们加入到项目中: ~~~ 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语法](https://facebook.github.io/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](http://karma-runner.github.io/0.13/index.html) 库一样的功能来进行真实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](https://github.com/isaacs/node-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。不存在隐藏的内部状态导致渲染不同。 这就带来了[一个和使用纯函数一样的效果](https://www.youtube.com/watch?v=1uRC3hmKQnM&feature=youtu.be&t=13m10s): 我们可以根据输入来预测一个组件的渲染,我们不需要知道组件的其它信息。这也使得我们的界面测试变得很简单, 与我们测试纯应用逻辑一样简单。 如果组件不包含状态,那么状态放在哪?当然在不可变的Store中啊!我们已经见识过它是怎么运作的了,其 最大的特点就是从界面代码中分离出状态。 在此之前,我们还是先给项目添加React: ~~~ npm install --save react ~~~ 我们同样需要[react-hot-loader](https://github.com/gaearon/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个可选项,当投票结束,它显示最终结果。 [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-10-19_562479b5a7d07.png)](http://teropa.info/images/voting_shots.png) 我们之前都是以测试先行的开发方式,但是在react组件开发中我们将先实现组件,再进行测试。这是因为 webpack和react-hot-loader提供了更加优良的[反馈机制](http://blog.iterate.no/2012/10/01/know-your-feedback-loop-why-and-how-to-optimize-it/)。 而且,也没有比直接看到界面更加好的测试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](https://facebook.github.io/react/docs/test-utils.html#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](https://facebook.github.io/react/docs/test-utils.html#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](https://facebook.github.io/react/docs/test-utils.html#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](https://facebook.github.io/react/docs/more-about-refs.html),这是因为我们将在单元测试中利用它获取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](https://facebook.github.io/react/docs/pure-render-mixin.html)实现的。 当该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](http://rackt.github.io/react-router/)可以很容易实现这个需求。让我们加入项目: ~~~ npm install --save react-router ~~~ 我们这里使用的react-router的0.13版本,它的1.0版本官方还没有发布,如果你打算使用其1.0RC版,那么下面的代码 你可能需要做一些修改,可以看[router文档](https://github.com/rackt/react-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](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更常见的使用场景。 和在服务端一样,我们先来思考一下应用的状态。客户端的状态和服务端会非常的类似。 我们有两个界面,并在其中需要显示成对的用于投票的条目: [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-10-19_562479b5c4766.png)](http://teropa.info/images/vote_client_pair.png) 此外,结果页面需要显示票数: [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-10-19_562479b5e7d50.png)](http://teropa.info/images/vote_client_tally.png) 投票组件还需要记录当前用户已经投票过的选项: [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-10-19_562479b60bb60.png)](http://teropa.info/images/vote_client_hasvoted.png) 结果组件还需要记录胜利者: [![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-10-19_562479b62230e.png)](http://teropa.info/images/vote_server_tree_winner.png) 注意这里除了`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](https://facebook.github.io/immutable-js/docs/#/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]%E5%8C%85%E6%9D%A5%E5%81%9A%EF%BC%9A) ~~~ npm install --save react-redux ~~~ 这个库主要做的是: 1. 映射Store的状态到组件的输入props中; 2. 映射actions到组件的回调props中。 为了让它可以正常工作,我们需要将顶层的应用组件嵌套在react-redux的[Provider](https://github.com/rackt/react-redux#provider-store)组件中。 这将把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](https://github.com/rackt/react-redux#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options)的函数: ~~~ 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库](http://socket.io/docs/client-api/)来完成 这个目的: ~~~ 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](http://rackt.github.io/redux/docs/advanced/Middleware.html)。 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](https://en.wikipedia.org/wiki/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); ~~~ 彻底完工了!我们实现了一个功能完备的应用。
';