راهنمای قطعی برقراری تماس های API در React

درک نحوه برخورد با تماسهای API در برنامههای کاربردی وب یک مهارت بسیار مهم است. کتابخانههای مختلف زیادی وجود دارد که به شما در انجام این فرآیند کمک میکنند، اما گاهی اوقات برای مبتدیان چندان مناسب نیستند.
هنگام کار با وانیلی جاوا اسکریپت، احتمالاً از کتابخانه ای مانند Fetch یا Axios برای درخواست های API استفاده می کنید. در React نیز می توانید از آنها استفاده کنید، و چالش این است که چگونه کد را در اطراف این کتابخانه ها سازماندهی کنیم تا آن را تا حد امکان خوانا، توسعه پذیر و جدا کنیم.
این کار خیلی شهودی نیست. برای توسعه دهندگان جدیدی که با React شروع به کار می کنند بسیار معمول است که درخواست های API مانند این را ارائه دهند:
// ❌ Don't do this
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
- هر فراخوانی API در اجزای دیگر به یک محلی جدید نیاز دارد
-
کتابخانه درخواست (Fetch) مستقیماً در کامپوننت فراخوانی می شود
- برای مثال، اگر کتابخانه را به Axios تغییر دهید، هر مؤلفه باید دوباره ساخته شود
- همین امر در مورد نقطه پایانی نیز صدق میکند، اگر تغییر کند، باید آن را در بسیاری از مکانها تغییر دهید
-
یک درخواست در سطح سرور در یک مؤلفه نمایشی در حال انجام است
- کامپوننت ها برای ارائه داده ها در نظر گرفته شده اند، نه مدیریت منطق واکشی
- داشتن یک مسئولیت واحد برای هر جزء، کلاس و عملکرد، تمرین خوبی است
-
معلوم نیست چه درخواستی برمی گردد
- برای دانستن آنچه توسط API برگردانده می شود، به نام نقطه پایانی تکیه می کنید
راه های مختلفی برای حل این مشکلات وجود دارد. امروز ایدهها و رویکردهایم را برای ایجاد یک ساختار پوشه و فایل که قابل اعتماد و مقیاسپذیر باشد، به شما نشان خواهم داد، و میتوانید آن را – یا ایده پشت آن – را حتی در چارچوبهایی مانند Next.js اعمال کنید.
سناریوی مثال ما
برای درک و چسباندن همه مفاهیم، بیایید به تدریج یک برنامه لیست مواد غذایی بسازیم. این برنامه دارای ویژگی های زیر خواهد بود:
- فهرست موارد موجود؛
- مورد جدیدی اضافه کنید؛
- حذف مورد؛
- علامت گذاری مورد به عنوان انجام شده؛
برای استایل ها، از TailwindCSS استفاده خواهم کرد. برای شبیه سازی درخواست های API از Mirage JS استفاده خواهد شد که استفاده از آن بسیار آسان و مفید است. برای فراخوانی این API، از Fetch استفاده می کنیم.
همه نمونه ها در GitHub من هستند، بنابراین با خیال راحت مخزن را شبیه سازی کنید و با آن بازی کنید. جزئیات نحوه اجرای آن در فایل README توضیح داده شده است.
نتیجه نهایی به این صورت خواهد بود:
ایجاد نقاط پایانی API
این برنامه به 4 نقطه پایانی API نیاز دارد:
-
GET /api/grocery-list
– بازیابی همه موارد -
POST /api/grocery-list
– یک آیتم جدید ایجاد کنید -
PUT /api/grocery-list/:id/done
– مورد را با id برابر با :id به عنوان انجام شده علامت گذاری کنید -
DELETE /api/grocery-list/:id
– آیتم با شناسه برابر با :id را حذف می کند
مثال های زیر ابتدایی ترین مورد فراخوانی API ها هستند. این بهترین کد نیست، اما در حین حرکت، کد را اصلاح می کنیم، بنابراین همه مفاهیم را بهتر درک خواهید کرد. همچنین، ما روی لایه ارائه، یعنی JSX واقعی کامپوننت تمرکز نمی کنیم. مطمئناً می توان آن را بهبود بخشید، اما تمرکز این مقاله نیست.
1. بازیابی همه موارد
یک مکان خوب برای افزودن اولین تماس در این است useEffect
از جزء، و اضافه کنید refresh
حالت را به عنوان پارامتر تعیین کنید، بنابراین هر بار که این حالت تغییر می کند، موارد زیر را دوباره واکشی می کنیم:
// src/App.jsx
const App = () => {
const [items, setItems] = useState([]);
const [refetch, setRefetch] = 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. ایجاد یک آیتم جدید
هنگامی که کاربر عنوان مورد را وارد میکند و روی دکمه «افزودن» کلیک میکند، برنامه باید یک تماس با API برای ایجاد یک مورد جدید ارسال کند، سپس همه موارد را دوباره برای نمایش آیتم جدید واکشی کند:
// src/App.jsx
const App = () => {
// ...
const [title, setTitle] = useEffect("");
const handleAdd = (event) => {
event.preventDefault();
fetch("/api/grocery-list", {
method: "POST",
body: JSON.stringify({ title }),
}).then(() => {
setTitle(""); // Empty the title input
setRefresh(!refresh); // Force refetch to update the list
});
};
return (
// ...
<form onSubmit={handleAdd}>
<input
required
type="text"
onChange={(event) => setTitle(event.target.value)}
value={title}
/>
<button type="submit">Add</button>
</form>
// ...
);
};
3. علامت گذاری یک مورد به عنوان انجام شده
هنگامی که کاربر بر روی کادر انتخاب کلیک می کند تا مورد را به عنوان تمام شده علامت گذاری کند، برنامه باید یک درخواست 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); // Force refetch to update the list
});
};
return (
// ...
<ul>
{items.map((item) => (
<li key={item.id}>
<label>
{/* Checkbox to mark the item as done */}
<input
type="checkbox"
checked={item.isDone}
onChange={() => handleMarkAsDone(item)}
/>
{item.title}
</label>
</li>
))}
</ul>
// ...
);
};
4. حذف یک مورد
این تقریباً همان کاری است که در علامت گذاری یک مورد به عنوان انجام شده انجام دادیم، اما با روش DELETE. هنگام کلیک بر روی دکمه “حذف”، برنامه باید تابعی را فراخوانی کند که تماس API را ارسال می کند:
// src/App.jsx
const App = () => {
// ...
const handleDelete = (item) => {
fetch(`/api/grocery-list/${item.id}`, {
method: "DELETE",
}).then(() => {
setRefresh(!refresh); // Force refetch to update the list
});
};
return (
// ...
<ul>
{items.map((item) => (
<li key={item.id}>
<label>
{/* Checkbox to mark the item as done */}
<input type="checkbox" onChange={() => handleMarkAsDone(item)} />
{item.title}
</label>
{/* Delete button */}
<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);
// Retrieve all the items
useEffect(() => {
fetch("/api/grocery-list")
.then((data) => data.json())
.then(({ items }) => setItems(items));
}, [refresh]);
// Adds a new item
const handleAdd = (event) => {
event.preventDefault();
fetch("/api/grocery-list", {
method: "POST",
body: JSON.stringify({ title }),
}).then(() => {
setRefresh(!refresh);
setTitle("");
});
};
// Mark an item as done
const handleMarkAsDone = (item) => {
if (item.isDone) {
return;
}
fetch(`/api/grocery-list/${item.id}/done`, {
method: "PUT",
}).then(() => {
setRefresh(!refresh);
});
};
// Deletes an item
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>
</>
);
};
اولین Refactor: ایجاد خدمات
اکنون که همه چیز را در جای خود داریم و کار می کنیم، بیایید کد را بازسازی کنیم.
اولین کاری که میتوانیم برای بهتر کردن کد انجام دهیم، ایجاد یک سرویس برای تماسهای API است. خدمات اساساً توابع جاوا اسکریپت هستند که مسئول فراخوانی APIها هستند.
این مفید است زیرا اگر نیاز به تماس با API در مکان های دیگر دارید، به جای کپی پیست کل، فقط با سرویس تماس بگیرید. fetch
زنگ زدن.
// src/services/grocery-list.js
const basePath = "/api/grocery-list";
export const getItems = () => fetch(basePath).then((data) => data.json());
export const createItem = (title) =>
fetch(basePath, {
method: "POST",
body: JSON.stringify({ title }),
});
export const markItemAsDone = (itemId) =>
fetch(`${basePath}/${itemId}/done`, {
method: "PUT",
});
export const deleteItem = (itemId) =>
fetch(`${basePath}/${itemId}`, {
method: "DELETE",
});
توجه داشته باشید که سرویسها یک Promise را برمیگردانند و همه تماسهای دولتی حذف شدند. همچنین مسیر پایه تکراری نقاط انتهایی API را با یک ثابت جایگزین کردیم.
حالا بیایید قدیمی را جایگزین کنیم fetch
با سرویس های جدید کامپوننت را فراخوانی می کند:
// src/App.jsx
// Importing the services
import {
createItem,
deleteItem,
getItems,
markItemAsDone,
} from "./services/grocery-list";
const App = () => {
// ...
useEffect(() => {
// Service call
getItems().then(({ items }) => {
setItems(items);
});
}, [refresh]);
const handleAdd = (event) => {
event.preventDefault();
// Service call
createItem(title).then(() => {
setRefresh(!refresh);
setTitle("");
});
};
const handleMarkAsDone = (item) => {
if (item.isDone) {
return;
}
// Service call
markItemAsDone(item.id).then(() => {
setRefresh(!refresh);
});
};
const handleDelete = (item) => {
// Service call
deleteItem(item.id).then(() => {
setRefresh(!refresh);
});
};
// ...
};
این بسیار قابل خواندن و آزمایش تر است. شما میتوانید هر سرویس را بهجای آزمایش کامل مؤلفه بهصورت جداگانه آزمایش کنید. همچنین، درک اینکه کد قرار است چه کاری انجام دهد بسیار ساده تر است، به عنوان مثال:
// Get the items, then set the items.
getItems().then(({ items }) => {
setItems(items);
});
Refactor دوم: انتزاع تماس HTTP
این grocery-list
این سرویس به شدت به کتابخانه Fetch متکی است. اگر تصمیم بگیریم آن را به Axios تغییر دهیم، همه تماس ها باید تغییر کنند. همچنین، لایه سرویس نیازی به دانستن ندارد چگونه برای فراخوانی API، اما فقط که API باید فراخوانی شود.
برای جلوگیری از اختلاط این مسئولیت ها، من دوست دارم یک API Adapter ایجاد کنم. نام در واقع مهم نیست –هدف در اینجا داشتن یک مکان واحد است که در آن تماسهای HTTP API پیکربندی میشوند.
// 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 };
این تنها فایل در کل برنامه است که با تماس های HTTP سروکار دارد. سایر فایل هایی که نیاز به فراخوانی API دارند، فقط باید این متدها را فراخوانی کنند.
حالا اگر تصمیم دارید که Fetch را با Axios جایگزین کنید، فقط این تک فایل را تغییر دهید و آماده هستید.
در سمت تست، اکنون می توان هر روش API را به صورت جداگانه و بدون اتکا به تماس سرویس آزمایش کرد.
در مورد خدمات صحبت می کنیم، بیایید قدیمی را جایگزین کنیم fetch
تماس با جدید api.
آنهایی که
// 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 این کار را انجام می دهد.
باز هم، کد خوانا و آزمایش پذیرتر شده است.
Refactor سوم: ایجاد قلاب
ما سرویسها و لایههای API را داریم، حالا بیایید لایه ارائه، یعنی مؤلفه UI را بهبود ببخشیم.
مؤلفه ها در حال حاضر مستقیماً با خدمات تماس می گیرند. این به خوبی کار می کند، اما نگه داشتن وضعیت و فراخوانی سرویس بیشتر شبیه به یک ویژگی برنامه شما است به جای مسئولیت هر مؤلفه ای که نیاز به فراخوانی API دارد.
اولین قلابی که میخواهیم ایجاد کنیم قلاب است useGetGroceryListItems()
، که شامل getItems()
تماس API.
// src/hooks/grocery-list.js
// Default module import
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 };
};
توجه داشته باشید که ما اساساً رفتاری را که قبلاً روی مؤلفه بود در قلاب جدید کپی کردیم. ما همچنین نیاز به ایجاد refreshItems()
، بنابراین می توانیم به جای تماس مستقیم دوباره با سرویس، داده ها را در زمانی که می خواهیم به روز نگه داریم.
ما همچنین ماژول سرویس را وارد می کنیم تا از آن استفاده کنیم groceryListService.getItems()
، به جای اینکه فقط تماس بگیرید getItems()
. این به این دلیل است که قلابهای ما نامهای عملکرد مشابهی خواهند داشت، بنابراین برای جلوگیری از تداخل و همچنین بهبود خوانایی، کل ماژول سرویس وارد میشود.
حالا بیایید بقیه قلاب ها را برای سایر ویژگی ها (ایجاد، به روز رسانی و حذف) ایجاد کنیم.
// 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 };
};
سپس باید تماس های سرویس را با هوک های موجود در کامپوننت جایگزین کنیم.
// src/App.jsx
// Hooks import
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) => {
// Validation moved to hook and passing `item` instead of `item.id`
markItemAsDone(item).then(() => refreshItems());
};
const handleDelete = (item) => {
// Passing `item` instead of `item.id`
deleteItem(item).then(() => refreshItems());
};
// ...
};
و بس. اکنون برنامه از قلاب ها استفاده می کند که بسیار مفید است زیرا اگر به همین ویژگی در سایر اجزا نیاز دارید، فقط آن را فراخوانی می کنید.
اگر از یک راه حل مدیریت حالت مانند Redux، Context API یا Zustand استفاده می کنید، می توانید به جای فراخوانی در سطح کامپوننت، تغییرات حالت را در داخل هوک ها انجام دهید. این کمک می کند تا همه چیز واضح تر شود و به خوبی بین مسئولیت ها تقسیم شود.
آخرین Refactor: اضافه کردن حالت بارگذاری
برنامه ما به خوبی کار می کند، اما هیچ بازخوردی به کاربر در طول دوره انتظار درخواست و پاسخ API وجود ندارد. یک راه حل برای این، اضافه کردن یک حالت بارگذاری به هر هوک برای اطلاع از وضعیت درخواست API واقعی است.
پس از اضافه کردن حالت بارگذاری به هر هوک، فایل به شکل زیر در میآید:
// src/hooks/grocery-list.js
export const useGetGroceryListItems = () => {
const [isLoading, setIsLoading] = useState(false); // Creating loading state
const [items, setItems] = useState([]);
const [refresh, setRefresh] = useState(false);
useEffect(() => {
setIsLoading(true); // Adding loading state
groceryListService.getItems().then(({ items }) => {
setItems(items);
setIsLoading(false); // Removing loading state
});
}, [refresh]);
const refreshItems = () => {
setRefresh(!refresh);
};
return { items, refreshItems, isLoading };
};
export const useCreateGroceryListItem = () => {
const [isLoading, setIsLoading] = useState(false); // Creating loading state
const createItem = (title) => {
setIsLoading(true); // Adding loading state
return groceryListService.createItem(title).then(() => {
setIsLoading(false); // Removing loading state
});
};
return { createItem, isLoading };
};
export const useMarkGroceryListItemAsDone = () => {
const [isLoading, setIsLoading] = useState(false); // Creating loading state
const markItemAsDone = (item) => {
if (item.isDone) {
return;
}
setIsLoading(true); // Adding loading state
return groceryListService.markItemAsDone(item.id).then(() => {
setIsLoading(false); // Removing loading state
});
};
return { markItemAsDone, isLoading };
};
export const useDeleteGroceryListItem = () => {
const [isLoading, setIsLoading] = useState(false); // Creating loading state
const deleteItem = (item) => {
setIsLoading(true); // Adding loading state
return groceryListService.deleteItem(item.id).then(() => {
setIsLoading(false); // Removing loading state
});
};
return { deleteItem, isLoading };
};
اکنون باید وضعیت بارگذاری صفحه را به هر هوک متصل کنیم:
// src/App.jsx
const App = () => {
// ...
// Getting loading states and renaming to avoid conflicts
const { items, refreshItems, isLoading: isFetchingItems } = useGetGroceryListItems();
const { createItem, isLoading: isCreatingItem } = useCreateGroceryListItem();
const { markItemAsDone, isLoading: isUpdatingItem } = useMarkGroceryListItemAsDone();
const { deleteItem, isLoading: isDeletingItem } = useDeleteGroceryListItem();
// Read each loading state and convert them to a component-level value
const isLoading = isFetchingItems || isCreatingItem || isUpdatingItem || isDeletingItem;
// ...
return (
<>
<form onSubmit={handleAdd}>
<input
required
type="text"
onChange={(event) => setTitle(event.target.value)}
value={title}
disabled={isLoading} {/* Loading State */}
/>
<button type="submit" disabled={isLoading}> {/* Loading State */}
Add
</button>
</form>
<ul>
{items.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.isDone}
onChange={() => handleMarkAsDone(item)}
disabled={isLoading} {/* Loading State */}
/>
{item.title}
</label>
<button onClick={() => handleDelete(item)} disabled={isLoading}> {/* Loading State */}
delete
</button>
</li>
))}
</ul>
</>
);
};
Bonus Refactor: یک ابزار کاربردی ایجاد کنید
توجه کنید که در useMarkGroceryListItemAsDone()
hook ما منطقی داریم که می گوید آیا آیتم باید به روز شود یا نه:
// src/hooks/grocery-list.js
const markItemAsDone = (item) => {
if (item.isDone) {
return; // Don't call the service
}
// Call the service and update the item
این مکان ایده آلی برای این منطق نیست زیرا می تواند در جاهای دیگر مورد نیاز باشد و باعث تکرار آن شود و همچنین منطق تجاری برنامه است و نه صرفاً منطق خاص این قلاب.
یک راه حل ممکن این است که یک Util ایجاد کنید و این منطق را در آنجا اضافه کنید، بنابراین ما فقط تابع موجود در hook را فراخوانی می کنیم:
// src/utils/grocery-list.js
export const shouldUpdateItem = (item) => !item.isDone;
و سپس این ابزار را در قلاب صدا بزنید:
export const useMarkGroceryListItemAsDone = () => {
// ...
const markItemAsDone = (item) => {
// Calling the util
if (!shouldUpdateItem(item)) {
return;
}
// ...
اکنون هوک ها به هیچ منطق مرتبط با کسب و کار وابسته نیستند: آنها فقط توابع را فراخوانی می کنند و مقادیر آن را برمی گردانند.
بسته بندی
همه بازسازهایی که ما انجام دادیم در خدمت بهبود کیفیت کد هستند و آن را برای انسان خواناتر می کنند. کد در ابتدا کار می کرد، اما نه قابل توسعه بود و نه قابل آزمایش. اینها ویژگی های بسیار مهم یک پایگاه کد عالی هستند.
ما اساساً برای بهتر کردن کد، اصل مسئولیت تک را اعمال کردیم. این کد می تواند به عنوان پایه ای برای ساخت سرویس های دیگر، اتصال با API های خارجی، ایجاد اجزای دیگر و غیره استفاده شود.
همانطور که گفته شد، شما همچنین می توانید راه حل مدیریت ایالت خود را در اینجا وصل کنید و وضعیت جهانی برنامه را در قلاب هایی که ما ایجاد کرده ایم مدیریت کنید.
برای بهبود بیشتر کد، ایده خوبی است که با React Query کار کنید تا از ویژگیهای آن مانند کش کردن، واکشی مجدد و باطل کردن خودکار استفاده کنید.
خودشه! امیدوارم امروز چیز جدیدی یاد گرفته باشید تا سفر برنامه نویسی خود را حتی بهتر کنید!
اگر بازخورد یا پیشنهادی دارید، برای من ایمیل بفرستید
کد نویسی عالی!