iT邦幫忙

2023 iThome 鐵人賽

DAY 26
1
Modern Web

從Vue學React!不只要會用,還要真的懂~系列 第 26

【Day 26】用實作學Redux Toolkit!完成一個購物車(下) - Middleware之處理非同步的操作

  • 分享至 

  • xImage
  •  

昨天已經看了一些RTK的基本用法,今天一樣是使用RTK繼續完成我們的購物車。雖然昨天已經完成了大部分的功能,可以把商品加入到購物車了,但是我們的商品清單目前是暫時先寫死的狀態,也就是沒有透過api取得當前最新的商品列表。前幾天有提到使用Redux時,通常會透過Middleware處理非同步的操作,今天就從Middleware是什麼開始認識,並在最後完成我們購物車串接api的部分吧!

什麼是Middleware?

在Redux中,Middleware會用來處理dispatch一個action後,到更新store state觸發畫面重新渲染之間需要額外做的一些動作,這些動作包含處理打api相關的操作、記錄log等。簡單來說,就是讓dispatch action的動作,不只是很單純地送出一個action來更新store的state,還在中間做了其他的事情

為什麼需要Middleware

雖然不使用Middleware一樣也可以操作非同步或記錄log的動作,但是如果不使用Middleware,就必須自己去管理這些動作的狀態,例如串接api時,發送request後的不同狀態,像是pending、fullfilled、rejected。
這裡先稍微看一下,沒使用Middleware的時候,要怎麼處理非同步的動作。

沒有使用Middleware的情況

首先會需要定義打API後,會有的pending、fullfilled、rejected的狀態,以及與其對應的action,如果是用一般的Redux的寫法,需要透過action creator來產生處理這三個狀態的action物件。
例如以下這樣的寫法

const fetchProductsDataRequest = () => ({
  type: 'FETCH_PRODUCTS_DATA_REQUEST',
});

const fetchProductsDataSuccess = (data) => ({
  type: 'FETCH_PRODUCTS_DATA_SUCCESS',
  payload: data,
});

const fetchProductsDataFailure = (error) => ({
  type: 'FETCH_PRODUCTS_DATA_FAILURE',
  error: error,
});

但是因為今天我們是使用RTK,createSlice會幫我們自動處理action creator,所以可以省略這個步驟。不過由於會需要把fetch到的資料存在store裡面,所以還是會需要先使用createSlice來定義store。

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  products: null,
  loading: false,
  error: null,
};

export const productsDataSlice = createSlice({
  name: 'productData',
  initialState,
  reducers: {
    // 定義接API時,處理對應狀態的action
    fetchDataRequest: (state) => (
      {
        ...state,
        loading: true,
        error: null,
      }
    ),
    fetchDataSuccess: (state, action) => (
      {
        ...state,
        ...action.payload,
        products: action.payload,
        loading: false,
        error: null,
      }
    ),
    fetchDataError: (state, action) => (
      {
        ...state,
        products: null,
        loading: false,
        error: action.payload,
      }
    ),
  }
});

export const { fetchDataRequest, fetchDataSuccess, fetchDataError }  = productsDataSlice.actions;
export default productsDataSlice.reducer;

action的定義好後,再透過定義的action,來處理打API時,需依照狀態改變來進行的動作。

import { useEffect } from "react";
import ProductListItem from "./ProductListItem";
import { useDispatch , useSelector } from "react-redux";
import { addProductItem } from "../slices/shoppingCart";
import { fetchDataRequest, fetchDataSuccess, fetchDataError } from "../slices/productsData";

export default function ProductList() {
  const dispatch = useDispatch();
  const { products } = useSelector(state => state.productsDataReducer);
  const addProductToCart = (payload) => {
    dispatch(addProductItem(payload));
  };

  useEffect(() => {
    const fetchProductsData = () => {
      // dispatch 開始發送api request
      dispatch(fetchDataRequest());
      fetch('https://api.escuelajs.co/api/v1/products')
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        // dispatch 成功的 action
        dispatch(fetchDataSuccess(data));
      })
      .catch((error) => {
        // dispatch 失敗的 action
        dispatch(fetchDataError(error.message));
      });  
    };
    fetchProductsData()
  }, [dispatch]);

  return (
    <div className="product-list">
      <h2>Product List</h2>
      <div className="product-item-container">
        {
          products && products.map((product) => (
            <ProductListItem product={product} key={product.id} addProductToCart={addProductToCart} />
          ))
        }
      </div>
    </div>
  )
}

