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