参考英文网站:http://redux.js.org/docs/introduction/Motivation.htmlhtml
本文乱译自一篇英文博文( Full-Stack Redux Tutorial ),本人英语能力不足,技术能力有限,若有错误,多多包涵。 前端
Redux是最近发生在js界使人兴奋的事儿。它把众多优秀的库和框架中很是正确的特性保留了下来:简单且可预测的模型,强调函数式编程和不可变数据,基于api的轻量级实现……你还有什么理由不喜欢呢?node
Redux是一个很是小的代码库,掌握它全部的api并不困难,但对不少同窗来说,它要求的:建立组件(blocks),自知足的纯函数和不可变数据会带来很多别扭,那到底应该怎么办呢?react
这篇文章将会带你建立一个全栈的Redux和Immutable-js应用。咱们将详细讲解建立该应用的Node+Redu后端和React+Redux前端的全部步骤。本指南将使用ES6,Babel,Socket.io,Webpack和Mocha。这是一个很是使人着迷的技术栈选型,你确定不及待的想要开始了。webpack
(不翻译)git
这篇文章须要读者具有开发js应用的能力,咱们讲使用Node,ES6,React,Webpack,和Babel,因此你最好能了解这些工具,这样你才不会掉队。github
在上面提到的工具集中,你须要安装Node和NPM,和一款你喜欢的编辑器。web
咱们将要开发一款应用,它用来为聚会,会议,集会等用户群提供实时投票功能。npm
这个点子来自于现实中咱们常常须要为电影,音乐,编程语言等进行投票。该应用将全部选项两两分组,这样用户能够根据喜爱进行二选一,最终拿到最佳结果。编程
举个例子,这里拿Danny Boyle电影作例子来发起投票:
这个应用有两类独立的界面:用于投票的移动端界面,用于其它功能的浏览器界面。投票结果界面设计成有利于幻灯片或其它更大尺寸的屏幕显示,它用来展现投票的实时结果。
该系统应该有2部分组成:浏览器端咱们使用React来提供用户界面,服务端咱们使用Node来处理投票逻辑。两端通讯咱们选择使用WebSockets。
咱们将使用Redux来组织先后端的应用代码。咱们将使用Immutable数据结构来处理应用的state。
虽然咱们的先后端存在许多类似性,例如都使用Redux。可是它们之间并无什么可复用代码。这更像一个分布式系统,靠传递消息进行通讯。
咱们先来实现Node应用,这有助于咱们专一于核心业务逻辑,而不是过早的被界面干扰。
实现服务端应用,咱们须要先了解Redux和Immutable,而且明白它们如何协做。Redux经常被用在React开发中,但它并不限制于此。咱们这里就要学习让Redux如何在其它场景下使用。
我推荐你们跟着咱们的指导一块儿写出一个应用,但你也能够直接从 github 上下载代码。
设计一个Redux应用每每从思考应用的状态树数据结构开始,它是用来描述你的应用在任什么时候间点下状态的数据结构。
任何的框架和架构都包含状态。在Ember和Backbone框架里,状态就是模型(Models)。在Anglar中,状态经常用Factories和Services来管理。而在大多数Flux实现中,经常用Stores来负责状态。那Redux又和它们有哪些不一样之处呢?
最大的不一样之处是,在Redux中,应用的状态是所有存在一个单一的树结构中的。换句话说,应用的全部状态信息都存储在这个包含map和array的数据结构中。
这么作颇有意义,咱们立刻就会感觉到。最重要的一点是,这么作迫使你将应用的行为和状态隔离开来。状态就是纯数据,它不包含任何方法或函数。
这么作听起来存在局限,特别是你刚刚从面向对象思想背景下转到Redux。但这确实是一种解放,由于这么作将使你专一于数据自身。若是你花一些时间来设计你的应用状态,其它环节将水到渠成。
这并非说你总应该一上来就设计你的实体状态树而后再作其它部分。一般你最终会同时考虑应用的全部方面。然而,我发现当你想到一个点子时,在写代码前先思考在不一样解决方案下状态树的结构会很是有帮助。
因此,让咱们先看看咱们的投票应用的状态树应该是什么样的。应用的目标是能够针对多个选项进行投票,那么符合直觉的一种初始化状态应该是包含要被投票的选项集合,咱们称之为条目[entries]:
当投票开始,还必须定位哪些选项是当前项。因此咱们可能还须要一个vote条目,它用来存储当前投票的数据对,投票项应该是来自entries中的:
除此以外,投票的计数也应该被保存起来:
每次用户进行二选一后,未被选择的那项直接丢弃,被选择的条目从新放回entries的末尾,而后从entries头部选择下一对投票项:
咱们能够想象一下,这么周而复始的投票,最终将会获得一个结果,投票也就结束了:
如此设计看起来是合情合理的。针对上面的场景存在不少不一样的设计,咱们当前的作法也可能不是最佳的,但咱们暂时就先这么定吧,足够咱们进行下一步了。最重要的是咱们在没有写任何代码的前提下已经从最初的点子过渡到肯定了应用的具体功能。
是时候开始脏活累活了。开始以前,咱们先建立一个项目目录:
mkdir voting-server cd voting-server npm init #全部提示问题直接敲回车便可
初始化完毕后,咱们的项目目录下将会只存在一个 package.json 文件。
咱们将采用ES6语法来写代码。Node是从4.0.0版本后开始支持大多数ES6语法的,而且目前并不支持modules,但咱们须要用到。咱们将加入Babel,这样咱们就能将ES6直接转换成ES5了:
npm install --save-dev babel
咱们还须要些库来用于写单元测试:
npm install --save-dev mocha chai
Mocha 是一个咱们将要使用的测试框架, Chai 是一个咱们用来测试的断言库。
咱们将使用下面的mocha命令来跑测试项:
./node_modules/mocha/bin/mocha --compilers js:babel/register --recursive
这条命令告诉Mocha递归的去项目中查找并执行全部测试项,但执行前先使用Babel进行语法转换。
为了使用方便,能够在咱们的 package.json 中添加下面这段代码:
"scripts": { "test": "mocha --compilers js:babel/register --recursive" },
这样之后咱们跑测试就只须要执行:
npm run test
另外,咱们还能够添加 test:watch 命令,它用来监控文件变化并自动跑测试项:
"scripts": { "test": "mocha --compilers js:babel/register --recursive", "test:watch": "npm run test -- --watch" },
咱们还将用到一个库,来自于facebook: Immutable ,它提供了许多数据结构供咱们使用。下一小节咱们再来讨论Immutable,但咱们在这里先将它加入到咱们的项目中,附带 chai-immutable 库,它用来向Chai库加入不可变数据结构比对功能:
npm install --save immutable npm install --save-dev chai-immutable
咱们须要在全部测试代码前先加入chai-immutable插件,因此咱们来先建立一个测试辅助文件:
//test/test_helper.js import chai from 'chai'; import chaiImmutable from 'chai-immutable'; chai.use(chaiImmutable);
而后咱们须要让Mocha在开始跑测试以前先加载这个文件,修改package.json:
"scripts": { "test": "mocha --compilers js:babel/register -- require ./test/test_helper.js --recursive", "test:watch": "npm run test -- --watch" },
好了,准备的差很少了。
第二个值得重视的点是,Redux架构下状态并不是只是一个普通的tree,而是一棵不可变的tree。
回想一下前面咱们设计的状态tree,你可能会以为能够直接在应用的代码里直接更新tree:修改映射的值,或删除数组元素等。然而,这并非Redux容许的。
一个Redux应用的状态树是不可变的数据结构。这意味着,一旦你获得了一棵状态树,它就不会在改变了。任何用户行为改变应用状态,你都会获取一棵映射应用改变后新状态的完整状态树。
这说明任何连续的状态(改变先后)都被分别存储在独立的两棵树。你经过调用一个函数来从一种状态转入下一个状态。
这么作好在哪呢?第一,用户一般想一个undo功能,当你误操做致使破坏了应用状态后,你每每想退回到应用的历史状态,而单一的状态tree让该需求变得廉价,你只须要简单保存上一个状态tree的数据便可。你也能够序列化tree并存储起来以供未来重放,这对debug颇有帮助的。
抛开其它的特性不谈,不可变数据至少会让你的代码变得简单,这很是重要。你能够用纯函数来进行编程:接受参数数据,返回数据,其它啥都不作。这种函数拥有可预见性,你能够屡次调用它,只要参数一致,它总返回相同的结果(冪等性)。测试将变的容易,你不须要在测试前建立太多的准备,仅仅是传入参数和返回值。
不可变数据结构是咱们建立应用状态的基础,让咱们花点时间来写一些测试项来保证它的正常工做。
为了更了解不可变性,咱们来看一个十分简单的数据结构:假设咱们有一个计数应用,它只包含一个计数器变量,该变量会从0增长到1,增长到2,增长到3,以此类推。
若是用不可变数据来设计这个计数器变量,则每当计数器自增,咱们不是去改变变量自己。你能够想象成该计数器变量没有“setters”方法,你不能执行 42.setValue(43)
。
每当变化发生,咱们将得到一个新的变量,它的值是以前的那个变量的值加1等到的。咱们能够为此写一个纯函数,它接受一个参数表明当前的状态,并返回一个值表示新的状态。记住,调用它并会修改传入参数的值。这里看一下函数实现和测试代码:
//test/immutable_spec.js import {expect} from 'chai'; describe('immutability', () => { describe('a number', () => { function increment(currentState) { return currentState + 1; } it('is immutable', () => { let state = 42; let nextState = increment(state); expect(nextState).to.equal(43); expect(state).to.equal(42); }); }); });
能够看到当 increment
调用后 state
并无被修改,这是由于 Numbers
是不可变的。
咱们接下来要作的是让各类数据结构都不可变,而不只仅是一个整数。
利用Immutable提供的 Lists ,咱们能够假设咱们的应用拥有一个电影列表的状态,而且有一个操做用来向当前列表中添加新电影,新列表数据是添加前的列表数据和新增的电影条目合并后的结果,注意,添加前的旧列表数据并无被修改哦:
//test/immutable_spec.json import {expect} from 'chai'; import {List} from 'immutable'; describe('immutability', () => { // ... describe('A List', () => { function addMovie(currentState, movie) { return currentState.push(movie); } it('is immutable', () => { let state = List.of('Trainspotting', '28 Days Later'); let nextState = addMovie(state, 'Sunshine'); expect(nextState).to.equal(List.of( 'Trainspotting', '28 Days Later', 'Sunshine' )); expect(state).to.equal(List.of( 'Trainspotting', '28 Days Later' )); }); }); });
若是咱们使用的是原生态js数组,那么上面的 addMovie
函数并不会保证旧的状态不会被修改。这里咱们使用的是Immutable List。
真实软件中,一个状态树一般是嵌套了多种数据结构的:list,map以及其它类型的集合。假设状态树是一个包含了 movies 列表的hash map,添加一个电影意味着咱们须要建立一个新的map,而且在新的map的 movies 元素中添加该新增数据:
//test/immutable_spec.json import {expect} from 'chai'; import {List, Map} from 'immutable'; describe('immutability', () => { // ... describe('a tree', () => { function addMovie(currentState, movie) { return currentState.set( 'movies', currentState.get('movies').push(movie) ); } it('is immutable', () => { let state = Map({ movies: List.of('Trainspotting', '28 Days Later') }); let nextState = addMovie(state, 'Sunshine'); expect(nextState).to.equal(Map({ movies: List.of( 'Trainspotting', '28 Days Later', 'Sunshine' ) })); expect(state).to.equal(Map({ movies: List.of( 'Trainspotting', '28 Days Later' ) })); }); }); });
该例子和前面的那个相似,主要用来展现在嵌套结构下Immutable的行为。
针对相似上面这个例子的嵌套数据结构,Immutable提供了不少辅助函数,能够帮助咱们更容易的定位嵌套数据的内部属性,以达到更新对应值的目的。咱们可使用一个叫 update
的方法来修改上面的代码:
//test/immutable_spec.json function addMovie(currentState, movie) { return currentState.update('movies', movies => movies.push(movie)); }
如今咱们很好的了解了不可变数据,这将被用于咱们的应用状态。 Immutable API 提供了很是多的辅助函数,咱们目前只是学了点皮毛。
不可变数据是Redux的核心理念,但并非必须使用Immutable库来实现这个特性。事实上, 官方Redux文档 使用的是原生js对象和数组,并经过简单的扩展它们来实现的。
这个教程中,咱们将使用Immutable库,缘由以下:
根据目前咱们掌握的不可变状态树和相关操做,咱们能够尝试实现投票应用的逻辑。应用的核心逻辑咱们拆分红:状态树结构和生成新状态树的函数集合。
首先,以前说到,应用容许“加载”一个用来投票的条目集。咱们须要一个 setEntries
函数,它用来提供应用的初始化状态:
//test/core_spec.js import {List, Map} from 'immutable'; import {expect} from 'chai'; import {setEntries} from '../src/core'; describe('application logic', () => { describe('setEntries', () => { it('adds the entries to the state', () => { const state = Map(); const entries = List.of('Trainspotting', '28 Days Later'); const nextState = setEntries(state, entries); expect(nextState).to.equal(Map({ entries: List.of('Trainspotting', '28 Days Later') })); }); }); });
咱们目前 setEntries
函数的初版很是简单:在状态map中建立一个 entries
键,并设置给定的条目List。
//src/core.js export function setEntries(state, entries) { return state.set('entries', entries); }
为了方便起见,咱们容许函数第二个参数接受一个原生js数组(或支持iterable的类型),但在状态树中它应该是一个Immutable List:
//test/core_spec.js it('converts to immutable', () => { const state = Map(); const entries = ['Trainspotting', '28 Days Later']; const nextState = setEntries(state, entries); expect(nextState).to.equal(Map({ entries: List.of('Trainspotting', '28 Days Later') })); });
为了达到要求,咱们须要修改一下代码:
//src/core.js import {List} from 'immutable'; export function setEntries(state, entries) { return state.set('entries', List(entries)); }
当state加载了条目集合后,咱们能够调用一个 next
函数来开始投票。这表示,咱们到了以前设计的状态树的第二阶段。
next
函数须要在状态树建立中一个投票map,该map有拥有一个 pair
键,值为投票条目中的前两个元素。
这两个元素一旦肯定,就要从以前的条目列表中清除:
//test/core_spec.js import {List, Map} from 'immutable'; import {expect} from 'chai'; import {setEntries, next} from '../src/core'; describe('application logic', () => { // .. describe('next', () => { it('takes the next two entries under vote', () => { const state = Map({ entries: List.of('Trainspotting', '28 Days Later', 'Sunshine') }); const nextState = next(state); expect(nextState).to.equal(Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later') }), entries: List.of('Sunshine') })); }); }); });
next
函数实现以下:
//src/core.js import {List, Map} from 'immutable'; // ... export function next(state) { const entries = state.get('entries'); return state.merge({ vote: Map({pair: entries.take(2)}), entries: entries.skip(2) }); }
当用户产生投票行为后,每当用户给某个条目投了一票后, vote
将会为这个条目添加 tally
信息,若是对应的
条目信息已存在,则须要则增:
//test/core_spec.js import {List, Map} from 'immutable'; import {expect} from 'chai'; import {setEntries, next, vote} from '../src/core'; describe('application logic', () => { // ... describe('vote', () => { it('creates a tally for the voted entry', () => { const state = Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later') }), entries: List() }); const nextState = vote(state, 'Trainspotting'); expect(nextState).to.equal(Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 1 }) }), entries: List() })); }); it('adds to existing tally for the voted entry', () => { const state = Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 3, '28 Days Later': 2 }) }), entries: List() }); const nextState = vote(state, 'Trainspotting'); expect(nextState).to.equal(Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 4, '28 Days Later': 2 }) }), entries: List() })); }); }); });
为了让上面的测试项经过,咱们能够以下实现 vote
函数:
//src/core.js export function vote(state, entry) { return state.updateIn( ['vote', 'tally', entry], 0, tally => tally + 1 ); }
updateIn 让咱们更容易完成目标。
它接受的第一个参数是个表达式,含义是“定位到嵌套数据结构的指定位置,路径为:[‘vote’, ‘tally’, ‘Trainspotting’]”,
而且执行后面逻辑:若是路径指定的位置不存在,则建立新的映射对,并初始化为0,不然对应值加1。
可能对你来讲上面的语法太过于晦涩,但一旦你掌握了它,你将会发现用起来很是的酸爽,因此花一些时间学习并适应它是很是值得的。
每次完成一次二选一投票,用户将进入到第二轮投票,每次得票最高的选项将被保存并添加回条目集合。咱们须要添加
这个逻辑到 next
函数中:
//test/core_spec.js describe('next', () => { // ... it('puts winner of current vote back to entries', () => { const state = Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 4, '28 Days Later': 2 }) }), entries: List.of('Sunshine', 'Millions', '127 Hours') }); const nextState = next(state); expect(nextState).to.equal(Map({ vote: Map({ pair: List.of('Sunshine', 'Millions') }), entries: List.of('127 Hours', 'Trainspotting') })); }); it('puts both from tied vote back to entries', () => { const state = Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 3, '28 Days Later': 3 }) }), entries: List.of('Sunshine', 'Millions', '127 Hours') }); const nextState = next(state); expect(nextState).to.equal(Map({ vote: Map({ pair: List.of('Sunshine', 'Millions') }), entries: List.of('127 Hours', 'Trainspotting', '28 Days Later') })); }); });
咱们须要一个 getWinners
函数来帮咱们选择谁是赢家:
//src/core.js function getWinners(vote) { if (!vote) return []; const [a, b] = vote.get('pair'); const aVotes = vote.getIn(['tally', a], 0); const bVotes = vote.getIn(['tally', b], 0); if (aVotes > bVotes) return [a]; else if (aVotes < bVotes) return [b]; else return [a, b]; } export function next(state) { const entries = state.get('entries') .concat(getWinners(state.get('vote'))); return state.merge({ vote: Map({pair: entries.take(2)}), entries: entries.skip(2) }); }
当投票项只剩一个时,投票结束:
//test/core_spec.js describe('next', () => { // ... it('marks winner when just one entry left', () => { const state = Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 4, '28 Days Later': 2 }) }), entries: List() }); const nextState = next(state); expect(nextState).to.equal(Map({ winner: 'Trainspotting' })); }); });
咱们须要在 next
函数中增长一个条件分支,用来匹配上面的逻辑:
//src/core.js export function next(state) { const entries = state.get('entries') .concat(getWinners(state.get('vote'))); if (entries.size === 1) { return state.remove('vote') .remove('entries') .set('winner', entries.first()); } else { return state.merge({ vote: Map({pair: entries.take(2)}), entries: entries.skip(2) }); } }
咱们能够直接返回 Map({winner: entries.first()})
,但咱们仍是基于旧的状态数据进行一步一步的
操做最终获得结果,这么作是为未来作打算。由于应用未来可能还会有不少其它状态数据在Map中,这是一个写测试项的好习惯。
因此咱们之后要记住,不要从新建立一个状态数据,而是从旧的状态数据中生成新的状态实例。
到此为止咱们已经有了一套能够接受的应用核心逻辑实现,表现形式为几个独立的函数。咱们也有针对这些函数的
测试代码,这些测试项很容易写:No setup, no mocks, no stubs。这就是纯函数的魅力,咱们只须要调用它们,
并检查返回值就好了。
提醒一下,咱们目前尚未安装redux哦,咱们就已经能够专一于应用自身的逻辑自己进行实现,而不被所谓的框架所干扰。这真的很不错,对吧?
咱们有了应用的核心函数,但在Redux中咱们不该该直接调用函数。在这些函数和应用之间还存在这一个中间层:Actions。
Action是一个描述应用状态变化发生的简单数据结构。按照约定,每一个action都包含一个 type
属性,
该属性用于描述操做类型。action一般还包含其它属性,下面是一个简单的action例子,该action用来匹配
前面咱们写的业务操做:
{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']} {type: 'NEXT'} {type: 'VOTE', entry: 'Trainspotting'}
actions的描述就这些,但咱们还须要一种方式用来把它绑定到咱们实际的核心函数上。举个例子:
// 定义一个action let voteAction = {type: 'VOTE', entry: 'Trainspotting'} // 该action应该触发下面的逻辑 return vote(state, voteAction.entry);
咱们接下来要用到的是一个普通函数,它用来根据action和当前state来调用指定的核心函数,咱们称这种函数叫:reducer:
//src/reducer.js export default function reducer(state, action) { // Figure out which function to call and call it }
咱们应该测试这个reducer是否能够正确匹配咱们以前的三个actions:
//test/reducer_spec.js import {Map, fromJS} from 'immutable'; import {expect} from 'chai'; import reducer from '../src/reducer'; describe('reducer', () => { it('handles SET_ENTRIES', () => { const initialState = Map(); const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; const nextState = reducer(initialState, action); expect(nextState).to.equal(fromJS({ entries: ['Trainspotting'] })); }); it('handles NEXT', () => { const initialState = fromJS({ entries: ['Trainspotting', '28 Days Later'] }); const action = {type: 'NEXT'}; const nextState = reducer(initialState, action); expect(nextState).to.equal(fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'] }, entries: [] })); }); it('handles VOTE', () => { const initialState = fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'] }, entries: [] }); const action = {type: 'VOTE', entry: 'Trainspotting'}; const nextState = reducer(initialState, action); expect(nextState).to.equal(fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} }, entries: [] })); }); });
咱们的reducer将根据action的type来选择对应的核心函数,它同时也应该知道如何使用action的额外属性:
//src/reducer.js import {setEntries, next, vote} from './core'; export default function reducer(state, action) { switch (action.type) { case 'SET_ENTRIES': return setEntries(state, action.entries); case 'NEXT': return next(state); case 'VOTE': return vote(state, action.entry) } return state; }
注意,若是reducer没有匹配到action,则应该返回当前的state。
reducers还有一个须要特别注意的地方,那就是当传递一个未定义的state参数时,reducers应该知道如何
初始化state为有意义的值。咱们的场景中,初始值为Map,所以若是传给reducer一个 undefined
state的话,
reducers将使用一个空的Map来代替:
//test/reducer_spec.js describe('reducer', () => { // ... it('has an initial state', () => { const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']}; const nextState = reducer(undefined, action); expect(nextState).to.equal(fromJS({ entries: ['Trainspotting'] })); }); });
以前在咱们的 cores.js
文件中,咱们定义了初始值:
//src/core.js export const INITIAL_STATE = Map();
因此在reducer中咱们能够直接导入它:
//src/reducer.js import {setEntries, next, vote, INITIAL_STATE} from './core'; export default function reducer(state = INITIAL_STATE, action) { switch (action.type) { case 'SET_ENTRIES': return setEntries(state, action.entries); case 'NEXT': return next(state); case 'VOTE': return vote(state, action.entry) } return state; }
事实上,提供一个action集合,你能够将它们分解并做用在当前状态上,这也是为何称它们为reducer的缘由:它彻底适配reduce方法:
//test/reducer_spec.js it('can be used with reduce', () => { const actions = [ {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}, {type: 'NEXT'}, {type: 'VOTE', entry: 'Trainspotting'}, {type: 'VOTE', entry: '28 Days Later'}, {type: 'VOTE', entry: 'Trainspotting'}, {type: 'NEXT'} ]; const finalState = actions.reduce(reducer, Map()); expect(finalState).to.equal(fromJS({ winner: 'Trainspotting' })); });
相比直接调用核心业务函数,这种批处理或称之为重放一个action集合的能力主要依赖于状态转换的action/reducer模型。
举个例子,你能够把actions序列化成json,并轻松的将它发送给Web Worker去执行你的reducer逻辑。或者
直接经过网络发送到其它地方供往后执行!
注意咱们这里使用的是普通js对象做为actions,而并不是不可变数据类型。这是Redux提倡咱们的作法。
目前咱们的核心函数都是接受整个state并返回更新后的整个state。
这么作在大型应用中可能并不太明智。若是你的应用全部操做都要求必须接受完整的state,那么这个项目维护起来就是灾难。往后若是你想进行state结构的调整,你将会付出惨痛的代价。
其实有更好的作法,你只须要保证组件操做尽量小的state片断便可。咱们这里提到的就是模块化思想:提供给模块仅它须要的数据,很少很多。
咱们的应用很小,因此这并非太大的问题,但咱们仍是选择改善这一点:没有必要给 vote
函数传递整个state,它只须要 vote
部分。让咱们修改一下对应的测试代码:
//test/core_spec.js describe('vote', () => { it('creates a tally for the voted entry', () => { const state = Map({ pair: List.of('Trainspotting', '28 Days Later') }); const nextState = vote(state, 'Trainspotting') expect(nextState).to.equal(Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 1 }) })); }); it('adds to existing tally for the voted entry', () => { const state = Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 3, '28 Days Later': 2 }) }); const nextState = vote(state, 'Trainspotting'); expect(nextState).to.equal(Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({ 'Trainspotting': 4, '28 Days Later': 2 }) })); }); });
看,测试代码更加简单了。
vote
函数的实现也须要更新:
//src/core.js export function vote(voteState, entry) { return voteState.updateIn( ['tally', entry], 0, tally => tally + 1 ); }
最后咱们还须要修改 reducer
,只传递须要的state给 vote
函数:
//src/reducer.js export default function reducer(state = INITIAL_STATE, action) { switch (action.type) { case 'SET_ENTRIES': return setEntries(state, action.entries); case 'NEXT': return next(state); case 'VOTE': return state.update('vote', voteState => vote(voteState, action.entry)); } return state; }
这个作法在大型项目中很是重要:根reducer只传递部分state给下一级reducer。咱们将定位合适的state片断的工做从对应的更新操做中分离出来。
Redux的reducers文档 针对这一细节
介绍了更多内容,并描述了一些辅助函数的用法,能够在更多长场景中有效的使用。
如今咱们能够开始了解如何将上面介绍的内容使用在Redux中了。
如你所见,若是你有一个actions集合,你能够调用 reduce
,得到最终的应用状态。固然,一般状况下不会如此,actions
将会在不一样的时间发生:用户操做,远程调用,超时触发器等。
针对这些状况,咱们可使用Redux Store。从名字能够看出它用来存储应用的状态。
Redux Store一般会由一个reducer函数初始化,如咱们以前实现的:
import {createStore} from 'redux'; const store = createStore(reducer);
接下来你就能够向这个Store指派actions了。Store内部将会使用你实现的reducer来处理action,并负责传递给reducer应用的state,最后负责存储reducer返回的新state:
store.dispatch({type: 'NEXT'});
任什么时候刻你均可以经过下面的方法获取当前的state:
store.getState();
咱们将会建立一个 store.js
用来初始化和导出一个Redux Store对象。让咱们先写测试代码吧:
//test/store_spec.js import {Map, fromJS} from 'immutable'; import {expect} from 'chai'; import makeStore from '../src/store'; describe('store', () => { it('is a Redux store configured with the correct reducer', () => { const store = makeStore(); expect(store.getState()).to.equal(Map()); store.dispatch({ type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later'] }); expect(store.getState()).to.equal(fromJS({ entries: ['Trainspotting', '28 Days Later'] })); }); });
在建立Store以前,咱们先在项目中加入Redux库:
npm install --save redux
而后咱们新建 store.js
文件,以下:
//src/store.js import {createStore} from 'redux'; import reducer from './reducer'; export default function makeStore() { return createStore(reducer); }
Redux Store负责将应用的全部组件关联起来:它持有应用的当前状态,并负责指派actions,且负责调用包含了业务逻辑的reducer。
应用的业务代码和Redux的整合方式很是引人注目,由于咱们只有一个普通的reducer函数,这是惟一须要告诉Redux的事儿。其它部分所有都是咱们本身的,没有框架入侵的,高便携的纯函数代码!
如今咱们建立一个应用的入口文件 index.js
:
//index.js import makeStore from './src/store'; export const store = makeStore();
如今咱们能够开启一个 Node REPL (例如babel-node),
载入 index.js
文件来测试执行了。
咱们的应用服务端用来为一个提供投票和显示结果浏览器端提供服务的,为了这个目的,咱们须要考虑两端通讯的方式。
这个应用须要实时通讯,这确保咱们的投票者能够实时查看到全部人的投票信息。为此,咱们选择使用WebSockets做为
通讯方式。所以,咱们选择 Socket.io 库做为跨终端的websocket抽象实现层,它在客户端
不支持websocket的状况下提供了多种备选方案。
让咱们在项目中加入Socket.io:
npm install --save socket.io
如今,让我新建一个 server.js
文件:
//src/server.js import Server from 'socket.io'; export default function startServer() { const io = new Server().attach(8090); }
这里咱们建立了一个Socket.io 服务,绑定8090端口。端口号是我随意选的,你能够更改,但后面客户端链接时要注意匹配。
如今咱们能够在 index.js
中调用这个函数:
//index.js import makeStore from './src/store'; import startServer from './src/server'; export const store = makeStore(); startServer();
咱们如今能够在 package.json
中添加 start
指令来方便启动应用:
//package.json "scripts": { "start": "babel-node index.js", "test": "mocha --compilers js:babel/register --require ./test/test_helper.js --recursive", "test:watch": "npm run test --watch" },
这样咱们就能够直接执行下面命令来开启应用:
npm run start
咱们如今拥有了一个Socket.io服务,也创建了Redux状态容器,但它们并无整合在一块儿,这就是咱们接下来要作的事儿。
咱们的服务端须要让客户端知道当前的应用状态(例如:“正在投票的项目是什么?”,“当前的票数是什么?”,
“已经出来结果了吗?”)。这些均可以经过每当变化发生时 触发Socket.io事件 来实现。
咱们如何得知何时发生变化?Redux对此提供了方案:你能够订阅Redux Store。这样每当store指派了action以后,在可能发生变化前会调用你提供的指定回调函数。
咱们要修改一下 startServer
实现,咱们先来调整一下index.js:
//index.js import makeStore from './src/store'; import {startServer} from './src/server'; export const store = makeStore(); startServer(store);
接下来咱们只需监听store的状态,并把它序列化后用socket.io事件传播给全部处于链接状态的客户端。
//src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); }
目前咱们的作法是一旦状态有改变,就发送整个state给全部客户端,很容易想到这很是不友好,产生大量流量损耗,更好的作法是只传递改变的state片断,但咱们为了简单,在这个例子中就先这么实现吧。
除了状态发生变化时发送状态数据外,每当新客户端链接服务器端时也应该直接发送当前的状态给该客户端。
咱们能够经过监听Socket.io的 connection
事件来实现上述需求:
//src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); }); }
除了将应用状态同步给客户端外,咱们还须要接受来自客户端的更新操做:投票者须要发起投票,投票组织者须要发起下一轮投票的请求。
咱们的解决方案很是简单。咱们只须要让客户端发布“action”事件便可,而后咱们直接将事件发送给Redux Store:
//src/server.js import Server from 'socket.io'; export function startServer(store) { const io = new Server().attach(8090); store.subscribe( () => io.emit('state', store.getState().toJS()) ); io.on('connection', (socket) => { socket.emit('state', store.getState().toJS()); socket.on('action', store.dispatch.bind(store)); }); }
这样咱们就完成了远程调用actions。Redux架构让咱们的项目更加简单:actions仅仅是js对象,能够很容易用于网络传输,咱们如今实现了一个支持多人投票的服务端系统,颇有成就感吧。
如今咱们的服务端操做流程以下:
在结束服务端开发以前,咱们载入一些测试数据来感觉一下。咱们能够添加 entries.json
文件:
//entries.json [ "Shallow Grave", "Trainspotting", "A Life Less Ordinary", "The Beach", "28 Days Later", "Millions", "Sunshine", "Slumdog Millionaire", "127 Hours", "Trance", "Steve Jobs" ]
咱们在 index.json
中加载它而后发起 next
action来开启投票:
//index.js import makeStore from './src/store'; import {startServer} from './src/server'; export const store = makeStore(); startServer(store); store.dispatch({ type: 'SET_ENTRIES', entries: require('./entries.json') }); store.dispatch({type: 'NEXT'});
那么接下来咱们就来看看如何实现客户端。
本教程剩余的部分就是写一个React应用,用来链接服务端,并提供投票给使用者。
在客户端咱们依然使用Redux。这是更常见的搭配:用于React应用的底层引擎。咱们已经了解到Redux如何使用。如今咱们将学习它是如何结合并影响React应用的。
我推荐你们跟随本教程的步骤完成应用,但你也能够从 github 上获取源码。
第一件事儿咱们固然是建立一个新的NPM项目,以下:
mkdir voting-client cd voting-client npm init # Just hit enter for each question
咱们的应用须要一个html主页,咱们放在 dist/index.html
:
//dist/index.html <!DOCTYPE html> <html> <body> <div id="app"></div> <script src="bundle.js"></script> </body> </html>
这个页面包含一个id为app的 <div>
,咱们将在其中插入咱们的应用。在同级目录下还须要一个 bundle.js
文件。
咱们为应用新建第一个js文件,它是系统的入口文件。目前咱们先简单的添加一行日志代码:
//src/index.js console.log('I am alive!');
为了给咱们客户端开发减负,咱们将使用 Webpack ,让咱们加入到项目中:
npm install --save-dev webpack webpack-dev-server
接下来,咱们在项目根目录新建一个Webpack配置文件:
//webpack.config.js module.exports = { entry: [ './src/index.js' ], output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist' } };
配置代表将找到咱们的 index.js
入口,并编译到 dist/bundle.js
中。同时把 dist
目录看成开发服务器根目录。
你如今能够执行Webpack来生成 bundle.js
:
webpack
你也能够开启一个开发服务器,访问localhost:8080来测试页面效果:
webpack-dev-server
因为咱们将使用ES6语法和React的 JSX语法 ,咱们须要一些工具。
Babel是一个很是合适的选择,咱们须要Babel库:
npm install --save-dev babel-core babel-loader
咱们能够在Webpack配置文件中添加一些配置,这样webpack将会对 .jsx
和 .js
文件使用Babel进行处理:
//webpack.config.js module.exports = { entry: [ './src/index.js' ], module: { loaders: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel' }] }, resolve: { extensions: ['', '.js', '.jsx'] }, output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist' } };
咱们也将会为客户端代码编写一些单元测试。咱们使用与服务端相同的测试套件:
npm install --save-dev mocha chai
咱们也将会测试咱们的React组件,这就要求须要一个DOM库。咱们可能须要像 Karma
库同样的功能来进行真实web浏览器测试。但咱们这里准备使用一个node端纯js的dom库:
npm install --save-dev jsdom@3
在用于react以前咱们须要一些jsdom的预备代码。咱们须要建立一般在浏览器端被提供的 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-core
代替 babel
--recursive
,但这么设置没法匹配 .jsx
文件,因此咱们须要使用 为了实现当代码发生修改后自动进行测试,咱们依然添加 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+Redux+Immutable来开发应用真正酷毙的地方在于:咱们能够用纯组件(有时候也称为蠢组件)思想实现任何东西。这个概念与纯函数很相似,有以下一些规则:
这就带来了 一个和使用纯函数同样的效果 :
咱们能够根据输入来预测一个组件的渲染,咱们不须要知道组件的其它信息。这也使得咱们的界面测试变得很简单,
与咱们测试纯应用逻辑同样简单。
若是组件不包含状态,那么状态放在哪?固然在不可变的Store中啊!咱们已经见识过它是怎么运做的了,其最大的特色就是从界面代码中分离出状态。
在此以前,咱们仍是先给项目添加React:
npm install --save react
咱们一样须要 react-hot-loader 。它让咱们的开发
变得很是快,由于它提供了咱们在不丢失当前状态的状况下重载代码的能力:
npm install --save-dev react-hot-loader
咱们须要更新一下 webpack.config.js
,使其能热加载:
//webpack.config.js var webpack = require('webpack'); module.exports = { entry: [ 'webpack-dev-server/client?http://localhost:8080', 'webpack/hot/only-dev-server', './src/index.js' ], module: { loaders: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'react-hot!babel' }], } resolve: { extensions: ['', '.js', '.jsx'] }, output: { path: __dirname + '/dist', publicPath: '/', filename: 'bundle.js' }, devServer: { contentBase: './dist', hot: true }, plugins: [ new webpack.HotModuleReplacementPlugin() ] };
在上述配置的 entry
里咱们包含了2个新的应用入口点:webpack dev server和webpack hot module loader。
它们提供了webpack模块热替换能力。该能力并非默认加载的,因此上面咱们才须要在 plugins
和 devServer
中手动加载。
配置的 loaders
部分咱们在原先的Babel前配置了 react-hot
用于 .js
和 .jsx
文件。
若是你如今重启开发服务器,你将看到一个在终端看到Hot Module Replacement已开启的消息提醒。咱们能够开始写咱们的第一个组件了。
应用的投票界面很是简单:一旦投票启动,它将现实2个按钮,分别用来表示2个可选项,当投票结束,它显示最终结果。
咱们以前都是以测试先行的开发方式,可是在react组件开发中咱们将先实现组件,再进行测试。这是由于
webpack和react-hot-loader提供了更加优良的 反馈机制 。
并且,也没有比直接看到界面更加好的测试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 :
//test/components/Voting_spec.jsx import React from 'react/addons'; import Voting from '../../src/components/Voting'; const {renderIntoDocument} = React.addons.TestUtils; describe('Voting', () => { it('renders a pair of buttons', () => { const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} /> ); }); });
一旦组件渲染完毕,我就能够经过react提供的另外一个辅助函数 scryRenderedDOMComponentsWithTag
来拿到 button
元素。咱们指望存在两个按钮,而且指望按钮的值是咱们设置的:
//test/components/Voting_spec.jsx import React from 'react/addons'; import Voting from '../../src/components/Voting'; import {expect} from 'chai'; const {renderIntoDocument, scryRenderedDOMComponentsWithTag} = React.addons.TestUtils; describe('Voting', () => { it('renders a pair of buttons', () => { const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons.length).to.equal(2); expect(buttons[0].getDOMNode().textContent).to.equal('Trainspotting'); expect(buttons[1].getDOMNode().textContent).to.equal('28 Days Later'); }); });
若是咱们跑一下测试,将会看到测试经过的提示:
npm run test
当用户点击某个按钮后,组件将会调用回调函数,该函数也由组件的prop传递给组件。
让咱们完成这一步,咱们能够经过使用React提供的测试工具 Simulate
来模拟点击操做:
//test/components/Voting_spec.jsx import React from 'react/addons'; import Voting from '../../src/components/Voting'; import {expect} from 'chai'; const {renderIntoDocument, scryRenderedDOMComponentsWithTag, Simulate} = React.addons.TestUtils; describe('Voting', () => { // ... it('invokes callback when a button is clicked', () => { let votedWith; const vote = (entry) => votedWith = entry; const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} vote={vote}/> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); Simulate.click(buttons[0].getDOMNode()); expect(votedWith).to.equal('Trainspotting'); }); });
要想使上面的测试经过很简单,咱们只须要让按钮的 onClick
事件调用 vote
并传递选中条目便可:
//src/components/Voting.jsx import React from 'react'; export default React.createClass({ getPair: function() { return this.props.pair || []; }, render: function() { return <div className="voting"> {this.getPair().map(entry => <button key={entry} onClick={() => this.props.vote(entry)}> <h1>{entry}</h1> </button> )} </div>; } });
这就是咱们在纯组件中经常使用的方式:组件不须要作太多,只是回调传入的参数便可。
注意,这里咱们又是先写的测试代码,我发现业务代码的测试要比测试UI更容易写,因此后面咱们会保持这种方式:UI测试后行,业务代码测试先行。
一旦用户已经针对某对选项投过票了,咱们就不该该容许他们再次投票,难道咱们应该在组件内部维护某种状态么?
不,咱们须要保证咱们的组件是纯粹的,因此咱们须要分离这个逻辑,组件须要一个 hasVoted
属性,咱们先硬编码
传递给它:
//src/index.jsx import React from 'react'; import Voting from './components/Voting'; const pair = ['Trainspotting', '28 Days Later']; React.render( <Voting pair={pair} hasVoted="Trainspotting" />, document.getElementById('app') );
咱们能够简单的修改一下组件便可:
//src/components/Voting.jsx import React from 'react'; export default React.createClass({ getPair: function() { return this.props.pair || []; }, isDisabled: function() { return !!this.props.hasVoted; }, render: function() { return <div className="voting"> {this.getPair().map(entry => <button key={entry} disabled={this.isDisabled()} onClick={() => this.props.vote(entry)}> <h1>{entry}</h1> </button> )} </div>; } });
让咱们再为按钮添加一个提示,当用户投票完毕后,在选中的项目上添加标识,这样用户就更容易理解:
//src/components/Voting.jsx import React from 'react'; export default React.createClass({ getPair: function() { return this.props.pair || []; }, isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) { return this.props.hasVoted === entry; }, render: function() { return <div className="voting"> {this.getPair().map(entry => <button key={entry} disabled={this.isDisabled()} onClick={() => this.props.vote(entry)}> <h1>{entry}</h1> {this.hasVotedFor(entry) ? <div className="label">Voted</div> : null} </button> )} </div>; } });
投票界面最后要添加的,就是获胜者样式。咱们可能须要添加新的props:
//src/index.jsx import React from 'react'; import Voting from './components/Voting'; const pair = ['Trainspotting', '28 Days Later']; React.render( <Voting pair={pair} winner="Trainspotting" />, document.getElementById('app') );
咱们再次修改一下组件:
//src/components/Voting.jsx import React from 'react'; export default React.createClass({ getPair: function() { return this.props.pair || []; }, isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) { return this.props.hasVoted === entry; }, render: function() { return <div className="voting"> {this.props.winner ? <div ref="winner">Winner is {this.props.winner}!</div> : this.getPair().map(entry => <button key={entry} disabled={this.isDisabled()} onClick={() => this.props.vote(entry)}> <h1>{entry}</h1> {this.hasVotedFor(entry) ? <div className="label">Voted</div> : null} </button> )} </div>; } });
目前咱们已经完成了全部要作的,可是 render
函数看着有点丑陋,若是咱们能够把胜利界面独立成新的组件
可能会好一些:
//src/components/Winner.jsx import React from 'react'; export default React.createClass({ render: function() { return <div className="winner"> Winner is {this.props.winner}! </div>; } });
这样投票组件就会变得很简单,它只需关注投票按钮逻辑便可:
//src/components/Vote.jsx import React from 'react'; export default React.createClass({ getPair: function() { return this.props.pair || []; }, isDisabled: function() { return !!this.props.hasVoted; }, hasVotedFor: function(entry) { return this.props.hasVoted === entry; }, render: function() { return <div className="voting"> {this.getPair().map(entry => <button key={entry} disabled={this.isDisabled()} onClick={() => this.props.vote(entry)}> <h1>{entry}</h1> {this.hasVotedFor(entry) ? <div className="label">Voted</div> : null} </button> )} </div>; } });
最后咱们只须要在 Voting
组件作一下判断便可:
//src/components/Voting.jsx import React from 'react'; import Winner from './Winner'; import Vote from './Vote'; export default React.createClass({ render: function() { return <div> {this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <Vote {...this.props} />} </div>; } });
注意这里咱们为胜利组件添加了 ref ,这是由于咱们将在单元测试中利用它获取DOM节点。
这就是咱们的纯组件!注意目前咱们尚未实现任何逻辑:咱们并无定义按钮的点击操做。组件只是用来渲染UI,其它什么都不须要作。后面当咱们将UI与Redux Store结合时才会涉及到应用逻辑。
继续下一步以前咱们要为刚才新增的特性写更多的单元测试代码。首先, hasVoted
属性将会使按钮改变状态:
//test/components/Voting_spec.jsx it('disables buttons when user has voted', () => { const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} hasVoted="Trainspotting" /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons.length).to.equal(2); expect(buttons[0].getDOMNode().hasAttribute('disabled')).to.equal(true); expect(buttons[1].getDOMNode().hasAttribute('disabled')).to.equal(true); });
被 hasVoted
匹配的按钮将显示 Voted
标签:
//test/components/Voting_spec.jsx it('adds label to the voted entry', () => { const component = renderIntoDocument( <Voting pair={["Trainspotting", "28 Days Later"]} hasVoted="Trainspotting" /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons[0].getDOMNode().textContent).to.contain('Voted'); });
当获胜者产生,界面将不存在按钮,取而代替的是胜利者元素:
//test/components/Voting_spec.jsx it('renders just the winner when there is one', () => { const component = renderIntoDocument( <Voting winner="Trainspotting" /> ); const buttons = scryRenderedDOMComponentsWithTag(component, 'button'); expect(buttons.length).to.equal(0); const winner = React.findDOMNode(component.refs.winner); expect(winner).to.be.ok; expect(winner.textContent).to.contain('Trainspotting'); });
咱们以前已经讨论了许多关于不可变数据的红利,可是,当它和react结合时还会有一个很是屌的好处:若是咱们建立纯react组件并传递给它不可变数据做为属性参数,咱们将会让react在组件渲染检测中获得最大性能。
这是靠react提供的 PureRenderMixin 实现的。
当该mixin添加到组件中后,组件的更新检查逻辑将会被改变,由深比对改成高性能的浅比对。
咱们之因此可使用浅比对,就是由于咱们使用的是不可变数据。若是一个组件的全部参数都是不可变数据,那么将大大提升应用性能。
咱们能够在单元测试里更清楚的看见差异,若是咱们向纯组件中传入可变数组,当数组内部元素产生改变后,组件并不会从新渲染:
//test/components/Voting_spec.jsx it('renders as a pure component', () => { const pair = ['Trainspotting', '28 Days Later']; const component = renderIntoDocument( <Voting pair={pair} /> ); let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting'); pair[0] = 'Sunshine'; component.setProps({pair: pair}); firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting'); });
若是咱们使用不可变数据,则彻底没有问题:
//test/components/Voting_spec.jsx import React from 'react/addons'; import {List} from 'immutable'; import Voting from '../../src/components/Voting'; import {expect} from 'chai'; const {renderIntoDocument, scryRenderedDOMComponentsWithTag, Simulate} = React.addons.TestUtils; describe('Voting', () => { // ... it('does update DOM when prop changes', () => { const pair = List.of('Trainspotting', '28 Days Later'); const component = renderIntoDocument( <Voting pair={pair} /> ); let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; expect(firstButton.getDOMNode().textContent).to.equal('Trainspotting'); const newPair = pair.set(0, 'Sunshine'); component.setProps({pair: newPair}); firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0]; expect(firstButton.getDOMNode().textContent).to.equal('Sunshine'); }); });
若是你跑上面的两个测试,你将会看到非预期的结果:由于实际上UI在两种场景下都更新了。那是由于如今组件依然使用的是深比对,这正是咱们使用不可变数据想极力避免的。
下面咱们在组件中引入mixin,你就会拿到指望的结果了:
//src/components/Voting.jsx import React from 'react/addons'; import Winner from './Winner'; import Vote from './Vote'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], // ... }); //src/components/Vote.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], // ... }); //src/components/Winner.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], // ... });
投票页面已经搞定了,让咱们开始实现投票结果页面吧。
投票结果页面依然会显示两个条目,而且显示它们各自的票数。此外屏幕下方还会有一个按钮,供用户切换到下一轮投票。
如今咱们根据什么来肯定显示哪一个界面呢?使用URL是个不错的主意:咱们能够设置根路径 #/
去显示投票页面,
使用 #/results
来显示投票结果页面。
咱们使用 react-router 能够很容易实现这个需求。让咱们加入项目:
npm install --save react-router
咱们这里使用的react-router的0.13版本,它的1.0版本官方尚未发布,若是你打算使用其1.0RC版,那么下面的代码
你可能须要作一些修改,能够看 router文档 。
咱们如今能够来配置一下路由路径,Router提供了一个 Route
组件用来让咱们定义路由信息,同时也提供了 DefaultRoute
组件来让咱们定义默认路由:
//src/index.jsx import React from 'react'; import {Route, DefaultRoute} from 'react-router'; import App from './components/App'; import Voting from './components/Voting'; const pair = ['Trainspotting', '28 Days Later']; const routes = <Route handler={App}> <DefaultRoute handler={Voting} /> </Route>; React.render( <Voting pair={pair} />, document.getElementById('app') );
咱们定义了一个默认的路由指向咱们的 Voting
组件。咱们须要定义个 App
组件来用于Route使用。
根路由的做用就是为应用指定一个根组件:一般该组件充当全部子页面的模板。让咱们来看看 App
的细节:
//src/components/App.jsx import React from 'react'; import {RouteHandler} from 'react-router'; import {List} from 'immutable'; const pair = List.of('Trainspotting', '28 Days Later'); export default React.createClass({ render: function() { return <RouteHandler pair={pair} /> } });
这个组件除了渲染了一个 RouteHandler
组件并无作别的,这个组件一样是react-router提供的,它的做用就是
每当路由匹配了某个定义的页面后将对应的页面组件插入到这个位置。目前咱们只定义了一个默认路由指向 Voting
,
因此目前咱们的组件老是会显示 Voting
界面。
注意,咱们将咱们硬编码的投票数据从 index.jsx
移到了 App.jsx
,当你给 RouteHandler
传递了属性值时,
这些参数将会传给当前路由对应的组件。
如今咱们能够更新 index.jsx
:
//src/index.jsx import React from 'react'; import Router, {Route, DefaultRoute} from 'react-router'; import App from './components/App'; import Voting from './components/Voting'; const routes = <Route handler={App}> <DefaultRoute handler={Voting} /> </Route>; Router.run(routes, (Root) => { React.render( <Root />, document.getElementById('app') ); });
run
方法会根据当前浏览器的路径去查找定义的router来决定渲染哪一个组件。一旦肯定了对应的组件,它将会被
看成指定的 Root
传给 run
的回调函数,在回调中咱们将使用 React.render
将其插入DOM中。
目前为止咱们已经基于React router实现了以前的内容,咱们如今能够很容易添加更多新的路由到应用。让咱们把投票结果页面添加进去吧:
//src/index.jsx import React from 'react'; import Router, {Route, DefaultRoute} from 'react-router'; import App from './components/App'; import Voting from './components/Voting'; import Results from './components/Results'; const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={Voting} /> </Route>; Router.run(routes, (Root) => { React.render( <Root />, document.getElementById('app') ); });
这里咱们用使用 <Route>
组件定义了一个名为 /results
的路径,并绑定 Results
组件。
让咱们简单的实现一下这个 Results
组件,这样咱们就能够看一下路由是如何工做的了:
//src/components/Results.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], render: function() { return <div>Hello from results!</div> } });
若是你在浏览器中输入 http://localhost:8080/#/results ,你将会看到该结果组件。
而其它路径都对应这投票页面,你也可使用浏览器的先后按钮来切换这两个界面。
接下来咱们来实际实现一下结果组件:
//src/components/Results.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], getPair: function() { return this.props.pair || []; }, render: function() { return <div className="results"> {this.getPair().map(entry => <div key={entry} className="entry"> <h1>{entry}</h1> </div> )} </div>; } });
结果界面除了显示投票项外,还应该显示它们对应的得票数,让咱们先硬编码一下:
//src/components/App.jsx import React from 'react/addons'; import {RouteHandler} from 'react-router'; import {List, Map} from 'immutable'; const pair = List.of('Trainspotting', '28 Days Later'); const tally = Map({'Trainspotting': 5, '28 Days Later': 4}); export default React.createClass({ render: function() { return <RouteHandler pair={pair} tally={tally} /> } });
如今,咱们再来修改一下结果组件:
//src/components/Results.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], getPair: function() { return this.props.pair || []; }, getVotes: function(entry) { if (this.props.tally && this.props.tally.has(entry)) { return this.props.tally.get(entry); } return 0; }, render: function() { return <div className="results"> {this.getPair().map(entry => <div key={entry} className="entry"> <h1>{entry}</h1> <div className="voteCount"> {this.getVotes(entry)} </div> </div> )} </div>; } });
如今咱们来针对目前的界面功能编写测试代码,以防止将来咱们破坏这些功能。
咱们指望组件为每一个选项都渲染一个div,并在其中显示选项的名称和票数。若是对应的选项没有票数,则默认显示0:
//test/components/Results_spec.jsx import React from 'react/addons'; import {List, Map} from 'immutable'; import Results from '../../src/components/Results'; import {expect} from 'chai'; const {renderIntoDocument, scryRenderedDOMComponentsWithClass} = React.addons.TestUtils; describe('Results', () => { it('renders entries with vote counts or zero', () => { const pair = List.of('Trainspotting', '28 Days Later'); const tally = Map({'Trainspotting': 5}); const component = renderIntoDocument( <Results pair={pair} tally={tally} /> ); const entries = scryRenderedDOMComponentsWithClass(component, 'entry'); const [train, days] = entries.map(e => e.getDOMNode().textContent); expect(entries.length).to.equal(2); expect(train).to.contain('Trainspotting'); expect(train).to.contain('5'); expect(days).to.contain('28 Days Later'); expect(days).to.contain('0'); }); });
接下来,咱们看一下”Next”按钮,它容许用户切换到下一轮投票。
咱们的组件应该包含一个回调函数属性参数,当组件中的”Next”按钮被点击后,该回调函数将会被调用。咱们来写一下这个操做的测试代码:
//test/components/Results_spec.jsx import React from 'react/addons'; import {List, Map} from 'immutable'; import Results from '../../src/components/Results'; import {expect} from 'chai'; const {renderIntoDocument, scryRenderedDOMComponentsWithClass, Simulate} = React.addons.TestUtils; describe('Results', () => { // ... it('invokes the next callback when next button is clicked', () => { let nextInvoked = false; const next = () => nextInvoked = true; const pair = List.of('Trainspotting', '28 Days Later'); const component = renderIntoDocument( <Results pair={pair} tally={Map()} next={next}/> ); Simulate.click(React.findDOMNode(component.refs.next)); expect(nextInvoked).to.equal(true); }); });
写法和以前的投票按钮很相似吧。接下来让咱们更新一下结果组件:
//src/components/Results.jsx import React from 'react/addons'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], getPair: function() { return this.props.pair || []; }, getVotes: function(entry) { if (this.props.tally && this.props.tally.has(entry)) { return this.props.tally.get(entry); } return 0; }, render: function() { return <div className="results"> <div className="tally"> {this.getPair().map(entry => <div key={entry} className="entry"> <h1>{entry}</h1> <div class="voteCount"> {this.getVotes(entry)} </div> </div> )} </div> <div className="management"> <button ref="next" className="next" onClick={this.props.next}> Next </button> </div> </div>; } });
最终投票结束,结果页面和投票页面同样,都要显示胜利者:
//test/components/Results_spec.jsx it('renders the winner when there is one', () => { const component = renderIntoDocument( <Results winner="Trainspotting" pair={["Trainspotting", "28 Days Later"]} tally={Map()} /> ); const winner = React.findDOMNode(component.refs.winner); expect(winner).to.be.ok; expect(winner.textContent).to.contain('Trainspotting'); });
咱们能够想在投票界面中那样简单的实现一下上面的逻辑:
//src/components/Results.jsx import React from 'react/addons'; import Winner from './Winner'; export default React.createClass({ mixins: [React.addons.PureRenderMixin], getPair: function() { return this.props.pair || []; }, getVotes: function(entry) { if (this.props.tally && this.props.tally.has(entry)) { return this.props.tally.get(entry); } return 0; }, render: function() { return this.props.winner ? <Winner ref="winner" winner={this.props.winner} /> : <div className="results"> <div className="tally"> {this.getPair().map(entry => <div key={entry} className="entry"> <h1>{entry}</h1> <div className="voteCount"> {this.getVotes(entry)} </div> </div> )} </div> <div className="management"> <button ref="next" className="next" onClick={this.props.next}> Next </button> </div> </div>; } });
到目前为止,咱们已经实现了应用的UI,虽然如今它们并无和真实数据和操做整合起来。这很不错不是么?咱们只须要一些占位符数据就能够完成界面的开发,这让咱们在这个阶段更专一于UI。
接下来咱们将会使用Redux Store来将真实数据整合到咱们的界面中。
Redux将会充当咱们UI界面的状态容器,咱们已经在服务端用过Redux,以前说的不少内容在这里也受用。如今咱们已经准备好要在React应用中使用Redux了,这也是Redux更常见的使用场景。
和在服务端同样,咱们先来思考一下应用的状态。客户端的状态和服务端会很是的相似。
咱们有两个界面,并在其中须要显示成对的用于投票的条目:
此外,结果页面须要显示票数:
投票组件还须要记录当前用户已经投票过的选项:
结果组件还须要记录胜利者:
注意这里除了 hasVoted
外,其它都映射着服务端状态的子集。
接下来咱们来思考一下应用的核心逻辑,actions和reducers应该是什么样的。
咱们先来想一想可以致使应用状态改变的操做都有那些?状态改变的来源之一是用户行为。咱们的UI中存在两种可能的用户操做行为:
另外,咱们知道咱们的服务端会将应用当前状态发送给客户端,咱们将编写代码来接受状态数据,这也是致使状态改变的来源之一。
咱们能够从服务端状态更新开始,以前咱们在服务端设置发送了一个 state
事件。该事件将携带咱们以前设计的客户端
状态树的状态数据。咱们的客户端reducer将经过一个action来将服务器端的状态数据合并到客户端状态树中,
这个action以下:
{ type: 'SET_STATE', state: { vote: {...} } }
让咱们先写一下reducer测试代码,它应该接受上面定义的那种action,并合并数据到客户端的当前状态中:
//test/reducer_spec.js import {List, Map, fromJS} from 'immutable'; import {expect} from 'chai'; import reducer from '../src/reducer'; describe('reducer', () => { it('handles SET_STATE', () => { const initialState = Map(); const action = { type: 'SET_STATE', state: Map({ vote: Map({ pair: List.of('Trainspotting', '28 Days Later'), tally: Map({Trainspotting: 1}) }) }) }; const nextState = reducer(initialState, action); expect(nextState).to.equal(fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} } })); }); });
这个renducers接受一个来自socket发送的原始的js数据结构,这里注意不是不可变数据类型哦。咱们须要在返回前将其转换成不可变数据类型:
//test/reducer_spec.js it('handles SET_STATE with plain JS payload', () => { const initialState = Map(); const action = { type: 'SET_STATE', state: { vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} } } }; const nextState = reducer(initialState, action); expect(nextState).to.equal(fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} } })); });
reducer一样应该能够正确的处理 undefined
初始化状态:
//test/reducer_spec.js it('handles SET_STATE without initial state', () => { const action = { type: 'SET_STATE', state: { vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} } } }; const nextState = reducer(undefined, action); expect(nextState).to.equal(fromJS({ vote: { pair: ['Trainspotting', '28 Days Later'], tally: {Trainspotting: 1} } })); });
如今咱们来看一下如何实现知足上面测试条件的reducer:
//src/reducer.js import {Map} from 'immutable'; export default function(state = Map(), action) { return state; }
reducer须要处理 SET_STATE
动做。在这个动做的处理中,咱们应该将传入的状态数据和现有的进行合并,
使用Map提供的 merge 将很容易来实现这个操做:
//src/reducer.js import {Map} from 'immutable'; function setState(state, newState) { return state.merge(newState); } export default function(state = Map(), action) { switch (action.type) { case 'SET_STATE': return setState(state, action.state); } return state; }
注意这里咱们并无单独写一个核心模块,而是直接在reducer中添加了个简单的 setState
函数来作业务逻辑。
这是由于如今这个逻辑还很简单~
关于改变用户状态的那两个用户交互:投票和下一步,它们都须要和服务端进行通讯,咱们一会再说。咱们如今先把redux添加到项目中:
npm install --save redux
index.jsx
入口文件是一个初始化Store的好地方,让咱们暂时先使用硬编码的数据来作:
//src/index.jsx import React from 'react'; import Router, {Route, DefaultRoute} from 'react-router'; import {createStore} from 'redux'; import reducer from './reducer'; import App from './components/App'; import Voting from './components/Voting'; import Results from './components/Results'; const store = createStore(reducer); store.dispatch({ type: 'SET_STATE', state: { vote: { pair: ['Sunshine', '28 Days Later'], tally: {Sunshine: 2} } } }); const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={Voting} /> </Route>; Router.run(routes, (Root) => { React.render( <Root />, document.getElementById('app') ); });
那么,咱们如何在react组件中从Store中获取数据呢?
咱们已经建立了一个使用不可变数据类型保存应用状态的Redux Store。咱们还拥有接受不可变数据为参数的
无状态的纯React组件。若是咱们能使这些组件从Store中获取最新的状态数据,那真是极好的。当状态变化时,
React会从新渲染组件,pure render mixin可使得咱们的UI避免没必要要的重复渲染。
相比咱们本身手动实现同步代码,咱们更推荐使用[react-redux][ https://github.com/rackt/react-redux]包来作:
npm install --save react-redux
这个库主要作的是:
为了让它能够正常工做,咱们须要将顶层的应用组件嵌套在react-redux的 Provider 组件中。
这将把Redux Store和咱们的状态树链接起来。
咱们将让Provider包含路由的根组件,这样会使得Provider成为整个应用组件的根节点:
//src/index.jsx import React from 'react'; import Router, {Route, DefaultRoute} from 'react-router'; import {createStore} from 'redux'; import {Provider} from 'react-redux'; import reducer from './reducer'; import App from './components/App'; import {VotingContainer} from './components/Voting'; import Results from './components/Results'; const store = createStore(reducer); store.dispatch({ type: 'SET_STATE', state: { vote: { pair: ['Sunshine', '28 Days Later'], tally: {Sunshine: 2} } } }); const routes = <Route handler={App}> <Route path="/results" handler={Results} /> <DefaultRoute handler={VotingContainer} /> </Route>; Router.run(routes, (Root) => { React.render( <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });
接下来咱们要考虑一下,咱们的那些组件须要绑定到Store上。咱们一共有5个组件,能够分红三类:
App
不须要绑定任何数据; Vote
和 Winner
组件只使用父组件传递来的数据,因此它们也不须要绑定; Voting
和 Results
)目前都是使用的硬编码数据,咱们如今须要将其绑定到Store上。 让咱们从 Voting
组件开始。使用react-redux咱们获得一个叫 connect 的函数:
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”来描述它们。
怎么称呼随你便,主要是明白它们之间的差异:
咱们得更新一下路由表,改用 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 /> } });
如今咱们已经建立好了客户端的Redux应用,咱们接下来将讨论如何让其与咱们以前开发的服务端应用进行对接。
服务端已经准备好接受socket链接,并为其进行投票数据的发送。而咱们的客户端也已经可使用Redux Store很方便的接受数据了。咱们剩下的工做就是把它们链接起来。
咱们须要使用socket.io从浏览器向服务端建立一个链接,咱们可使用 socket.io-client库 来完成
这个目的:
npm install --save socket.io-client
这个库赋予了咱们链接Socket.io服务端的能力,让咱们链接以前写好的服务端,端口号8090(注意使用和后端匹配的端口):
//src/index.jsx import React from 'react'; import Router, {Route, DefaultRoute} from 'react-router'; import {createStore} from 'redux'; import {Provider} from 'react-redux'; import io from 'socket.io-client'; import reducer from './reducer'; import App from './components/App'; import {VotingContainer} from './components/Voting'; import {ResultsContainer} from './components/Results'; const store = createStore(reducer); store.dispatch({ type: 'SET_STATE', state: { vote: { pair: ['Sunshine', '28 Days Later'], tally: {Sunshine: 2} } } }); const socket = io(`${location.protocol}//${location.hostname}:8090`); const routes = <Route handler={App}> <Route path="/results" handler={ResultsContainer} /> <DefaultRoute handler={VotingContainer} /> </Route>; Router.run(routes, (Root) => { React.render( <Provider store={store}> {() => <Root />} </Provider>, document.getElementById('app') ); });
你必须先确保你的服务端已经开启了,而后在浏览器端访问客户端应用,并检查网络监控,你会发现建立了一个WebSockets链接,而且开始传输Socket.io的心跳包了。
咱们虽然已经建立了个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
的硬编码,咱们如今已经不须要伪造数据了。
审视咱们的界面,无论是投票仍是结果页面,它们都会显示服务端提供的第一对选项。服务端和客户端已经链接上了!
咱们已经知道如何从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
动做。
最后咱们要作的是把用户数据提交到服务端,这种操做通常发生在用户投票,或选择跳转下一轮投票时发生。
让咱们讨论一下投票操做,下面列出了投票的逻辑:
VOTE
action将产生并分派到客户端的Redux Store中; VOTE
actions将触发客户端reducer进行 hasVoted
状态设置; action
,它将接收到的actions分派到服务端的Redux Store; VOTE
action将触发服务端的reducer,其会建立vote数据并更新对应的票数。 这样来讲,咱们彷佛已经都搞定了。惟一缺乏的就是让客户端发送 VOTE
action给服务端。这至关于两端的
Redux Store相互分派action,这就是咱们接下来要作的。
那么该怎么作呢?Redux并无内建这种功能。因此咱们须要设计一下什么时候何地来作这个工做:从客户端发送action到服务端。
Redux提供了一个通用的方法来封装action: Middleware 。
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 。
这种写法主要用来简化中间件的实现:若是咱们使用一个一次性接受全部参数的函数( 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 }; }
让咱们从新审视一下咱们都干了什么:
VOTE
action被分派; hasVoted
属性; SET_STATE
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);
完全完工了!咱们实现了一个功能完备的应用。