從這個例子可以發現即使沒有使用Middleware,一樣也可以處理非同步的操作,但是非同步操作和錯誤處理都包會被含在React元件中,並且需要手動dispatch action。這樣的寫法也就會使得元件的程式碼變得很長且難以理解,尤其是如果有多個非同步操作,那程式碼就會變得更複雜。

有使用Middleware的情況

目前已經看了沒有使用任何Middleware的寫法了,接下來再來看看如果使用React Thunk這個Middleware的話,寫法上會有什麼樣的調整。在有使用Middleware的情境下,一樣會需要使用action creator來定義action。如果是使用一般Redux搭配redux thunk,會需要先以下述的寫法來定義action。

const fetchDataRequest = () => ({
  type: 'FETCH_DATA_PENDING',
});

const fetchDataSuccess = () => ({
  type: 'FETCH_DATA_FULFILLED',
});

const fetchDataFailure = (error) => ({
  type: 'FETCH_DATA_ERROR',
  error: error,
});

今天我們是使用RTK,則可以透過createAsyncThunk來簡化一些比較繁瑣的步驟,雖然createAsyncThunk並不是middleware本身,但是也是在建立在middleware的基礎之上。另外,由於RTK預設的MiddleWare是thunk,所以在使用createAsyncThunk,不需要額外安裝thunk。

使用createAsyncThunk有兩個一定要帶入的參數,分別actionTypePrefixasyncThunkFunction,另一個參數則可以依照實際情況再選擇要不要帶上。
actionTypePrefix會讓createAsyncThunk產生的三個狀態的action type都被帶上透過這個參數帶上的前綴,如果是fetchProductsData,產生的action type也就會是fetchProductsData/pending、fetchProductsData/fulfilled 和fetchProductsData/rejected;asyncThunkFunction則是處理非同步操作的函式。

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

// 使用createAsyncThunk來處理api的操作
export const fetchProductsData = createAsyncThunk('fetchProductsData', async () => {
  try {
    const response = await fetch('https://api.escuelajs.co/api/v1/products');
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
});

const initialState = {
  products: null,
  isLoading: true,
  error: null,
};

export const productsDataSlice = createSlice({
  name: 'productsData',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    // 增加關於fetch data有關三個狀態
    builder.addCase(fetchProductsData.pending, (state) => {
      state.isLoading = true; // 正在fetch資料
    })
    .addCase(fetchProductsData.fulfilled, (state, action) => {
      // fetch成功
      state.products = action.payload;
      state.isLoading = false;
    })
    .addCase(fetchProductsData.rejected, (state, action) => {
      state.isLoading = false; // fetch失敗
      state.error = action.error.message;
    });
  },
});

export default productsDataSlice.reducer;

最後記得還是要把這個reducer帶到createStore裡面,這樣才可以取得透過API拉取並儲存於store內的商品資料。

import { configureStore } from "@reduxjs/toolkit";
import shoppingCartReducer from './slices/shoppingCart';
import productsDataReducer from "./slices/productsData";
const store = configureStore({
  reducer: {
    shoppingCartReducer,
    // 把productsDataReducer也加上
    productsDataReducer,
  },
});

export default store;

前置作業都完成後,就可以在需要打API的元件中,補上打API拉取資料的動作,以及透過useSelector取得store內state的動作。

import { fetchProductsData } from '../slices/productsData';

const { products, isLoading } = useSelector(state => state.productsDataReducer);
useEffect(() => {
  dispatch(fetchProductsData());
}, [dispatch]);

回顧有無使用Middleware的差異

搭配Redux Thunk這個Middleware時,雖然在元件中使用的時候,只需要 dispatch一個action creator,而不需要在每個階段都手動dispatch不同的 action,但在編寫action creator時,仍然需要根據非同步操作中的不同狀態,來定義不同的action。
也就是說,即使用了Middleware仍然需要在action creator中定義不同的action type,以區分不同的非同步操作,例如開始、成功、失敗等。這樣才可以確保在不同情況下,Redux Thunk都能夠正確處理相對應的action,並通知 reducer進行狀態更新。所以使用Middleware雖然可以將處理非同步操作的程式碼集中管理,但仍然無法用更簡潔的方式處理非同步的操作或更新state之前的動作。如果想解決步驟繁瑣的問題的話,官方推薦搭配RTK一起使用的RTK Query就是一個很合適的用法。

RTK Query是什麼?

