企業級 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>
);
};
這樣可以工作,而且即使在業務級代碼庫中也很常見。但是存在一些問題:
-
數據存儲在本地狀態中
-
在其他組件中進行每個 API 調用都需要一個新的本地
useState
-
請求庫(Fetch)直接在組件中調用
-
如果你將庫更改爲 Axios,那麼每個組件都需要進行重構
-
如果域名發生變化,你需要在許多地方進行重構
-
在展示組件中進行了服務器級請求
-
組件旨在呈現數據,而不是處理獲取邏輯
-
對於每個組件、類和函數,擁有單一職責 [1] 是一種良好的實踐
-
不清楚請求會返回什麼
-
你依賴於請求名稱來知道 API 將返回什麼
解決這些問題有很多不同的方法,我的想法和方法是,創建一個可靠且可擴展的文件夾和文件結構,你甚至可以在像 Next.js 這樣的框架中應用它(或背後的思想)。
示例的場景
爲了理清楚所有這些概念,讓我們逐步構建一個購物清單 App 。該 App 將具有以下功能:
-
列出現有項目;
-
添加新項目;
-
刪除項目;
-
把項目標記爲已完成;
樣式方面,我會用 TailwindCSS[2]。爲了模擬 API 請求,我會用 Mirage JS[3],這是一個非常簡潔的 API 模擬庫。我們用 Fetch[4] 來調用 API。
所有示例代碼都在我的 GitHub[5] 上,你可以 clone 下來把玩。如何運行代碼的詳細信息,請看 README 文件。
最終結果將如下所示:
創建 API 請求
這個 App 需要 4 個 API 請求:
-
GET /api/grocery-list
- 列出所有項目 -
POST /api/grocery-list
- 創建新項目 -
PUT /api/grocery-list/:id/done
- 將 id 等於 :id 的項目標記爲已完成 -
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