现代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。