📶

Async Actions with Middleware and Thunks

2023/09/09に公開

前言

文章內容為 codecademy 的學習筆記。

目前已經學到用 Redux state management 來創建一個 App。接下來要挑戰在開發中常見到的挑戰—非同步的要求。如果只用基礎的 Redux store,目前只能做到同步的更新。而當 action 被發送時,它會立即由 reducer 處理,並去更新 store。但當開發 App 時,常常會想要執行非同步的操作(例如呼叫 API),並根據結果來更新 state。

在這篇文章中,會介紹與 Redux store 互動的非同步邏輯寫法,以及它所需要的工具。

  1. 首先,會學到兩個在 computing 中的觀念: middleware 和 thunks,以及它們與 Redux 的關係。
  2. 接下來會學到 the promise lifecycle 以及要如何用它來提供完整的使用者體驗。
  3. 最終,需要利用 @reduxjs/redux-toolkit 所提供的工具,來練習在 Redux apps 中加入非同步邏輯。

這篇文章會使用 Mock Service Worker 來複製外部的 API 功能。為了使用 MSW,可能也會使用 Google Chrome 以及 enable third-party cookies

Middleware in Redux

Redux 雖然可以滿足應用程式中大部分狀態管理的需求。 但每個專案都稍有不同,因此 Redux 提供了一些自定義的方式。其中可以自定義 Redux 的方法之一是新增 middleware

middleware 會在程式間執行——通常在接收請求和生成響應的框架之間。 並且 middleware 是一個強大的工具,可以用於擴充套件、修改、自定義框架或 library 的預設行為,來滿足應用程式的特定需求。

在 Redux 中,middleware 會在當 action 被發送時,以及把 action 傳給 reducer 的時候執行。 而透過之前的文章,大致上熟悉了資料流經 Redux 的方式:也就是 actions 被發送到 store 後,它們會由負責產生 new state 的 reducers 處理;而引用它的所有 components 都可以使用這個 new state,來讓這些 components 去更新。 整個流程可以在這個連結的附圖中看到 middleware 的位置,藉由這張圖可以理解 middleware 在哪一部分發揮了什麼作用?

在被傳送之後,以及傳遞給 reducer 之前,middleware 會攔截住 actions。而會用 middleware 執行的一些常見任務包括 logging、caching、adding auth tokens to request headers、crash reporting、routing 以及對資料做非同步請求。 目前可以使用比較流行的開源 middleware 將任何一個功能新增到應用程式中。 當然,也可以編寫自己的 middleware 來解決特定於應用程式或其框架的問題。

這邊以前一篇文章中所舉例的 Recipe app 來說明,那為了在 Recipe app 中提出非同步請求,可以使用 createAsyncThunk() 這樣的 Redux Toolkit function,也可以使用傳遞給 createSlice function 的 extraReducers 。 在隨後的範例中,將會介紹 createAsyncThunk() 如何使用 middleware 和 thunk 來做非同步請求;到這邊,已經大致了解了 middleware 在 Redux 資料流的位置。

接下來在這篇文章中會專注在 middleware 啟動的前後,Redux 的資料流會有什麼步驟?

Write Your Own Middleware

這部分會著重說明 middleware 如何進入 Redux 的資料流中。探討 middleware 實際上在 Redux 怎麼被使用,以及怎麼被建構的。這邊會從頭寫個簡單的 middleware 來做練習。

首先,可以從之前的練習中回想到,middleware 在發送 action 之後,或在 action 發送給 reducer 之前所執行的樣子。 這邊可以看看上面這些過程實際上是怎麼運作的?

那為了把 middleware 加入 project 中,這邊會使用 Redux’s applyMiddleware function 來示範:

import { createStore, applyMiddleware } from 'redux';
import { middleware1, middleware2, middleware3 } from './exampleMiddlewares';
import { exampleReducer } from './exampleReducer';
import { initialState} from './initialState';

const store = createStore(
  exampleReducer, 
  initialState, 
  applyMiddleware(
    middleware1, 
    middleware2, 
    middleware3
  )
);

那關於 applyMiddleware 會如何執行的細節不會在這篇文章詳述。 這部分只要知道,一旦 middleware 被新增到 Redux 專案中,呼叫 dispatch 事實上就是呼叫 middleware 的 pipeline(所有被加入的 middleware 所成的鏈),也就是說,被傳送的任何 actions 都會在進入應用程式的 reducers 之前,從 middleware 傳遞到另一個 middleware。

Middlewares 必須符合特定的 nested function 結構,才能作為 pipeline 的一部分(若想閱讀更多內容,這種 nested function 也叫做 higher-order function)。 這樣子的結構看起來像這樣:

const exampleMiddleware = storeAPI => next => action => {
  // do stuff here
  return next(action);  // pass the action on to the next middleware in the pipeline
}

每個 middleware 都可以使用 storeAPI(由 dispatchgetState function 組成),其中也包含 pipeline 中的 next middleware 以及將要發送的 action。middleware function 在用 the current action 呼叫 pipeline 中的下一個 middleware 之前,會執行 middleware 的特定任務(若是該 middleware 是 pipeline 中的最後一個,那麼 next 則會是storeAPI.dispatch,因此呼叫 next(action) 跟發送 action 到 store 的動作是相同的)。

現在來寫自定義的 middleware,來印出 store 的內容。

以下可以舉個例子來看看:

import { createStore, applyMiddleware } from 'redux';

const messageReducer = (state = '', action) => {
  if (action.type === 'NEW_MESSAGE') {
    return action.payload;
  } else {
    return state;
  }
}

const logger = storeAPI => next => action => {
    console.log(storeAPI.getState())
    const nextState = next(action);
    console.log(nextState)
    return nextState;  // pass the action on to the next middleware in the pipeline
};

const store = createStore(messageReducer, "", applyMiddleware(logger));

store.dispatch({
  type: "NEW_MESSAGE",
  payload: "I WROTE A MIDDLEWARE"
})
  • 註記一下給 createStore 的第二個參數,是 the store’s state 的初始值。

介紹 Thunks

那這篇文章的另一個目標,是要利用一些工具把非同步 function 加到 Redux app 中,其中一個常見且彈性的方式是使用 thunks。thunks 是一個 higher-order function,它會先包裹住之後要用到的程式,來讓後續的動作做處理。舉個例子來說,add() function 會回傳一個 thunks,當這個 thunks 被呼叫的時候才會執行 x+y

const add = (x,y) => {
  return () => {
    return x + y; 
  } 
}

Thunks 方便且彈性,因為它可以將想要做的計算包裹起來並在程式碼中傳遞,也可以放在之後處理。

而延續上面的程式碼,來看看下面兩個呼叫的 function :

const delayedAddition = add(2,2)
delayedAddition() // => 4

呼叫 add() 並不會造成什麼額外的事發生,它只會回傳一個 function ,這個 function 再被呼叫時才會做加法。所以為了執行加法,還需要去呼叫 delayedAddition()

可以看看下面的例子:

const remindMeTo = task => {
  return `Remember to ${task}!!!`;
}

const remindMeLater = task => {
  return () => {
    // call remindMeTo
    return remindMeTo(task);
  }
}

const reminder = remindMeLater('get groceries');
console.log(reminder());
//print 'Remember to get groceries!!!';

Promise Lifecycle Actions

在理想狀況下,我們提出的每一個網路請求都會立即得到回應。但網路請求可能很慢,也可能失敗。而作為開發人員,我們必須考慮到這些可能會發生的事,才能讓使用者有比較好的使用體驗。比如如果我們知道一個請求它正在等待處理,則可以透過顯示載入的狀態來讓使用者感覺友善一些。 同樣地,若請求失敗了,也可以顯示錯誤的狀態。

而為了設計良好的使用者體驗,我們需要在任何給定的時間,去追蹤我們非同步請求的狀態,讓我們可以把這些狀態反應給使用者知道。通常會在執行一個非同步操作,或是根據已完成操作的結果進行 “fulfilled” 以及 “rejected” actions 之前,來發送一個 “pending” action。這邊以下面的程式碼以及圖片中的 fetchUserById 為例:

import { fetchUser } from './api';

const fetchUserById = (id) => {
  const payload = await fetchUser(id);
  // update user data in store with `payload`
}

截圖 2023-09-06 下午3.37.16.png

From learn Redux

若包含 pending 以及 rejected actions 的話,則會像以下這樣:

import { fetchUser } from './api'
const fetchUserById = (id) => {
  // update store to show user data is being requested -> "pending" state
  try {
    const payload = await fetchUser(id)
   // update user data in store with `payload` -> "fulfilled" state
  } catch(err) {
   // notify store to show user data failed to be fetched -> "rejected" state
  }
}

可以稱 pending / fulfilled / rejected actions 這些為 promise lifecycle actions。這樣的 pattern 蠻普遍的, 所以 Redux Toolkit 也提供一個方式—createAsyncThunk,在 Redux apps 中涵蓋了promise lifecycle actions。

createAsyncThunk()

createAsyncThunk() 是一個具有兩個引參數的 function,一個是 action type string 和一個是非同步 callback,它生成一個 thunk action creator,這個 creator 將會執行前面提供的 callback,並適當地發送 promise lifecycle actions,這樣就不必手動去發送 pending / fulfilled / rejected actions。

而為了使用 createAsyncThunk() ,要先從 Redux Toolkit import:

import { createAsyncThunk } from '@reduxjs/toolkit';

接下來,會需要呼叫 createAsyncThunk(),並傳遞兩個參數。

  1. 第一個是表示非同步的 action’s type 字串。 通常,type strings 的格式會是 "resourceType/actionName"。 且在這種情況下,由於是透過 ID 來獲取每一個使用者的資訊,所以 action type 的寫法會是 users/fetchUserById 這樣子。
  2. createAsyncThunk 的第二個參數是 the payload creator:是一個非同步 function,它將回傳一個用來解析非同步操作結果的 promise。

以下是利用 createAsyncThunk 重寫的 fetchUserById

import { createAsyncThunk } from '@reduxjs/toolkit'
import { fetchUser } from './api'
const fetchUserById = createAsyncThunk(
  'users/fetchUserById', // action type
  async (arg, thunkAPI) => { // payload creator
    const response = await fetchUser(arg);
    return response.json();
  }
)

這裡有一些值得觀察的地方。 首先,可以看到 payload creator 收到了兩個參數—— argthunkAPI ,這部分會在下一個段落詳細說明。 其次是這邊提供的 payload creator 根本不會發送任何的 actions。 它只會回傳非同步操作的結果。

目前看來,createAsyncThunk() 確實讓定義 thunk action creators 的方式變得簡單許多。 只需要寫一個非同步的 thunk function;而 createAsyncThunk() 負責剩下的部分,並回傳一個 action creator,而 action creator 就會適當地發送 pending / fulfilled / rejected actions 等。

Passing Arguments to Thunks

承上一個段落,這邊要詳細說明 payload creator (用來傳遞給 createAsyncThunk 的非同步 function)所接收的兩個參數:argthunkAPI。 第一個參數 arg 等於傳遞給 thunk action creator 本身的第一個參數。 例如,如果我們呼叫 fetchUserById(7),那麼在 payload creator 中,arg 將等於7。

但是,如果需要將多個參數傳遞到 thunk 呢? 因為 payload creator 只接收傳遞給 thunk action creator 的第一個參數,因此需要將多個參數包裹到一個 object 中。 例如,假設我們想按名字和姓氏來搜尋 App 的使用者, 如果 thunk action creator 被宣告為 searchUsers,則會把它寫成這樣:searchUsers({firstName: 'Ada', lastName: 'Lovelace'})

如果需要各別使用這些變數,可以在宣告 payload creator 時,使用 ES6 解構賦值來解開 object,並將其傳遞給 createAsyncThunk,如下所示:

const searchUsers = createAsyncThunk(
    'users/searchUsers',
    async ({ firstName, lastName }, thunkAPI) => {
        // perform the asynchronous search request here
    }
)

如果使用的 thunk 不需要參數,可以在沒有它的情況下呼叫 action creator,arg 參數就不會被定義。

如果 thunk 只需要一個參數(比如,透過 id 獲取特定資源),則會利用比較語義的方式來命名第一個參數。 下面是上一個段落中的 fetchUserById 例子,這邊 arg 在參數上可以用userId 來命名。

import { createAsyncThunk } from '@reduxjs/toolkit'
import { fetchUser } from './api'
const fetchUserById = createAsyncThunk(
    'users/fetchUserById', // action type
    async (userId, thunkAPI) => { // payload creator
        const response = await fetchUser(userId)
        return response.data
    }
)

The payload creator 的第二個參數 thunkAPI 是一個包含幾個方法的 object,包括 store’s dispatchgetState。 若要更了解列出 thunkAPI object 的方法,可以閱讀這份文件

Actions Generated by createAsyncThunk()

如同前面提到的,createAsyncThunk 會負責每個 promise lifecycle states 所發送的 actions:pending, fulfilled, 以及 rejected。 但這些 actions 實際上是什麼樣子?

藉由建立傳遞給它的 action type 字串,createAsyncThunk 會為每個 promise lifecycle states 生成一個 action type。 如果將 action type 字串 'resourceType/actionType' 傳遞給createAsyncThunk,它將會產生以下三個 action types:

  • 'resourceType/actionType/pending'
  • 'resourceType/actionType/fulfilled'
  • 'resourceType/actionType/rejected'

利用之前的例子來看看:

import { createAsyncThunk } from '@reduxjs/toolkit'
import { fetchUser } from './api'

const fetchUserById = createAsyncThunk(
  'users/fetchUserById', // action type
  async (userId, thunkAPI) => { // payload creator
    const response = await fetchUser(userId)
    return response.data
  }
)

當傳遞 createAsyncThunk 這樣的 action type 字串 'users/fetchUserById' 時,createAsyncThunk 會產生以下三種 action types:

  • 'users/fetchUserById/pending'
  • 'users/fetchUserById/fulfilled'
  • 'users/fetchUserById/rejected'

如果需要使用個人的 pending / fulfilled / rejected action creators,可以像以下這樣引用它們:

  • fetchUserById.pending
  • fetchUserById.fulfilled
  • fetchUserById.rejected

如果想在 App 中反映這些 promise lifecycle states,則必須在 reducers 中處理這些 action types。在下一個段落中,將介紹如何做到。

Using createSlice() with Async Action Creators

在上一篇文章中有介紹了 createSlice()。 在這篇文章中,將會介紹 extraReducers,它可以讓你傳遞 createSlice 的屬性,這個屬性允許 createSlice 對它未生成的 action types 做響應。

回想一下以前說過的,createSlice() 接受單一個參數,也就是 options,這是一個 object ,它的參數是有結構的 ,包括名稱、 initial state 以及 reducers 等等。而且,createSlice() 會使用這些配置好的參數去生成一個 slice of the store,包括 action creators 和 action types,用於更新 slice 所包含的 state。 可以看看以下範例:

const usersSlice = createSlice({
  name: 'users',
  initialState: { users:  [] },
  reducers: {
    addUser: (state, action) => { 
      state.users.push(action.payload) 
    }        
  },
})

藉由對 createSlice() 的呼叫,來生成 a slice of the store,這個 slice 會響應 action creator usersSlice.actions.addUser。 但是,如果是透過呼叫 createAsyncThunk() 來生成 action creators 呢? 這部分就需要考慮使用 fetchUserById (前幾個段落有提到的非同步 action creator ):

const fetchUserById = createAsyncThunk(
  'users/fetchUserById', // action type
  async (userId, thunkAPI) => { // payload creator
    const response = await fetchUser(userId)
    return response.data
  }
)

此非同步 action creator 將會生成三種 action types:'users/fetchUserById/pending', 'users/fetchUserById/fulfilled', 以及 'users/fetchUserById/rejected'。 目前,這些 action types 對我們使用者的 slice 沒有影響,slice 只會對由 createSlice() 所生成的 users/addUser action type 有響應。

那如何在使用者的 slice 中解釋這些 promise lifecycle action types? 這正是 extraReducers(用來傳遞給 createSlice()的object 屬性)所要解決的問題。extraReducers 允許 createSlice() 響應其他地方產生的 action types。 為了讓使用者的 slice 響應 promise lifecycle action types,會將它們傳遞給extraReducers 屬性中的 createSlice()

可以在下面的 usersSlice.js 程式碼看看 extraReducers 這個屬性的範例:

const fetchUserById = createAsyncThunk(
  'users/fetchUserById',
  async (userId) => {
    const users = await fetch(`api/users${userId}`)
    const data = await users.json()
    return data
  }
)

const usersSlice = createSlice({
  name: 'users',
  initialState: { 
    users:  [], 
    isLoading: false, 
    hasError: false 
  },
  reducers: {
    addUser: (state, action) => { 
      state.users.push(action.payload) 
    }        
  },
  extraReducers: {
    [fetchUserById.pending]: (state, action) => {
      state.isLoading = true;
      state.hasError = false;
    },
    [fetchUserById.fulfilled]: (state, action) => {
      state.users.push(action.payload);
      state.isLoading = false;
      state.hasError = false;
    },
    [fetchUserById.rejected]: (state, action) => {
      state.isLoading = false;
      state.hasError = true;
    }
  }
})

這邊要注意一下,除了使用 extraReducers 屬性外,還會在 state object 中添加了一些額外的欄位:

  • 一個布林值 — isLoading,也就是當請求為待處理時,布林值為 true,否則為false。
  • 以及以及另一個布林值 — hasError,若抓取使用者的請求被拒絕了,則會將其設定為true。

這些新功能讓我們可以去追蹤 promise lifecycle states,以便在 promise 處於 pendingrejected 時,有使用者介面可以參考。 且當 promise 被 fulfilled 時,這些則會被設定為 false,並將使用者資料新增到 state 中。

文章參考

Learn Redux

Discussion