參考英文網站: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);
完全完工了!咱們實現了一個功能完備的應用。