企業級 React API 調用架構設計

很多人在項目做架構設計時,對於請求這裏怎麼梳理很困惑,這是成爲高級前端工程師的一項必備技能,一個良好的請求架構可以讓你的項目更加簡潔優雅。

在用原生 JavaScript 進行開發時,你可能會使用 Fetch 或 Axios 這樣的庫來進行 API 請求。在 React 中,你也可以用它們,關鍵的挑戰在於如何組織圍繞這些庫的代碼,使其儘可能可讀、可擴展和解耦。

有點難度,對於剛開始學習 React 的新開發人員來說,一般是這樣進行 API 請求:

// ❌ 別這樣做

const UsersList = () ={
  const [users, setUsers] = useState([]);

  useEffect(() ={
    fetch("/users").then((data) ={
      setUsers(users);
    });
  }[]);

  return (
    <ul>
      {users.map(user =(
        <li>{user.name}<li>
      ))}
    </ul>
  );
};

這樣可以工作,而且即使在業務級代碼庫中也很常見。但是存在一些問題:

解決這些問題有很多不同的方法,我的想法和方法是,創建一個可靠且可擴展的文件夾和文件結構,你甚至可以在像 Next.js 這樣的框架中應用它(或背後的思想)。

示例的場景

爲了理清楚所有這些概念,讓我們逐步構建一個購物清單 App 。該 App 將具有以下功能:

樣式方面,我會用 TailwindCSS[2]。爲了模擬 API 請求,我會用 Mirage JS[3],這是一個非常簡潔的 API 模擬庫。我們用 Fetch[4] 來調用 API。

所有示例代碼都在我的 GitHub[5] 上,你可以 clone 下來把玩。如何運行代碼的詳細信息,請看 README 文件。

最終結果將如下所示:

創建 API 請求

這個 App 需要 4 個 API 請求:

  1. GET /api/grocery-list - 列出所有項目

  2. POST /api/grocery-list - 創建新項目

  3. PUT /api/grocery-list/:id/done - 將 id 等於 :id 的項目標記爲已完成

  4. DELETE /api/grocery-list/:id - 刪除 id 等於 :id 的項目

下面的示例是調用 API 的最基本情況。這不是最佳示例,但我們將在進行過程中進行代碼重構,以便更好地理解所有概念。此外,我們不關注表示層,也就是組件的實際 JSX。它肯定可以改進,但這不是本文的重點。

1. 檢索所有項目

在組件的 useEffect 中添加第一個調用是一個很好的方法,並把 refresh 狀態作爲參數,這樣每當這個狀態發生變化時,我們會重新調用 api 獲取項目:

// src/App.jsx

