Menu
CoddyTech

تقسيم الصفحات

جزء من قسم واجهات 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 من قدرة العميل على طلب كل شيء دفعة واحدة.

challenge icon

التحدي

سهل

قائمة تضم عشرة آلاف مهمة طويلة جدًا بحيث لا يمكن إرسالها في استجابة واحدة. أرسل القائمة على صفحات، ليتمكن العميل من عرض «الصفحة 1 من 2».

  1. في 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 عدد الصفحات.
  2. اقرأ 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" } }
  3. نفّذ تقسيم الصفحات أخيرًا: رشّح ورتّب أولًا، ثم استخرج الصفحة. يحصي 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;
quiz iconاختبر نفسك

يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.

جميع دروس واجهات REST API

تدرّب بنفسك: Playground لـ Express