現代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
作為一個被復用可能性很大的公共組件,強烈建議定義組件的屬性類型。另外這個組件要求的屬性與接口所返回的數據並不完全一致, 服務返回的是條目總數,而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。