本教程剩余的部分就是寫一個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個可選項,當投票結束,它顯示最終結果。
[](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更常見的使用場景。
和在服務端一樣,我們先來思考一下應用的狀態。客戶端的狀態和服務端會非常的類似。
我們有兩個界面,并在其中需要顯示成對的用于投票的條目:
[](http://teropa.info/images/vote_client_pair.png)
此外,結果頁面需要顯示票數:
[](http://teropa.info/images/vote_client_tally.png)
投票組件還需要記錄當前用戶已經投票過的選項:
[](http://teropa.info/images/vote_client_hasvoted.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);
~~~
徹底完工了!我們實現了一個功能完備的應用。