現代web頁面裏處處都是ajax,因此處理好異步的代碼很是重要。javascript
此次我從新選了個最適合展現異步處理的應用場景——搜索新聞列表。因爲有現成的接口,咱們就不用本身搭服務了。 我在網上隨便搜到了一個新聞服務接口,支持jsonp,就用它吧。html
一開始,我們仍然按照action->reducer->components的順序把基本的代碼寫出來。先想好要什麼功能, 我設想的就是有一個輸入框,旁邊一個搜索按鈕,輸入關鍵字後一點按鈕相關的新聞列表就展現出來了。java
首先是action,如今能想到的動做就是把新聞列表放到倉庫裏,至於列表數據是哪兒來的一下子再說。 來看src/actions/news.js:node
import {cac} from 'utils' export const PUSH_NEWS_LIST = 'PUSH_NEWS_LIST' export const pushList = cac(PUSH_NEWS_LIST, 'list')
而後是reducer,沒什麼特別的,只要遇到上面定義的那個action,就把數據放到相應的狀態裏就好了。 咱們先定一個叫作news的狀態,裏面再包含一個子狀態list。後面還要擴充功能,還會給news狀態添加更多的子狀態。 如下是src/reducers/news.js的代碼:react
import {combineReducers} from 'redux'; import {cr} from '../utils' import {PUSH_NEWS_LIST} from 'actions/news' export default combineReducers({ list: cr([], { [PUSH_NEWS_LIST](state, {list}){return list} }) })
如今就能夠開始寫組件了。這回咱們要作的是個列表,也就是要有重複的東西,我想最好把重複的東西單抽取成一個組件以便維護和複用。 那就把一條新聞抽取成一個組件吧,它應該具備標題、發佈時間、圖片以及概述這些內容。 這個組件絕對是純純的,不用跟外界打交道,因此把它放到components目錄裏。src/components/NewsOverview.js:webpack
import React from 'react'; class NewsOverview extends React.Component { render(){ let date = new Date(this.props.time) return ( <div> <h2>{this.props.title}</h2> <div style={{padding:'16px 0',color: '#888'}}> {date.toLocaleDateString()} {date.toLocaleTimeString()} </div> <div style={{textAlign:'center'}}> <img src={this.props.img} style={{maxWidth:'100%'}}/> </div> <p>{this.props.description}</p> </div> ) } } export default NewsOverview
而後寫要跟外界打交道的組件,這個組件須要響應用戶的點擊按鈕的事件,發起獲取新聞列表的請求,而後把數據放到頁面裏。 src/containers/newsList.js:web
import React from 'react'; import { connect } from 'react-redux' import NewsOverview from 'components/NewsOverview' import {pushList} from 'actions/news' class NewsList extends React.Component { search(){ let keyword = this.refs.keyInput.value // TODO: 獲取新聞列表 } renderList(){ return this.props.list.map(item =>{ item.key = item.title return React.createElement(NewsOverview, item) }) } render(){ return ( <div> <div> <input ref="keyInput"/> <button onClick={this.search.bind(this)}>搜索</button> </div> <div> {this.renderList()} </div> </div> ) } } function mapStateToProps(state) { // 通常一組狀態都是爲一個頁面服務的,因此把它們一股腦的映射過來比較方便 // 可是把映射一一寫出來也有好處,就是很容易看到組件裏有什麼屬性 return Object.assign({}, state.news) } export default connect(mapStateToProps)(NewsList);
代碼差很少了,可是它如今無法工做,由於咱們還沒給添加ajax請求的代碼。最簡單粗暴的方法就是在上面的search方法中直接來個ajax請求, 而後在回調中派發「PUSH_NEWS_LIST」的action。也行。先寫出來吧。爲了簡化ajax代碼,我在src/index.html裏面引入了jQuery。 固然,用了react,咱們也許用不上jQuery的其餘功能,因此用fetch或者其它ajax庫都行。ajax
search(){ let keyword = this.refs.keyInput.value window.$.ajax({ url: 'http://www.tngou.net/api/search', data: { keyword, name: 'topword' }, dataType: 'jsonp', success: (data)=>{ if(data.status) this.props.dispatch(pushList(data.tngou)) } }) }
最後別忘了修改入口、添加reducer:把src/index.js裏面Provider下面的組件換成NewsList; 在src/reducers/index.js裏面引入新增的reducer,並加到reducers對象裏。express
好了,試一下,輸入個關鍵字點擊搜索,新聞列表如約而至。可是不能到這就知足啊。npm
咱們但願組件儘量接近純函數,組件要跟外界打交道要經過connent函數鏈接到倉庫,倉庫所存的狀態纔是能夠被外界改變的。 組件裏的表單帶來的外界影響實在是沒辦法,可是連網絡請求都塞到組件裏實在是不雅觀。從維護上講,咱們的組件只是要展現出新聞列表, 它不想管是哪裏來的新聞列表,更不肯意管你新聞列表是異步請求來的或是同步從本地文件讀取來的, 它只是想:我發起一個action,你根據這個action給我我們約定好格式的數據就好了。
OK,action,咱們應該變換動做來伺候好組件。那麼改action吧。目前來看咱們的action是同步的,怎麼能讓它異步呢? 也就是我發起一個action,給個回調的機會,讓它過一下子能發起另外一個action。
樸素的action是沒有這個能力的。這時候中間件該上場了。
中間件是一個軟件行業裏比較混亂的詞彙。運維人員管weblogic甚至tomcat叫中間件;SOA裏面管流程中間的服務叫中間件。 再加上如今不少軟件大廠都聲稱本身是中間件的供應商,讓中間件這個詞聽起來都十分高大上。高大上的東西太恐怖, 我只理解node的web框架express裏的中間件,就是在處理請求時插入到流程中間能夠加工請求數據或者根據請求數據作點別的事情的函數。 這個概念應該跟SOA的中間件差很少,但十分簡單明瞭。redux的中間件也是如此。既然它要「作點別的事情」, 說明它每每不會是個純函數,總要搞點反作用出來,ajax請求就是要搞反作用。
咱們派發一個action(實際是store派發的),這個action最終會被reducer處理,在這以前redux容許咱們插入中間件搞點別的事情。 舉個簡單的例子,咱們在中間件裏能夠打印日誌。下面,先彆着急修改咱們的ajax請求,先經過打印一些日誌來熟悉一下中間件。
action的派發和被reducer處理都是由store控制的,因此中間件的註冊應該在store的代碼裏。 咱們來修改src/stores/index.js:
const { createStore, applyMiddleware } = require('redux'); const reducers = require('../reducers'); const logger = store => next => action => { window.console.log('dispatching', action) next(action) window.console.log('next state', store.getState()) } module.exports = function(initialState) { let createStoreWithMiddleware = applyMiddleware(logger)(createStore) let store = createStoreWithMiddleware(reducers, initialState) // 原來生成的文件裏這裏有一段熱加載的代碼,若要保留熱加載功能請自行留下這段代碼 return store }
來看下中間件logger函數,它先打印出了正在派發的action,而後經過調用next讓action執行, 最後在action執行結束後打印出了最終的倉庫狀態。很簡單吧,就是在派發action的過程當中搞點打印日誌的事情。
回到咱們的目標上來,咱們但願的是一個action派發後作一些異步的事情,而後給個機會執行回調。 若是是異步的,action就不會馬上送到reducer那裏,那就須要兩個action,一個action是通知異步開始執行, 另外一個action是咱們熟悉的reducer所須要的action。既然第一個action不須要給reducer傳達指令而要作些別的事情, 那他是個函數就好了。中間件須要作的事情就是遇到類型爲函數的action就直接執行,遇到普通的action就正常發送給reducer。 因而這個中間件就是這個樣子:
const thunk = store => next => action => typeof action === 'function' ? action(store.dispatch, store.getState) : next(action)
其實這個名爲thunk的中間件在npm上有現成的,安裝一下就好了:
npm install redux-thunk --save
而後在src/store/index.js裏面註冊它:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' import reducers from '../reducers' module.exports = function(initialState) { // 原來的日誌中間件先給去掉了,其實applyMiddleware的參數列表裏面是能夠聽任意多箇中間件的 let createStoreWithMiddleware = applyMiddleware(thunk)(createStore) let store = createStoreWithMiddleware(reducers, initialState) return store }
如今就能夠把ajax的代碼移到src/actions/news.js裏面了:
import {cac} from 'utils' export const PUSH_NEWS_LIST = 'PUSH_NEWS_LIST' const pushList = cac(PUSH_NEWS_LIST, 'list') export function fetchList (keyword){ return dispatch => { window.$.ajax({ url: 'http://www.tngou.net/api/search', data: { keyword, name: 'topword' }, dataType: 'jsonp', success: (data)=>{ if(data.status) dispatch(pushList(data.tngou)) } }) } }
在組件src/containers/NewsList.js裏面,再也不須要pushList,而須要fetchList這個可用於中間件trunk的action:
import React from 'react'; import {connect} from 'react-redux' import NewsOverview from 'components/NewsOverview' import {fetchList} from 'actions/news' class NewsList extends React.Component { search(){ let keyword = this.refs.keyInput.value this.props.dispatch(fetchList(keyword)) } // ...
好了,組件回到了純潔的樣子,ajax獲取數據依然沒有問題。
thunk中間件雖然很是簡單,但它讓redux具備了在action裏面派發action的能力,這樣咱們的action就不只僅是指導reducer如何處理狀態, 而能夠作一切不純粹處理數據的事情。可是咱們應該儘可能避免action的膨脹,是處理數據的事兒就讓reducer去作, 是界面的事兒就交給組件,這樣才能讓邏輯儘量的清晰。
咱們來把這個應用作得更完善一些吧。做爲一個新聞列表,不能分頁不太像話。來改造一下。
仍是從action開始。須要什麼新的動做嗎?設置總數、頁碼?其實咱們在一個ajax請求中已經把這些數據都獲取到了, 設置這些都是處理數據的事兒,把它們放到action裏有些不合適,仍是讓reducer去處理比較好。 在action裏,咱們只須要把全部有用的數據都傳給reducer,嗯,名字也最好改個合適的。 除此以外,關鍵字也要保存到狀態裏,以供翻頁時使用。這裏把fetchList函數設計得多功能一些: 翻頁時不傳keyword,新查詢時不傳頁碼
src/actions/news.js:
import {cac} from 'utils' export const RECEIVE_NEWS_LIST = 'RECEIVE_NEWS_LIST' export const SET_KEYWORD = 'SET_KEYWORD' export const PAGE_SIZE = 10 const receiveList = cac(RECEIVE_NEWS_LIST, 'data', 'page') const setKeyword = cac(SET_KEYWORD, 'value') export function fetchList (keyword, page=1){ return (dispatch, getState) => { if(!keyword) keyword = getState().news.keyword else dispatch(setKeyword(keyword)) window.$.ajax({ url: 'http://www.tngou.net/api/search', data: { keyword, name: 'topword', page, rows:PAGE_SIZE }, dataType: 'jsonp', success: (data)=>{ if(data.status) dispatch(receiveList(data, page)) } }) } }
reducer改動就比較大了,對於同一個「RECEIVE_NEWS_LIST」的動做,好幾個狀態都要進行修改。
src/reducers/news.js:
import {combineReducers} from 'redux'; import {cr} from '../utils' import {RECEIVE_NEWS_LIST, SET_KEYWORD, PAGE_SIZE} from 'actions/news' export default combineReducers({ list: cr([], { [RECEIVE_NEWS_LIST](state, {data}){return data.tngou} }), totalPage: cr(0, { [RECEIVE_NEWS_LIST](state, {data}){return Math.ceil(data.total/PAGE_SIZE)} }), page: cr(1, { [RECEIVE_NEWS_LIST](state, {page}){return page} }), keyword: cr('', { [SET_KEYWORD](state, {value}){return value} }) })
頁碼的展現必定要單獨寫一個組件,由於它被複用的概率太大了。我這裏就簡單寫一個,省略號、上下頁之類的先不搞了。
src/components/pager.js
import React from 'react'; class Pager extends React.Component{ renderNumbers(){ let {page, totalPage, onChangePage} = this.props return Array.from({length:totalPage}, (x,i)=>{ ++i; let style = { display: 'inline-block', border: 'solid 1px #ddd', padding: '5px', margin: '2px', color: page==i ? 'red' : '#999' } return <b style={style} onClick={()=>{onChangePage(i)}}>{i}</b> }) } render(){ return <div> {this.renderNumbers()} </div> } } Pager.propTypes = { page: React.PropTypes.number.isRequired, totalPage: React.PropTypes.number.isRequired, onChangePage: React.PropTypes.func.isRequired } export default Pager
做爲一個被複用可能性很大的公共組件,強烈建議定義組件的屬性類型。另外這個組件要求的屬性與接口所返回的數據並不徹底一致, 服務返回的是條目總數,而Pager組件要的是總頁數,這個轉換放到reducer裏比較合適。
最後把Pager放到srsc/containers/NewsList.js裏面去
import React from 'react'; import { connect } from 'react-redux' import NewsOverview from 'components/NewsOverview' import Pager from 'components/Pager' import {fetchList} from 'actions/news' class NewsList extends React.Component { search(){ let keyword = this.refs.keyInput.value this.props.dispatch(fetchList(keyword)) } renderList(){ return this.props.list.map(item =>{ item.key = item.title return React.createElement(NewsOverview, item) }) } render(){ let {page, totalPage, dispatch} = this.props return ( <div> <div> <input ref="keyInput"/> <button onClick={this.search.bind(this)}>搜索</button>`` </div> <div> {this.renderList()} </div> <Pager page={page} totalPage={totalPage} onChangePage={i=>dispatch(fetchList(null,i))} /> </div> ) } } function mapStateToProps(state) { return Object.assign({}, state.news) } export default connect(mapStateToProps)(NewsList);
大功告成!
不過還沒完。如今咱們只有一個新聞列表,若是想看新聞的具體內容呢?🙄點進去看啊。。。
好吧,這就須要一個新的頁面了。難道咱們再寫一個新頁面另建一套這堆東西嗎?no, no, no。 都什麼時代了,咱們要作單頁應用(spa),給用戶最佳的操做體驗。要在單頁中模擬出來多個頁面, 就要用到路由了。下一節,咱們就玩一玩react本身的路由系統:react-router。