RTK Query是React Toolkit提供來fetch及cache資料的工具,主要目的是簡化fetch資料時,需要手寫相關邏輯的工。當使用RTK Query時,它會自動產生store、action和reducer,也會自動處理撰寫串接API時,需要處理pedding、fulfilled、error的三個狀態,也就可以省略掉需要使用action creator撰寫串接API時,需要手動定義及處理這三個狀態的這個步驟。透過這樣的做法,就能讓整體的程式碼變得更簡潔。

完成購物車的最後一個步驟!串接取得products的api

前面已經看了有使用Midleware和沒有使用Middleware的情境,並且認識RTK Query是什麼了,接下來直接透過使用RTK Query的實作,實際感受一下使用RTK Query和使用Middleware有什麼差異吧!

使用RTK Query的起手式 - 使用createApi定義API

首先透過createApi建立一個productsDataApi的檔案來定義API,並且用fetchBaseQuery來建立一個使用指定URL的fetch函式。

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const productsDataApi = createApi({
  reducerPath: 'productsDataApi',
  // 設定指定base URL的fetch函式
  baseQuery: fetchBaseQuery({ baseUrl: 'https://api.escuelajs.co/api/v1' }),
  endpoints: builder => ({
    // 定義一些api的操作,例如get, post等
    getProductsData: builder.query({
      query: () => `/products`
    })
  })
});

// 這裡export到外面的hook名稱,會依照上面endpoint取的名字加上use和query
export const { useGetProductsDataQuery } = productsDataApi;

使用createApi定義的API,會回傳以下的這些內容,這裡主要會使用到的是reducer。
https://ithelp.ithome.com.tw/upload/images/20230930/20130914MvXKGQUWSM.png

定義好的api之後,一樣也需要把它設定到store上,才能夠正常使用。主要會有兩個步驟,一個把reducer設定上去,另一個則是把productsDataApi的Middleware和預設的Middleware合併在一起

import { configureStore } from "@reduxjs/toolkit";
import shoppingCartReducer from './slices/shoppingCart';
import { productsDataApi } from "./apis/productsDataApi";

const store = configureStore({
  reducer: {
    shoppingCartReducer,
    // 把reducer加上
    [productsDataApi.reducerPath]: productsDataApi.reducer,
  },
  // 取得預設的middleware,並把createApi產生的middleware和預設的middleware合併成一個新的middleware
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(productsDataApi.middleware)
});

export default store;

透過createApi產生的use hook來fetch資料

在需要使用的地方把useGetProductsDataQuery這個hook import進去。

import { useGetProductsDataQuery } from "../apis/productsDataApi";

export default function ProductList() {
  // 透過useGetProductsDataQuery可以快速取得data, error, isLoading
  const {data, error, isLoading} = useGetProductsDataQuery();
  return (
    <div className="product-list">
      <h2>Product List</h2>
      <div className="product-item-container">
      // 這樣就可以輕鬆地把data和isLoading拿來使用
        {
          isLoading ? <p>Loading...</p> : data.map((product) => (
            <ProductListItem product={product} key={product.id} addProductToCart={addProductToCart} />
          ))
        }
      </div>
    </div>
  )
}

以上幾行程式碼,就可以輕輕鬆鬆地處理串接API的動作,而且不需要額外定義有關products的store,也不用創建action處理API相關的狀態,讓整體的程式碼更簡潔好維護。如果需要在處理API這樣非同步的操作時,做更多的處理時,也可以透過客製化hook的方式進行。

https://i.imgur.com/nTH4Mio.gif

最後完成的repo在這裡

回顧&總結

今天看了在redux中要怎麼處理非同步的操作,這之中嘗試了沒有透過middleware處理的方法,也嘗試了有middleware處理的方法,還近一步使用RTK Query實作,雖然三種方法都可以達到目的,但是也可以很明顯地感受出三種不同做法上,程式碼複雜與否的差異。從這次的實務過程,也能更熟悉redux在操作資料上的整體概念,也就是「透過action下去決定要做什麼操作」,不論是同步的動作,還是非同步的動作,都是以這個相同的概念下去執行。

今天到這裡已經把redux的部分告一個段落,明天會來接著看完成一個Vue或React專案也很重要的一個部分,也就是Route路由的部分。

參考資料

Middleware
RTK Query Basics


上一篇
【Day 25】用實作學Redux Toolkit!完成一個購物車(上) - 基本設定&基本用法
下一篇
【Day 27】 SPA與他的小夥伴 - 路由(Route)
系列文
從Vue學React!不只要會用,還要真的懂~30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言