Redux 異步 Action

2021-09-16 10:06 更新

異步 Action

基礎(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ù)流的呢?

Action

當(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 來做。

同步 Action Creator

下面先定義幾個(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_REDDITINVALIDATE_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)方案。

設(shè)計(jì) state 結(jié)構(gòu)

就像在基礎(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)用中,你還需要存放 fetchedPageCountnextPageUrl 這樣分頁相關(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)用中你可能需要使用。

處理 Action

在講 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

最后,如何把之前定義的同步 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 都行。

連接到 UI

Dispatch 同步 action 與異步 action 間并沒有區(qū)別,所以就不展開討論細(xì)節(jié)了。參照搭配 React 獲得 React 組件中使用 Redux 的介紹。參照 Example: Reddit API 來獲取本例的完整代碼。

下一步

閱讀 異步數(shù)據(jù)流 來整理一下 異步 action 是如何適用于 Redux 數(shù)據(jù)流的。

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)