Menu
CoddyTech

Phân trang

Thuộc phần REST API trong hành trình Node.js của Coddy. Bài học 17 trên 25.

Danh sách mười nghìn nhiệm vụ quá dài để trả lời một lần. Hãy gửi theo từng trang: client yêu cầu ?page=2&limit=20 và nhận được phần tương ứng, cùng với dữ liệu cần thiết để hiển thị "Page 2 of 5":

{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }
const start = (page - 1) * limit;    // Trang 1 bắt đầu từ 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);

Đếm total sau khi áp dụng bộ lọc, vì giá trị này mô tả danh sách mà client yêu cầu. Nếu chuỗi truy vấn không có page, hãy dùng 1 và dùng 10 cho limit trước khi chuyển đổi: Number(undefined) là NaN. Sau đó, từ chối mọi giá trị không phải số nguyên nằm trong phạm vi bằng mã trạng thái 400. Giới hạn tối đa cho limit sẽ ngăn client yêu cầu mọi thứ cùng lúc.

challenge icon

Thử thách

Dễ

Một danh sách gồm mười nghìn công việc quá dài để trả về trong một lần. Hãy gửi danh sách theo từng trang, kèm thông tin để ứng dụng khách hiển thị "Page 1 of 2".

  1. Trong routes/tasks.js, thay đổi giá trị mà tuyến danh sách trả về: trả về một đối tượng thay vì một mảng. Với GET /api/tasks?limit=2 trên danh sách công việc ban đầu, kết quả là:
    {
      "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 chứa các công việc trong trang này, page là số trang, total là số công việc khớp với bộ lọc, còn pages là tổng số trang.
  2. Đọc page và limit từ chuỗi truy vấn:
    • page là số nguyên từ 1 trở lên. Nếu không có giá trị này, hãy dùng 1.
    • limit là số nguyên từ 1 đến 50. Nếu không có giá trị này, hãy dùng 10.

    Dùng giá trị mặc định trước khi chuyển đổi, vì Number(undefined) là NaN. Mọi giá trị khác (page=0, page=abc, page=1.5, limit=0, limit=51) đều trả về trạng thái 400. Nếu limit không hợp lệ, thông báo sẽ là limit must be a whole number from 1 to 50:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Phân trang sau cùng: lọc và sắp xếp trước, rồi mới lấy phần tương ứng với trang. total đếm số công việc còn lại sau khi lọc, vì vậy ?done=false&limit=1 trả về "total": 2 và "pages": 2.

Điều này làm thay đổi cấu trúc phản hồi, vì vậy ứng dụng khách vốn mong đợi một mảng cũng cần thay đổi. Bài học về phiên bản hóa hướng dẫn cách xử lý việc này.

Thử nhé: GET /api/tasks?limit=2 trả về công việc 1 và 2, cùng với "page": 1, "total": 3 và "pages": 2. GET /api/tasks?page=2&limit=2 chỉ trả về công việc 3. GET /api/tasks thông thường trả về cả 3 công việc, với "page": 1 và "pages": 1. GET /api/tasks?page=0 trả về trạng thái 400 với mã invalid_query.

Tự làm thử

import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Toàn bộ tác vụ, đúng như PUT gửi đến. .strict() từ chối các trường không được liệt kê.
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 nếu một tác vụ khác đã có tiêu đề này, không phân biệt chữ hoa chữ thường.
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 iconTự kiểm tra

Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.

Tất cả bài học trong REST API

Tự luyện tập: Playground Express