const App = () ={
  const [items, setItems] = useState([]);
  const [refresh, setRefresh] = useState(false);

  useEffect(() ={
    fetch("/api/grocery-list")
      .then((data) => data.json())
      .then((data) ={
        setItems(data.items);
      });
  }[refresh]);

  return (
    <ul>
      {items.map((item) =(
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
};

2. 創建新項目

當用戶輸入項目標題並點擊 “添加” 按鈕時, App 應調用一個 API 請求來創建新項目,然後重新獲取所有項目:

// src/App.jsx

const App = () ={
  // ...
  const [title, setTitle] = useState("");

  const handleAdd = (event) ={
    event

.preventDefault();

    fetch("/api/grocery-list"{
      method: "POST",
      body: JSON.stringify({ title }),
    }).then(() ={
      setTitle(""); // 清空標題輸入
      setRefresh(!refresh); // 強制重新獲取以更新列表
    });
  };

  return (
    // ...

    <form onSubmit={handleAdd}>
      <input
        required
        type="text"
        onChange={(event) => setTitle(event.target.value)}
        value={title}
      />
      <button type="submit">Add</button>
    </form>

    // ...
  );
};

3. 標記項目爲已完成

當用戶點擊複選框以將項目標記爲已完成時, App 應調度一個 PUT 請求,將 item.id 作爲請求的參數傳遞。如果項目已標記爲已完成,則不需要進行請求。

這和創建新項目非常相似,只是請求方法不同:

// src/App.jsx

const App = () ={
  // ...

  const handleMarkAsDone = (item) ={
    if (item.isDone) {
      return;
    }

    fetch(`/api/grocery-list/${item.id}/done`{
      method: "PUT",
    }).then(() ={
      setRefresh(!refresh); // 強制重新獲取以更新列表
    });
  };

  return (
    // ...

    <ul>
      {items.map((item) =(
        <li key={item.id}>
          <label>
            {/* 複選框以將項目標記爲已完成 */}
            <input
              type="checkbox"
              checked={item.isDone}
              onChange={() => handleMarkAsDone(item)}
            />
            {item.title}
          </label>
        </li>
      ))}
    </ul>

    // ...
  );
};

4. 移除項目

這和把項目標記爲已完成的操作很像,只是用了 DELETE 方法。當用戶點擊 “刪除” 按鈕時, App 應調用一個函數來分派 API 調用:

// src/App.jsx

const App = () ={
  // ...

  const handleDelete = (item) ={
    fetch(`/api/grocery-list/${item.id}`{
      method: 'DELETE',
    }).then(() ={
      setRefresh(!refresh) // 強制重新獲取以更新列表
    })
  }

  return (
    // ...

    <ul>
      {items.map((item) =(
        <li key={item.id}>
          <label>
            {/* 複選框以將項目標記爲已完成 */}
            <input type="checkbox" onChange={() => handleMarkAsDone(item)} />
            {item.title}
          </label>

          {/* 刪除按鈕 */}
          <button onClick={() => handleDelete(item)}>Delete</button>
        </li>
      ))}
    </ul>

    // ...
  )
}

第一部分示例的最終代碼

第一部分的最終代碼如下所示:

// src/App.jsx

const App = () ={
  const [items, setItems] = useState([])
  const [title, setTitle] = useState('')
  const [refresh, setRefresh] = useState(false)

  // 獲取所有項目
  useEffect(() ={
    fetch('/api/grocery-list')
      .then((data) => data.json())
      .then(({ items }) => setItems(items))
  }[refresh])

  // 添加新項目
  const handleAdd = (event) ={
    event.preventDefault()

    fetch('/api/grocery-list'{
      method: 'POST',
      body: JSON.stringify({ title }),
    }).then(() ={
      setRefresh(!refresh)
      setTitle('')
    })
  }

  // 標記項目爲已完成
  const handleMarkAsDone = (item) ={
    if (item.isDone) {
      return
    }

    fetch(`/api/grocery-list/${item.id}/done`{
      method: 'PUT',
    }).then(() ={
      setRefresh(!refresh)
    })
  }

  // 刪除項目
  const handleDelete = (item) ={
    fetch(`/api/grocery-list/${item.id}`{
      method: 'DELETE',
    }).then(() ={
      setRefresh(!refresh)
    })
  }

  return (
    <>
      <form onSubmit={handleAdd}>
        <input required type="text" onChange={(event) => setTitle(event.target.value)} value={title} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {items.map((item) =(
          <li key={item.id}>
            <label>
              <input type="checkbox" checked={item.isDone} onChange={() => handleMarkAsDone(item)} />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)}>delete</button>
          </li>
        ))}
      </ul>
    </>
  )
}

第二次重構:抽象化 HTTP 調用

grocery-list服務在很大程度上依賴於 Fetch 庫。如果我們決定把它改爲 Axios,所有的調用都應該進行更改。此外,服務層不需要知道如何調用 API,而只需要知道應該調用哪個 API。

爲了避免混合這些責任,我喜歡創建一個 API 適配器。實際上,名稱並不重要 - 這裏的目標是有一個單一的地方來配置 API 的 HTTP 調用。

// src/adapters/api.js

const basePath = '/api'

const api = {
  get: (endpoint) => fetch(`${basePath}/${endpoint}`),
  post: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`{
      method: 'POST',
      body: body && JSON.stringify(body),
    }),
  put: (endpoint, body) =>
    fetch(`${basePath}/${endpoint}`{
      method: 'PUT',
      body: body && JSON.stringify(body),
    }),
  delete: (endpoint) =>
    fetch(`${basePath}/${endpoint}`{
      method: 'DELETE',
    }),
}

export { api }

這是整個 App 中唯一處理 HTTP 調用的文件。其他需要調用 API 的文件只需要調用這些方法。

現在,如果你決定用 Axios 替換 Fetch,只需更改這個單一的文件就可以了。

在測試方面,現在可以單獨測試每個 API 方法,而不依賴於服務調用。

說到服務,讓我們用新的api.方法替換舊的fetch調用。

// src/services/grocery-list

import { api } from '../adapters/api'

const resource = 'grocery-list'

export const getItems = () => api.get(resource).then((data) => data.json())

export const createItem = (title) => api.post(resource, { title })

export const markItemAsDone = (itemId) => api.put(`${resource}/${itemId}/done`)

export const deleteItem = (itemId) => api.delete(`${resource}/${itemId}`)

哇,清爽多了!發現了嗎?有些請求級別的責任沒了。,比如將 JSON 對象轉換爲字符串。這不是服務的責任,現在是 API 層在做這個事情。

代碼變得更加可讀和可測試。

第三次重構:創建 Hooks

我們已經完成了服務和 API 層,現在讓我們改進表示層,也就是 UI 組件。

目前,組件直接調用服務。但是把請求和保存狀態抽象爲 hook 會更好。

我們要創建的第一個 hook 是useGetGroceryListItems(),它包含了getItems()的 API 調用。

// src/hooks/grocery-list.js

// 默認模塊導入
import * as groceryListService from "../services/grocery-list";

export const useGetGroceryListItems = () ={
  const [items, setItems] = useState([]);
  const [

refresh, setRefresh] = useState(false);

  useEffect(() ={
    groceryListService.getItems().then(({ items }) ={
      setItems(items);
    });
  }[refresh]);

  const refreshItems = () ={
    setRefresh(!refresh);
  };

  return { items, refreshItems };
};

發現了嗎,我們基本上把之前在組件中的行爲複製到了新的 hook 中。我們還需要創建refreshItems(),這樣我們可以在需要時保持數據更新,而不是直接再次調用服務。

我們還導入了服務模塊來調用groceryListService.getItems(),而不僅僅是調用getItems()。這是因爲我們的 hooks 將具有類似的函數名稱,爲了避免衝突並提高可讀性,整個服務模塊都被導入。

現在我們給其他功能(創建、更新和刪除)創建剩下的 hooks。

// src/hooks/grocery-list.js

export const useCreateGroceryListItem = () ={
  const createItem = (title) => groceryListService.createItem(title);

  return { createItem };
};

export const useMarkGroceryListItemAsDone = () ={
  const markItemAsDone = (item) ={
    if (item.isDone) {
      return;
    }
    groceryListService.markItemAsDone(item.id);
  };

  return { markItemAsDone };
};

export const useDeleteGroceryListItem = () ={
  const deleteItem = (item) => groceryListService.deleteItem(item.id);

  return { deleteItem };
};

然後我們需要在組件中用 hooks 替換服務調用。

// src/App.jsx

// 引入hooks
import {
  useGetGroceryListItems,
  useCreateGroceryListItem,
  useMarkGroceryListItemAsDone,
  useDeleteGroceryListItem,
} from "./hooks/grocery-list";

const App = () ={
  // ...
  const { items, refreshItems } = useGetGroceryListItems();
  const { createItem } = useCreateGroceryListItem();
  const { markItemAsDone } = useMarkGroceryListItemAsDone();
  const { deleteItem } = useDeleteGroceryListItem();

  // ...

  const handleMarkAsDone = (item) ={
    // 驗證移到hook中,並傳遞`item`而不是`item.id`
    markItemAsDone(item).then(() => refreshItems());
  };

  const handleDelete = (item) ={
    // 傳遞`item`而不是`item.id`
    deleteItem(item).then(() => refreshItems());
  };

  // ...
};

就是這樣。現在 App 就直接調用 hooks,便於複用。

如果你用的是像 Redux、Context API 或 Zustand 等狀態管理解決方案,你可以在 hooks 內部進行狀態修改,而不是在組件級別調用它們。這樣會讓代碼更清晰,責任分工明確。

最後一次重構:添加加載狀態

我們的 App 正常工作,但在 API 請求和響應的等待期間,沒有向用戶提供任何反饋。解決這個問題的其中一種方法是爲每個 hook 添加加載狀態,以傳遞實際的 API 請求狀態。

在爲每個 hook 添加加載狀態之後,文件將如下所示:

// src/hooks/grocery-list.js

export const useGetGroceryListItems = () ={
  const [isLoading, setIsLoading] = useState(false) // 創建加載狀態
  const [items, setItems] = useState([])
  const [refresh, setRefresh] = useState(false)

  useEffect(() ={
    setIsLoading(true) // 添加加載狀態
    groceryListService.getItems().then(({ items }) ={
      setItems(items)
      setIsLoading(false) // 移除加載狀態
    })
  }[refresh])

  const refreshItems = () ={
    setRefresh(!refresh)
  }

  return { items, refreshItems, isLoading }
}

export const useCreateGroceryListItem = () ={
  const [isLoading, setIsLoading] = useState(false) // 創建加載狀態

  const createItem = (title) ={
    setIsLoading(true) // 添加加載狀態
    return groceryListService.createItem(title).then(() ={
      setIsLoading(false) // 移除加載狀態
    })
  }

  return { createItem, isLoading }
}

export const useMarkGroceryListItemAsDone = () ={
  const [isLoading, setIsLoading] = useState(false) // 創建加載狀態

  const markItemAsDone = (item) ={
    if (item.isDone) {
      return
    }

    setIsLoading(true) // 添加加載狀態
    return groceryListService.markItemAsDone(item.id).then(() ={
      setIsLoading(false) // 移除加載狀態
    })
  }

  return { markItemAsDone, isLoading }
}

export const useDeleteGroceryListItem = () ={
  const [isLoading, setIsLoading] = useState(false) // 創建加載狀態

  const deleteItem = (item) ={
    setIsLoading(true) // 添加加載狀態
    return groceryListService.deleteItem(item.id).then(() ={
      setIsLoading(false) // 移除加載狀態
    })
  }

  return { deleteItem, isLoading }
}

現在,我們需要把頁面的加載狀態和每個 hook 連接起來:

// src/App.jsx

const App = () ={
  // ...

  // 獲取加載狀態並重命名來避免衝突
  const { items, refreshItems, isLoading: isFetchingItems } = useGetGroceryListItems();
  const { createItem, isLoading: isCreatingItem } = useCreateGroceryListItem();
  const { markItemAsDone, isLoading: isUpdatingItem } = useMarkGroceryListItemAsDone();
  const { deleteItem, isLoading: isDeletingItem } = useDeleteGroceryListItem();

  // 讀取每個加載狀態並將其轉換爲組件級別的值
  const isLoading = isFetchingItems || isCreatingItem || isUpdatingItem || isDeletingItem;

  // ...

  return (
    <>
      <form onSubmit={handleAdd}>
        <input
          required
          type="text"
          onChange={(event) => setTitle(event.target.value)}
          value={title}
          disabled={isLoading} {/* 加載狀態 */}
        />
        <button type="submit" disabled={isLoading}{/* 加載狀態 */}
          Add
        </button>
      </form>
      <ul>
        {items.map((item) =(
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.isDone}
                onChange={() => handleMarkAsDone(item)}
                disabled={isLoading} {/* 加載狀態 */}


 />
              {item.title}
            </label>
            <button onClick={() => handleDelete(item)} disabled={isLoading}{/* 加載狀態 */}
              delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};

額外改進:創建一個工具函數

注意到在useMarkGroceryListItemAsDone() hook 中有一個邏輯判斷項是否應該更新:

// src/hooks/grocery-list.js

const markItemAsDone = (item) ={
  if (item.isDone) {
    return; // 不調用服務
  }

  // 調用服務並更新項

這個邏輯判斷不適合放在這裏,因爲它可能在其他地方需要,導致重複,並且這是 App 的業務邏輯,而不是僅僅是這個 hook 的特定邏輯。

一個可能的解決方案是創建一個實用工具並將這個邏輯添加到其中,這樣我們只需在 hook 中調用該函數:

// src/utils/grocery-list.js

export const shouldUpdateItem = (item) => !item.isDone

然後在 hook 中調用這個工具函數:

export const useMarkGroceryListItemAsDone = () ={
  // ...

  const markItemAsDone = (item) ={
    // 調用工具函數
    if (!shouldUpdateItem(item)) {
      return;
    }

    // ...

現在,hook 不依賴於與業務相關的任何邏輯:它們只調用函數並返回值。

總結

我們所做的所有重構都是爲了提高代碼的質量,並使其對人類更易讀。代碼一開始就是可行的,但它不具有可擴展性,也不能進行測試。這些都是一個優秀代碼庫的非常重要的特徵。

基本上,我們將單一職責原則 [6] 應用於代碼,讓它變得更好。該代碼可以作爲構建其他服務、連接外部 API、創建其他組件等的基礎。

如前所述,你還可以在這裏插入狀態管理解決方案,並在我們創建的 hooks 中管理 App 的全局狀態。

爲了進一步改進代碼,推薦使用 React Query[7],來利用其緩存、重新獲取和自動無效化等功能。

希望你今天學到了一些新東西,讓你的編碼之旅更加美好!

參考:https://dev.to/wolfflucas/the-definitive-guide-to-make-api-calls-in-react-2c1i

參考資料

[1]

單一職責: https://www.wolff.fun/srp-in-react/

[2]

TailwindCSS: https://tailwindcss.com/

[3]

Mirage JS: https://miragejs.com/

[4]

Fetch: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch

[5]

GitHub: https://github.com/wolfflucas/definitive-guide-react-apis

[6]

單一職責原則: https://www.wolff.fun/srp-in-react/

[7]

React Query: https://www.wolff.fun/react-query-server-state-alternative/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NIhmLYTlktHC0DKEgz6n2w