Redux 異步 Action

2021-09-16 10:06 更新

異步 Action

基礎教程中,我們創(chuàng)建了一個簡單的 todo 應用。它只有同步操作。每當 dispatch action 時,state 會被立即更新。

在本教程中,我們將開發(fā)一個不同的,異步的應用。它將使用 Reddit API 來獲取并顯示指定 reddit 下的帖子列表。那么 Redux 究竟是如何處理異步數據流的呢?

Action

當調用異步 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 Creator

下面先定義幾個同步的 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_REDDITINVALIDATE_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。雖然在本教程中我們并不做錯誤處理,但是這個 真實場景的案例 會演示一種實現方案。

設計 state 結構

就像在基礎教程中,在功能開發(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 存放列表信息本身。在實際應用中,你還需要存放 fetchedPageCountnextPageUrl 這樣分頁相關的 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]
    }
  }
}

在本教程中,我們不會對內容進行范式化,但是在一個復雜些的應用中你可能需要使用。

處理 Action

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

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

連接到 UI

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

下一步

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

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號