在基礎(chǔ)教程中,我們創(chuàng)建了一個(gè)簡(jiǎn)單的 todo 應(yīng)用。它只有同步操作。每當(dāng) dispatch action 時(shí),state 會(huì)被立即更新。
在本教程中,我們將開發(fā)一個(gè)不同的,異步的應(yīng)用。它將使用 Reddit API 來獲取并顯示指定 reddit 下的帖子列表。那么 Redux 究竟是如何處理異步數(shù)據(jù)流的呢?
當(dāng)調(diào)用異步 API 時(shí),有兩個(gè)非常關(guān)鍵的時(shí)刻:發(fā)起請(qǐng)求的時(shí)刻,和接收到響應(yīng)的時(shí)刻 (也可能是超時(shí))。
這兩個(gè)時(shí)刻都可能會(huì)更改應(yīng)用的 state;為此,你需要 dispatch 普通的同步 action。一般情況下,每個(gè) API 請(qǐng)求都至少需要 dispatch 三個(gè)不同的 action:
一個(gè)通知 reducer 請(qǐng)求開始的 action。
對(duì)于這種 action,reducer 可能會(huì)切換一下 state 中的 isFetching
標(biāo)記。以此來告訴 UI 來顯示進(jìn)度條。
一個(gè)通知 reducer 請(qǐng)求成功結(jié)束的 action。
對(duì)于這種 action,reducer 可能會(huì)把接收到的新數(shù)據(jù)合并到 state 中,并重置 isFetching
。UI 則會(huì)隱藏進(jìn)度條,并顯示接收到的數(shù)據(jù)。
一個(gè)通知 reducer 請(qǐng)求失敗的 action。
對(duì)于這種 action,reducer 可能會(huì)重置 isFetching
?;蛘撸行?reducer 會(huì)保存這些失敗信息,并在 UI 里顯示出來。
為了區(qū)分這三種 action,可能在 action 里添加一個(gè)專門的 status
字段作為標(biāo)記位:
{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }
又或者為它們定義不同的 type:
{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }
究竟使用帶有標(biāo)記位的同一個(gè) action,還是多個(gè) action type 呢,完全取決于你。這應(yīng)該是你的團(tuán)隊(duì)共同達(dá)成的約定。使用多個(gè) type 會(huì)降低犯錯(cuò)誤的機(jī)率,但是如果你使用像 redux-actions 這類的輔助庫來生成 action creator 和 reducer 的話,這完成就不是問題了。
無論使用哪種約定,一定要在整個(gè)應(yīng)用中保持統(tǒng)一。在本教程中,我們將使用不同的 type 來做。
下面先定義幾個(gè)同步的 action type 和 action creator。比如,用戶可以選擇要顯示的 reddit:
export const SELECT_REDDIT = 'SELECT_REDDIT';
export function selectReddit(reddit) {
return {
type: SELECT_REDDIT,
reddit
};
}
也可以按 "刷新" 按鈕來更新它:
export const INVALIDATE_REDDIT = 'INVALIDATE_REDDIT';
export function invalidateReddit(reddit) {
return {
type: INVALIDATE_REDDIT,
reddit
};
}
這些是用戶操作來控制的 action。也有另外一類 action,由網(wǎng)絡(luò)請(qǐng)求來控制。后面會(huì)介紹如何使用它們,現(xiàn)在,我們只是來定義它們。
當(dāng)需要請(qǐng)求指定 reddit 的帖子的時(shí)候,需要 dispatch REQUEST_POSTS
action:
export const REQUEST_POSTS = 'REQUEST_POSTS';
export function requestPosts(reddit) {
return {
type: REQUEST_POSTS,
reddit
};
}
把 SELECT_REDDIT
和 INVALIDATE_REDDIT
分開很重要。雖然它們的發(fā)生有先后順序,隨著應(yīng)用變得復(fù)雜,有些用戶操作(比如,預(yù)加載最流行的 reddit,或者一段時(shí)間后自動(dòng)刷新過期數(shù)據(jù))后需要馬上請(qǐng)求數(shù)據(jù)。路由變化時(shí)也可能需要請(qǐng)求數(shù)據(jù),所以一開始如果把請(qǐng)求數(shù)據(jù)和特定的 UI 事件耦合到一起是不明智的。
最后,當(dāng)收到請(qǐng)求響應(yīng)時(shí),我們會(huì) dispatch RECEIVE_POSTS
:
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export function receivePosts(reddit, json) {
return {
type: RECEIVE_POSTS,
reddit,
posts: json.data.children.map(child => child.data),
receivedAt: Date.now()
};
}
以上就是現(xiàn)在需要知道的所有內(nèi)容。稍后會(huì)介紹如何把 dispatch action 與網(wǎng)絡(luò)請(qǐng)求結(jié)合起來。
錯(cuò)誤處理須知
在實(shí)際應(yīng)用中,網(wǎng)絡(luò)請(qǐng)求失敗時(shí)也需要 dispatch action。雖然在本教程中我們并不做錯(cuò)誤處理,但是這個(gè) 真實(shí)場(chǎng)景的案例 會(huì)演示一種實(shí)現(xiàn)方案。
就像在基礎(chǔ)教程中,在功能開發(fā)前你需要 設(shè)計(jì)應(yīng)用的 state 結(jié)構(gòu)。在寫同步代碼的時(shí)候,需要考慮更多的 state,所以我們要仔細(xì)考慮一下。
這部分內(nèi)容通常讓初學(xué)者感到迷惑,因?yàn)檫x擇哪些信息才能清晰地描述異步應(yīng)用的 state 并不直觀,還有怎么用一個(gè)樹來把這些信息組織起來。
我們以最通用的案例來打頭:列表。Web 應(yīng)用經(jīng)常需要展示一些內(nèi)容的列表。比如,貼子的列表,朋友的列表。首先要明確應(yīng)用要顯示哪些列表。然后把它們分開儲(chǔ)存在 state 中,這樣你才能對(duì)它們分別做緩存并且在需要的時(shí)候再次請(qǐng)求更新數(shù)據(jù)。
"Reddit 頭條" 應(yīng)用會(huì)長(zhǎng)這個(gè)樣子:
{
selectedReddit: 'frontend',
postsByReddit: {
frontend: {
isFetching: true,
didInvalidate: false,
items: []
},
reactjs: {
isFetching: false,
didInvalidate: false,
lastUpdated: 1439478405547,
items: [{
id: 42,
title: 'Confusion about Flux and Relay'
}, {
id: 500,
title: 'Creating a Simple Application Using React JS and Flux Architecture'
}]
}
}
}
下面列出幾個(gè)要點(diǎn):
分開存儲(chǔ) reddit 信息,是為了緩存所有 reddit。當(dāng)用戶來回切換 reddit 時(shí),可以立即更新,同時(shí)在不需要的時(shí)候可以不請(qǐng)求數(shù)據(jù)。不要擔(dān)心把所有帖子放到內(nèi)存中(會(huì)浪費(fèi)內(nèi)存):除非你需要處理成千上萬條帖子,而且用戶通常不會(huì)關(guān)閉標(biāo)簽,你不需要做任何清理。
每個(gè)帖子的列表都需要使用 isFetching
來顯示進(jìn)度條,didInvalidate
來標(biāo)記數(shù)據(jù)是否過期,lastUpdated
來存放數(shù)據(jù)最后更新時(shí)間,還有 items
存放列表信息本身。在實(shí)際應(yīng)用中,你還需要存放 fetchedPageCount
和 nextPageUrl
這樣分頁相關(guān)的 state。
嵌套內(nèi)容須知
在這個(gè)示例中,接收到的列表和分頁信息是存在一起的。但是,這種做法并不適用于有互相引用的嵌套內(nèi)容的場(chǎng)景,或者用戶可以編輯列表的場(chǎng)景。想像一下用戶需要編輯一個(gè)接收到的帖子,但這個(gè)帖子在 state tree 的多個(gè)位置重復(fù)出現(xiàn)。這會(huì)讓開發(fā)變得非常困難。
如果你有嵌套內(nèi)容,或者用戶可以編輯接收到的內(nèi)容,你需要把它們分開存放在 state 中,就像數(shù)據(jù)庫中一樣。在分頁信息中,只使用它們的 ID 來引用。這可以讓你始終保持?jǐn)?shù)據(jù)更新。真實(shí)場(chǎng)景的案例 中演示了這種做法,結(jié)合 normalizr 來把嵌套的 API 響應(yīng)數(shù)據(jù)范式化,最終的 state 看起來是這樣:
{
selectedReddit: 'frontend',
entities: {
users: {
2: {
id: 2,
name: 'Andrew'
}
},
posts: {
42: {
id: 42,
title: 'Confusion about Flux and Relay',
author: 2
},
100: {
id: 100,
title: 'Creating a Simple Application Using React JS and Flux Architecture',
author: 2
}
}
},
postsByReddit: {
frontend: {
isFetching: true,
didInvalidate: false,
items: []
},
reactjs: {
isFetching: false,
didInvalidate: false,
lastUpdated: 1439478405547,
items: [42, 100]
}
}
}
在本教程中,我們不會(huì)對(duì)內(nèi)容進(jìn)行范式化,但是在一個(gè)復(fù)雜些的應(yīng)用中你可能需要使用。
在講 dispatch action 與網(wǎng)絡(luò)請(qǐng)求結(jié)合使用細(xì)節(jié)前,我們?yōu)樯厦娑x的 action 開發(fā)一些 reducer。
Reducer 組合須知
這里,我們假設(shè)你已經(jīng)學(xué)習(xí)過
combineReducers()
并理解 reducer 組合,還有 基礎(chǔ)章節(jié) 中的 拆分 Reducer。如果還沒有,請(qǐng)先學(xué)習(xí)。
reducers.js
import { combineReducers } from 'redux';
import {
SELECT_REDDIT, INVALIDATE_REDDIT,
REQUEST_POSTS, RECEIVE_POSTS
} from '../actions';
function selectedReddit(state = 'reactjs', action) {
switch (action.type) {
case SELECT_REDDIT:
return action.reddit;
default:
return state;
}
}
function posts(state = {
isFetching: false,
didInvalidate: false,
items: []
}, action) {
switch (action.type) {
case INVALIDATE_REDDIT:
return Object.assign({}, state, {
didInvalidate: true
});
case REQUEST_POSTS:
return Object.assign({}, state, {
isFetching: true,
didInvalidate: false
});
case RECEIVE_POSTS:
return Object.assign({}, state, {
isFetching: false,
didInvalidate: false,
items: action.posts,
lastUpdated: action.receivedAt
});
default:
return state;
}
}
function postsByReddit(state = {}, action) {
switch (action.type) {
case INVALIDATE_REDDIT:
case RECEIVE_POSTS:
case REQUEST_POSTS:
return Object.assign({}, state, {
[action.reddit]: posts(state[action.reddit], action)
});
default:
return state;
}
}
const rootReducer = combineReducers({
postsByReddit,
selectedReddit
});
export default rootReducer;
上面代碼有兩個(gè)有趣的點(diǎn):
使用 ES6 計(jì)算屬性語法,使用 Object.assign()
來簡(jiǎn)潔高效地更新 state[action.reddit]
。這個(gè):
return Object.assign({}, state, {
[action.reddit]: posts(state[action.reddit], action)
});
與下面代碼等價(jià):
let nextState = {};
nextState[action.reddit] = posts(state[action.reddit], action);
return Object.assign({}, state, nextState);
我們提取出 posts(state, action)
來管理指定帖子列表的 state。這僅僅使用 reducer 組合而已!我們還可以借此機(jī)會(huì)把 reducer 分拆成更小的 reducer,這種情況下,我們把對(duì)象內(nèi)列表的更新代理到了 posts
reducer 上。在真實(shí)場(chǎng)景的案例中甚至更進(jìn)一步,里面介紹了如何做一個(gè) reducer 工廠來生成參數(shù)化的分頁 reducer。
記住 reducer 只是函數(shù)而已,所以你可以盡情使用函數(shù)組合和高階函數(shù)這些特性。
最后,如何把之前定義的同步 action creator 和 網(wǎng)絡(luò)請(qǐng)求結(jié)合起來呢?標(biāo)準(zhǔn)的做法是使用 Redux Thunk middleware。要引入 redux-thunk
這個(gè)專門的庫才能使用。我們后面會(huì)介紹 middleware 大體上是如何工作的;目前,你只需要知道一個(gè)要點(diǎn):通過使用指定的 middleware,action creator 除了返回 action 對(duì)象外還可以返回函數(shù)。這時(shí),這個(gè) action creator 就成為了 thunk。
當(dāng) action creator 返回函數(shù)時(shí),這個(gè)函數(shù)會(huì)被 Redux Thunk middleware 執(zhí)行。這個(gè)函數(shù)并不需要保持純凈;它還可以帶有副作用,包括執(zhí)行異步 API 請(qǐng)求。這個(gè)函數(shù)還可以 dispatch action,就像 dispatch 前面定義的同步 action 一樣。
我們?nèi)钥梢栽?actions.js
里定義這些特殊的 thunk action creator。
actions.js
import fetch from 'isomorphic-fetch';
export const REQUEST_POSTS = 'REQUEST_POSTS';
function requestPosts(reddit) {
return {
type: REQUEST_POSTS,
reddit
};
}
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
function receivePosts(reddit, json) {
return {
type: RECEIVE_POSTS,
reddit,
posts: json.data.children.map(child => child.data),
receivedAt: Date.now()
};
}
// 來看一下我們寫的第一個(gè) thunk action creator!
// 雖然內(nèi)部操作不同,你可以像其它 action creator 一樣使用它:
// store.dispatch(fetchPosts('reactjs'));
export function fetchPosts(reddit) {
// Thunk middleware 知道如何處理函數(shù)。
// 這里把 dispatch 方法通過參數(shù)的形式參給函數(shù),
// 以此來讓它自己也能 dispatch action。
return function (dispatch) {
// 首次 dispatch:更新應(yīng)用的 state 來通知
// API 請(qǐng)求發(fā)起了。
dispatch(requestPosts(reddit));
// thunk middleware 調(diào)用的函數(shù)可以有返回值,
// 它會(huì)被當(dāng)作 dispatch 方法的返回值傳遞。
// 這個(gè)案例中,我們返回一個(gè)等待處理的 promise。
// 這并不是 redux middleware 所必須的,但是我們的一個(gè)約定。
return fetch(`http://www.reddit.com/r/${reddit}.json`)
.then(response => response.json())
.then(json =>
// 可以多次 dispatch!
// 這里,使用 API 請(qǐng)求結(jié)果來更新應(yīng)用的 state。
dispatch(receivePosts(reddit, json))
);
// 在實(shí)際應(yīng)用中,還需要
// 捕獲網(wǎng)絡(luò)請(qǐng)求的異常。
};
}
fetch
使用須知本示例使用了
fetch
API。它是替代XMLHttpRequest
用來發(fā)送網(wǎng)絡(luò)請(qǐng)求的非常新的 API。由于目前大多數(shù)瀏覽器原生還不支持它,建議你使用isomorphic-fetch
庫:
// 每次使用 `fetch` 前都這樣調(diào)用一下
import fetch from 'isomorphic-fetch';
在底層,它在瀏覽器端使用
whatwg-fetch
polyfill,在服務(wù)器端使用node-fetch
,所以如果當(dāng)你把應(yīng)用改成同構(gòu)時(shí),并不需要改變 API 請(qǐng)求。注意,
fetch
polyfill 假設(shè)你已經(jīng)使用了 Promise 的 polyfill。確保你使用 Promise polyfill 的一個(gè)最簡(jiǎn)單的辦法是在所有應(yīng)用代碼前啟用 Babel 的 ES6 polyfill:
// 在應(yīng)用中其它任何代碼執(zhí)行前調(diào)用一次
import 'babel-core/polyfill';
我們是如何在 dispatch 機(jī)制中引入 Redux Thunk middleware 的呢?我們使用了 applyMiddleware()
,如下:
index.js
import thunkMiddleware from 'redux-thunk';
import createLogger from 'redux-logger';
import { createStore, applyMiddleware } from 'redux';
import { selectReddit, fetchPosts } from './actions';
import rootReducer from './reducers';
const loggerMiddleware = createLogger();
const createStoreWithMiddleware = applyMiddleware(
thunkMiddleware, // 允許我們 dispatch() 函數(shù)
loggerMiddleware // 一個(gè)很便捷的 middleware,用來打印 action 日志
)(createStore);
const store = createStoreWithMiddleware(rootReducer);
store.dispatch(selectReddit('reactjs'));
store.dispatch(fetchPosts('reactjs')).then(() =>
console.log(store.getState())
);
thunk 的一個(gè)優(yōu)點(diǎn)是它的結(jié)果可以再次被 dispatch:
actions.js
import fetch from 'isomorphic-fetch';
export const REQUEST_POSTS = 'REQUEST_POSTS';
function requestPosts(reddit) {
return {
type: REQUEST_POSTS,
reddit
};
}
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
function receivePosts(reddit, json) {
return {
type: RECEIVE_POSTS,
reddit,
posts: json.data.children.map(child => child.data),
receivedAt: Date.now()
};
}
function fetchPosts(reddit) {
return dispatch => {
dispatch(requestPosts(reddit));
return fetch(`http://www.reddit.com/r/${reddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(reddit, json)));
};
}
function shouldFetchPosts(state, reddit) {
const posts = state.postsByReddit[reddit];
if (!posts) {
return true;
} else if (posts.isFetching) {
return false;
} else {
return posts.didInvalidate;
}
}
export function fetchPostsIfNeeded(reddit) {
// 注意這個(gè)函數(shù)也接收了 getState() 方法
// 它讓你選擇接下來 dispatch 什么。
// 這對(duì)緩存命中時(shí)
// 減少網(wǎng)絡(luò)請(qǐng)求很有用。
return (dispatch, getState) => {
if (shouldFetchPosts(getState(), reddit)) {
// 在 thunk 里 dispatch 另一個(gè) thunk!
return dispatch(fetchPosts(reddit));
} else {
// 告訴調(diào)用代碼不需要再等待。
return Promise.resolve();
}
};
}
這可以讓我們逐步開發(fā)復(fù)雜的異步控制流,同時(shí)保持代碼整潔如初:
index.js
store.dispatch(fetchPostsIfNeeded('reactjs')).then(() =>
console.log(store.getState());
);
服務(wù)端渲染須知
異步 action creator 對(duì)于做服務(wù)端渲染非常方便。你可以創(chuàng)建一個(gè) store,dispatch 一個(gè)異步 action creator,這個(gè) action creator 又 dispatch 另一個(gè)異步 action creator 來為應(yīng)用的一整塊請(qǐng)求數(shù)據(jù),同時(shí)在 Promise 完成和結(jié)束時(shí)才 render 界面。然后在 render 前,store 里就已經(jīng)存在了需要用的 state。
Thunk middleware 并不是 Redux 處理異步 action 的惟一方式。你也可以使用 redux-promise 或者 redux-promise-middleware 來 dispatch Promise 而不是函數(shù)。你也可以使用 redux-rx dispatch Observable。你甚至可以寫一個(gè)自定義的 middleware 來描述 API 請(qǐng)求,就像這個(gè)真實(shí)場(chǎng)景的案例中的做法一樣。你也可以先嘗試一些不同做法,選擇喜歡的,并使用下去,不論有沒有使用到 middleware 都行。
Dispatch 同步 action 與異步 action 間并沒有區(qū)別,所以就不展開討論細(xì)節(jié)了。參照搭配 React 獲得 React 組件中使用 Redux 的介紹。參照 Example: Reddit API 來獲取本例的完整代碼。
閱讀 異步數(shù)據(jù)流 來整理一下 異步 action 是如何適用于 Redux 數(shù)據(jù)流的。
更多建議: