更新於 2023/07/12閱讀時間約 19 分鐘

十分鐘完整理解 createAsyncThunk 如何處理非同步操作

如何完整理解 createAsyncThunk?

如何完整理解 createAsyncThunk?

在學習 Redux 和 Redux Toolkit 時,最頭痛的莫過於處理非同步操作,也因此有了這一篇文章。在 Redux Toolkit 中的 createAsyncThunk 函數中,有個關鍵字 Thunk,其實就是來自於內建的工具 Redux-Thunk。

那麼,Thunk 是什麼?

在軟體領域中,"Thunk" 是一個常用的術語,它指的是一種用於延遲計算,或將運算延後執行的程式碼片段。它通常用於函數式編程,或編譯器的設計中。

如何在 Redux 中應用 Thunk

這裡就要稍微提到中介軟體(Middleware)的概念。Middleware 在 Redux 中,指的是在 dispatch 派發任務後、在 action 實際執行前,中間這段可以額外處理的過程。整個概念,其實就和 Thunk 本身的含意相似。

在 Redux Toolkit 裡,處理異步的資料處理,我們可以使用 createAsyncThunk 這個 API。createAsyncThunk 其實本質是一個 Action creator,但透過 dispatch 前處理異步的行動,再將資料提供給 dispatch 來執行派發,以達到 Middleware 處理資料的目的。

要實現 Thunk 總共有三個步驟,分別是:

  1. 處理資料:使用 createAsyncThunk 建立 action 與異步資料處理
  2. 派發行動:使用 extraReducer 處理 action 派發後續使用的 reducer
  3. 執行任務:使用 dispatch 實際派發,執行 reducer

在瞭解具體的案例前,先讓我們瞭解 createAsyncThunk 如何使用吧!



一、createAsyncThunk 的用法

究竟什麼是 createAsyncThunk?

在這個區段,為了讓讀者瞭解完整 Thunk 運作的流程,總共會說明三個部分。分別是:

A: createAsyncThunk 參數輸入
B: 如何處理 Thunk 返回值
C: 完整的 Thunk 派發過程


A. createAsyncThunk 參數輸入

createAsyncThunk

createAsyncThunk 總共有三個參數,分別是:

  1. Type
  2. PayloaderCreator(arg, ThunkAPI)
  3. options

下面會逐步說明每一個參數的用途,並說明可以如何設定每個參數。

type 參數

用於設定額外的 Action Type,讓 Redux 可以辨識你使用的 Action 是什麼。通常都會以 “slice/action” 的寫法。且使用 Thunk 時,Redux 會自動生成額外的 pending/fulfilled/rejected 三個 Action,會於後續詳細說明。

payloadCreator 參數

需要傳入一個 Promise,該函式用於放置我們的異步操作,通常會使用 Async 函式來處理。payloadCreator 會傳入兩個參數,分別是 argThunkAPI

  • arg:便是我們在使用 Action 時,接收傳入資料作為參數的地方
  • ThunkAPI:則是 Redux 提供一整組的 API,例如獲取 getState、dispatch、extra 傳入其他參數等 API。

Options 參數

Options 會傳入一個物件,常見的有下列一些屬性,其他可以參考官方文件

  • condition:用於中斷建立 payload creator 的 condition 屬性。
  • dispatchConditionRejection: true,用於強制返回一個 rejected 的 Action 並派發。



B. 如何處理 Thunk 返回值

在處理完資料後,Thunk 會返回一個標準的 thunk Action creator。總共會返回四個 Action creator

  1. “slice/action”:會創建一個 Thunk 本身的 action creator,用於派發 Thunk 的 Action。
  2. “slice/action/pending”:會創建一個用於派發 pending 狀態的 Action creator。
  3. “slice/action/fulfilled”:會創建一個用於派發 fulfilled 狀態的 Action creator。
  4. “slice/action/rejected”:會創建一個用於派發 rejected 狀態的 Action creator。



C. 完整的 Thunk 派發過程

總共會有四個步驟,分別是:

  1. 等待:派發(dispatch)並執行 slice/action/pending
  2. 解析:呼叫 payloadCreator 並等待 Promise resolve
  3. 派發
    1. 若成功,派發 fulfilled 並傳遞值給 action.payload
    2. 若失敗,返回 ThunkAPI.rejectWithValue(value) 的錯誤值,並傳遞至 action.payload。可在 action.error.message 獲得 'Rejected' 字串;若未處理,則派發一個衍生錯誤至 action.error
  4. 返回總是返回一個 fulfilled 狀態的 Promise,可能包含 fulfilled / reject 狀態的資料。

在說明完 createAsyncThunk 的函式後,我們就以一個實際案例來說明。到底如何發揮 createAsyncThunk 的威力。



createAsyncThunk 實際案例說明

整個案例可以分成三個部分來理解,分別是:

  1. 建立 action 與異步資料處理
  2. cartSlice 處理 reducer 派發 Action
  3. 在元件中派發 Action

基本上所有的 Thunk 派發邏輯,都遵守上面的這個順序。讀者可以直接參考上方內容,並調整成自己希望使用的模式。


1. 建立 action 與異步資料處理

createAsyncThunk 函式使用

通常我們在撰寫 AsyncThunk 函式時,會將該函式放在 xxxActions.js 的檔案裡,用來特別區分 Action 相關的功能。此處,我們將 Thunk 提供的 typepayloadCreatoroptions 三個參數分開來看。

第一部份 type:我們設定在 cartSlice 中的 fetchCartData 這個 Action,故使用 "cart/fetchCartData" 來撰寫,這也符合 Redux 設定的規範。

第二部分 payloadCreator:此處傳入一個異步函式。因為這個 Thunk 是用來獲取資料,因此不需要傳入 Data,故用 _ 來忽略變數。之中對資料進行 json 解析,並處理部分狀態為 fulfilled 但 ok 為 false 的錯誤。

第三部分 options:可選參數,因為目前並沒有需要終止 Action 或其他需求,因此沒有設定第三個物件參數。



2. 在 cartSlice 處理 reducer 派發 Action

處理 Thunk 派生的 reducer

在這個階段,因為 reducer 並非直接設定在 slice 當中,因此 Redux 無法自動從 reducer 中生成 Action Creator,故需要使用 extraReducer 來處理 Thunk 派生的 Action Creator。

目前 Redux Toolkit 全面改用 builder 當作參數傳入,並使用 addCase 來接下 Action。

addCase 本身會有兩個參數:

  1. Action Type:用以傳入 Action 的 Type,此處其實可以手動輸入,但因為可能會有 Typo,因此慣例上都會使用 fetchCartDataThunk.fulfilled 寫法生成 Type。
  2. Callback:在派發之後,會實際執行 reducer 的地方。此處和內建的 reducers 享有 immer.js 帶來的便利,因此可以直接修改 state 來更新數據。



3. 在元件中派發 Action

在元件中讀取資料

附註:放入 dispatch 是為了避免 Prettier 跳出警示,但因為 dispatch 本身是讓 Redux 保證不會更新,因此可以放心放入。

因為該 Thunk 本身的目的,是在畫面讀取完後獲取資料,因此最合適在整個 App 處理完後,才進行下載的元件為 App.js

因此上述的 useEffect 程式碼,是放在 App.js 裡。確認後,在 App.js 讀取完後,就會執行 fetchCartDataThunk 的 Thunk 函式,並在更新後同步到元件中。

完整程式碼範例

// IN cartActions.js​
export const fetchCartDataThunk = createAsyncThunk(
"cart/fetchCartData",
async (_, { dispatch }) => {
try {
const response = await fetch(URL);
if (!response.ok) throw new Error("Fetch cart data failed.");
const data = await response.json();
return data;
} catch (error) {
console.error(error);
dispatch(notifyFor(messages.error));
}
}
);

// IN cartSlice.js
const cartSlice = createSlice({
name: "cart",
initialState: initialState,
reducers: [],
extraReducers: (builder) => {
builder
.addCase(fetchCartDataThunk.fulfilled, (state, action) => {
state.cartItems = action.payload?.cartItems || [];
state.cartQuantity = action.payload?.cartQuantity || 0;
})
.addCase(fetchCartDataThunk.rejected, (state, action) => {
state.status = "error";
state.error = action.error;
});
},
});

// IN App.js

const dispatch = useDispatch();

useEffect(() => {
dispatch(fetchCartDataThunk());
}, [dispatch]);

createAsyncThunk 整體的使用,是不是其實比想像中簡單呢!



如果元件需要知道錯誤資訊?

需要知道 Thunk 所使用的 payloadCreator 總是會返回 fulfilled 狀態的 Promise,因為他們不希望在開發者沒有 catch error 的情況下,就跳出 Uncaught Error。

因此 Thunk 會返回一個 fulfilled 的 Promise,並在該 Promise 中儲存 Fulfilled 或 Rejected 的資料,並讓我們獲取這些資料進行處理。

