現代web頁面里到處都是ajax,所以處理好非同步的代碼非常重要。 這次我重新選了個適合展示非同步處理的應用場景——搜索新聞列表。由於有現成的介面,我們就不用自己搭服務了。 我在網上隨便搜到了一個新聞服務介面,支持jsonp,就用它吧。 一開始,咱們仍然按照action->reducer->compon
現代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/stres/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);
大功告成!
不過還沒完。現在我們只有一個新聞列表,如果想看新聞的具體內容呢?