تقسيم الصفحات
جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 17 من 25.
قائمة تضم عشرة آلاف مهمة طويلة جدًا بحيث لا يمكن إرسالها في إجابة واحدة. أرسلها على صفحات: يطلب العميل ?page=2&limit=20 ويحصل على ذلك الجزء، بالإضافة إلى ما يحتاج إليه لعرض «الصفحة 2 من 5»:
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // تبدأ الصفحة 1 عند 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);احسب total بعد تطبيق عوامل التصفية، لأنه يصف القائمة التي طلبها العميل. عندما لا يحتوي نص الاستعلام على page، استخدم 1، واستخدم 10 لـ limit، قبل التحويل: Number(undefined) هي NaN. ثم ارفض أي قيمة ليست عددًا صحيحًا ضمن النطاق، وأرجع الحالة 400. يحدّ الحد الأقصى لـ limit من قدرة العميل على طلب كل شيء دفعة واحدة.
التحدي
سهلقائمة تضم عشرة آلاف مهمة طويلة جدًا بحيث لا يمكن إرسالها في استجابة واحدة. أرسل القائمة على صفحات، ليتمكن العميل من عرض «الصفحة 1 من 2».
- في
routes/tasks.js، غيّر ما يعيده مسار القائمة: كائن بدلًا من مصفوفة. عند طلبGET /api/tasks?limit=2للمهام الأولية، تكون الاستجابة:يحتوي{ "items": [ { "id": 1, "title": "Write the project brief", "done": true, "createdAt": "2026-03-02T09:00:00.000Z" }, { "id": 2, "title": "Book the meeting room", "done": false, "createdAt": "2026-03-03T10:30:00.000Z" } ], "page": 1, "total": 3, "pages": 2 }itemsعلى مهام هذه الصفحة، ويمثلpageرقم الصفحة، ويمثلtotalعدد المهام المطابقة للمرشحات، ويمثلpagesعدد الصفحات. - اقرأ
pageوlimitمن سلسلة الاستعلام:- قيمة
pageعدد صحيح يبدأ من 1. إذا لم تُحدَّد، فاستخدم 1. - قيمة
limitعدد صحيح من 1 إلى 50. إذا لم تُحدَّد، فاستخدم 10.
استخدم القيمة الافتراضية قبل التحويل، لأن
Number(undefined)تساويNaN. أي قيمة أخرى (page=0،page=abc،page=1.5،limit=0،limit=51) تُرجع الحالة 400، وتكون الرسالةlimit must be a whole number from 1 to 50عند عدم صلاحية قيمة limit:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - قيمة
- نفّذ تقسيم الصفحات أخيرًا: رشّح ورتّب أولًا، ثم استخرج الصفحة. يحصي
totalالمهام المتبقية بعد تطبيق المرشحات، لذا فإن?done=false&limit=1يعطي"total": 2و"pages": 2.
يغيّر هذا شكل الاستجابة، لذا يجب أيضًا تعديل أي عميل يتوقع مصفوفة. يوضح درس الإصدارات كيفية التعامل مع ذلك.
جرّب ذلك: يُرجع GET /api/tasks?limit=2 المهمتين 1 و2 مع "page": 1 و"total": 3 و"pages": 2. يُرجع GET /api/tasks?page=2&limit=2 المهمة 3 فقط. يُرجع GET /api/tasks العادي المهام الثلاث كلها مع "page": 1 و"pages": 1. يُرجع GET /api/tasks?page=0 الحالة 400 مع الرمز invalid_query.
جرّب بنفسك
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// مهمة كاملة، كما يرسلها PUT. ترفض .strict() الحقول التي لا تدرجها.
const Task = z
.object({
title: z.string().trim().min(1).max(100),
done: z.boolean(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// تكون القيمة true إذا كانت هناك مهمة أخرى تحمل هذا العنوان بالفعل، بغض النظر عن حالة الأحرف.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q, sort } = req.query;
let list = tasks;
if (done !== undefined) {
if (done !== 'true' && done !== 'false') {
return sendError(res, 400, 'invalid_query', 'done must be true or false');
}
list = list.filter((t) => t.done === (done === 'true'));
}
if (q !== undefined) {
const words = String(q).toLowerCase();
list = list.filter((t) => t.title.toLowerCase().includes(words));
}
if (sort !== undefined) {
const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
if (!SORT_FIELDS.includes(field)) {
return sendError(
res,
400,
'invalid_query',
'sort must be title or createdAt, with - in front to reverse',
);
}
const direction = String(sort).startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
}
res.json(list);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
res.json(task);
});
router.post('/', (req, res) => {
const result = NewTask.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { title, done = false } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = TaskChanges.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
Object.assign(task, result.data);
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = Task.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { id, createdAt } = tasks[index];
if (titleTaken(result.data.title, id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
tasks[index] = { id, ...result.data, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.
جميع دروس واجهات REST API
1الموارد والأفعال
الموارد وعناوين URLالإنشاء باستخدام POST4المتصفحات والإصدارات والإعدادات
ما هو المصدرالسماح لمصدر آخرCORSصفحة تستدعي APIتدرّب بنفسك: Playground لـ Express