所以如果直接使用 then 來串接後續的行為,則永遠只會收到 fulfilled 的 Promise 而已。 

如何像一般 Promise 般使用 Thunk?

參考自 Redux Toolkit 官網

當我們希望直接在元件中獲取資料,而不希望隔一層從 Redux 的 store 獲取錯誤資料,這時就可以使用 unwrapunwrapResult,用來進一步處理後續的行動。使用的方式有兩個,分別是:

  • unwrap:會直接解打包 dispatch 回傳的資料,並像一般的 Promise 進行處理。
  • unwrapResult:相同目的,但以函式傳入 dispatch 後的返回值。

那如果中間有錯誤,Promise 要回傳的錯誤是怎麼來的呢?其實是我們需要從 ThunkAPI.rejectWithValue(value, [meta]) 特別設定傳入,不然 Redux 就會自動衍生一個序列化錯誤(Serialized Error)。

unwrap 的用法

參考自 Redux Toolkit 官網

unwrapResult 的用法

參考自 Redux Toolkit 官網

除此之外,還有更多的 API 可以使用,可以自行參考官方文件 的內容。



那 Middleware 在哪裡?

createAsyncThunk 除了能夠讓我們自動派發不同 Promise 的狀態,他也自動幫我們加入了其他三個 middleware 處理:

  1. 檢驗資料是否沒有被 mutate
  2. 是否有無法序列化的資料
  3. 確認 Action creator 是否有被錯誤執行

具體的返回值,在 configureStore 裡,有個參數是:middleware,此處可以手動設定所有的 middleware。

一旦設定該屬性,Redux 就只會讀取該陣列裡的 middleware。若要載入預設的 middleware,則可使用 getDefaultMiddleware() 來獲取。

const cartSlice = createSlice({
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});

// getDefaultMiddleware will return as follow
const middleware = [
actionCreatorInvariant,
immutableStateInvariant,
thunk,
serializableStateInvariant,
];



另一個完整實際案例

這個案例,透過 PUT 方式,來將購物車(Cart)的資料上傳到資料庫裡。整個案例分成兩個階段:

  1. 階段一:處理 Thunk 要上傳的函式
  2. 階段二:使用 useEffect 處理資料更動

並且我們為了讓畫面的 UI 可以根據異步狀態更新,因此使用了 ThunkAPI 來派發另一個負責處理 UI 的 Action 來派發任務。


1. 處理 Thunk 函式

在這個階段,我們設定了 Typecart/sendCartData,並在 payloadCreator 傳入 cart 作為 arg;並從 ThunkAPI 抽取出 { dispatch } 的 API 來派發任務。

整個上傳的過程,分成三個階段:

  1. 開始上傳前,先更新 UI 為 loading
  2. 實際上傳 PUT 取代資料
  3. 若成功,則更新 UI 為 Success;若失敗,則更新 UI 為 Error



2.更新資料後,派發 Thunk 程式上傳

接著在 App 元件裡,在 useEffect 中透過 dependencies,追蹤 cart 資料的更動。其中,為了讓第一次執行時,不要讓空白的 cart,直接上傳取代原本儲存的資料。故使用 isInitial 的變數,在第一次時就直接 return 跳出,後續才會上傳更新 cart 資料。

完整程式碼範例

// IN cartActions.js

export const sendCartDataThunk = createAsyncThunk(
"cart/sendCartData",
async (cart, { dispatch }) => {
try {
dispatch(uiActions.showNotification("Sending..."));
await fetch(URL, {
method: "PUT",
body: JSON.stringify({
cartItems: cart.cartItems,
cartQuantity: cart.cartQuantity,
}),
});
dispatch(uiActions.showNotification("Success!"));
} catch (error) {
dispatch(uiActions.showNotification("Error"));
console.log(error);
}
}
);

// IN App.js
let isInitial = true;

useEffect(() => {
if (isInitial) {
isInitial = false;
return;
}

if (cart.isChanged) {
dispatch(sendCartDataThunk(cart));
}
}, [cart, dispatch]);



本文結論

在 Redux 裡面除了基礎的 state 更新外,算是非常複雜的功能和概念,也因此有了這一篇文章。createAsyncThunk 在異步資料的處理上,提供了完整且全面的設計與開發體驗。

希望讀者在使用 createAsyncThunk 時,可以少走一些彎路,並真正感受到 Thunk 帶來的便利與優勢。


延伸閱讀

技術文章

學習成長



Reference

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.