react、redux什么的都用起来 【2】异步action和redux中间件

现代web页面里到处都是ajax,所以处理好异步的代码非常重要。

这次我重新选了个最适合展示异步处理的应用场景——搜索新闻列表。由于有现成的接口,我们就不用自己搭服务了。 我在网上随便搜到了一个新闻服务接口,支持jsonp,就用它吧。

一开始,咱们仍然按照action->reducer->components的顺序把基本的代码写出来。先想好要什么功能, 我设想的就是有一个输入框,旁边一个搜索按钮,输入关键字后一点按钮相关的新闻列表就展示出来了。

首先是action,现在能想到的动作就是把新闻列表放到仓库里,至于列表数据是哪儿来的一会儿再说。 来看src/actions/news.js:

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的代码:

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:

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:

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库都行。

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对象里。

好了,试一下,输入个关键字点击搜索,新闻列表如约而至。但是不能到这就满足啊。

我们希望组件尽可能接近纯函数,组件要跟外界打交道要通过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
在这里为了展示方便,所有组件的样式都使用内联样式。不过实际开发中还是推荐使用单独的样式表文件。 另外,在webpack的帮助下,每个组件最好对应一个样式文件,在组件文件中require进来,这样组件就能保持完整的模块化。

作为一个被复用可能性很大的公共组件,强烈建议定义组件的属性类型。另外这个组件要求的属性与接口所返回的数据并不完全一致, 服务返回的是条目总数,而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。

posted @ 2016-03-17 22:10  tolg  阅读(7119)  评论(9编辑  收藏  举报