0

# Bài 21 — Tìm kiếm & phân trang

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

Ô tìm kiếm trên navbar của source gốc không hoạt động — nó chỉ là một <input> không có onChange, không có onSubmit. Bài này làm cho nó chạy thật:

  • API tìm kiếm với regex và text index
  • API phân trang với skip / limit
  • Debounce ô tìm kiếm để không gọi API mỗi ký tự
  • Trang /search với kết quả và phân trang
  • Đồng bộ trạng thái với URL để chia sẻ link được

📚 1. Vì sao cần chuyển lọc sang server?

Bài 15 lọc và sắp xếp ở client — tải hết 12 sản phẩm rồi lọc bằng JavaScript.

Với dữ liệu thật:

Số sản phẩm Client-side Server-side
12 30 KB, tức thì Thừa thãi
1.000 2 MB, hơi chậm 25 KB/trang
100.000 200 MB, trình duyệt treo 25 KB/trang

Ngoài hiệu năng, server-side còn cho phép:

  • Chia sẻ link kết quả (?q=gucci&page=2)
  • Google index được từng trang
  • F5 không mất trạng thái

💻 2. Backend: API tìm kiếm + phân trang

Thay thế route GET /products trong backend/routes/products.js:

// ============================================
// GET /products
// Query params:
//   q         — từ khóa tìm kiếm
//   category  — lọc theo danh mục (ObjectId)
//   minPrice  — giá tối thiểu
//   maxPrice  — giá tối đa
//   sort      — price_asc | price_desc | rating | newest
//   page      — trang hiện tại (mặc định 1)
//   limit     — số sản phẩm/trang (mặc định 12, tối đa 100)
// ============================================
router.get("/", async (req, res, next) => {
  try {
    const db = await connectDb();
    const products = db.collection("products");

    // ===== 1. Xây bộ lọc =====
    const filter = {};

    if (req.query.q && req.query.q.trim()) {
      // escape ký tự đặc biệt của regex để tránh ReDoS và lỗi cú pháp
      const keyword = req.query.q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      filter.$or = [
        { name: { $regex: keyword, $options: "i" } },
        { description: { $regex: keyword, $options: "i" } },
      ];
    }

    if (req.query.category && ObjectId.isValid(req.query.category)) {
      filter.categoryId = new ObjectId(req.query.category);
    }

    const minPrice = Number(req.query.minPrice);
    const maxPrice = Number(req.query.maxPrice);
    if (!isNaN(minPrice) || !isNaN(maxPrice)) {
      filter.price = {};
      if (!isNaN(minPrice)) filter.price.$gte = minPrice;
      if (!isNaN(maxPrice)) filter.price.$lte = maxPrice;
    }

    // ===== 2. Sắp xếp =====
    const sortMap = {
      price_asc:  { price: 1 },
      price_desc: { price: -1 },
      rating:     { rating: -1 },
      newest:     { _id: -1 },
    };
    const sort = sortMap[req.query.sort] || { _id: 1 };

    // ===== 3. Phân trang =====
    const page = Math.max(1, parseInt(req.query.page, 10) || 1);
    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 12));
    const skip = (page - 1) * limit;

    // ===== 4. Truy vấn song song: dữ liệu + tổng số =====
    const [items, total, categories] = await Promise.all([
      products.find(filter).sort(sort).skip(skip).limit(limit).toArray(),
      products.countDocuments(filter),
      db.collection("categories").find().toArray(),
    ]);

    // ===== 5. Ghép danh mục =====
    const data = items.map((item) => ({
      ...item,
      category: categories.find((c) => c._id.equals(item.categoryId)) || null,
    }));

    res.status(200).json({
      data,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
        hasNext: page * limit < total,
        hasPrev: page > 1,
      },
    });
  } catch (error) {
    next(error);
  }
});

⚠️ Thay đổi phá vỡ tương thích (breaking change)

API cũ trả về mảng:

[ { "_id": "...", "name": "..." } ]

API mới trả về object có data và pagination:

{
  "data": [ { "_id": "...", "name": "..." } ],
  "pagination": { "page": 1, "total": 12, "totalPages": 1 }
}

→ Mọi component đang dùng data.map(...) sẽ crash với data.map is not a function.

Hai cách xử lý:

a) Sửa frontend — đổi data thành data.data:

const { data } = useSWR(`${API}/products`, fetcher);
<Productlist data={data?.data ?? []} />

b) Giữ tương thích ngược — chỉ trả object khi có query param:

const hasQuery = Object.keys(req.query).length > 0;
if (!hasQuery) {
  return res.status(200).json(data);   // mảng như cũ
}
res.status(200).json({ data, pagination });

Tài liệu chọn cách (a) — rõ ràng hơn về lâu dài. Nhớ sửa cả page.js, menu/page.js, admin/product/page.js, admin/page.js.

🔍 Escape regex — chi tiết bảo mật quan trọng

const keyword = req.query.q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

Nếu không escape, người dùng gõ (a+)+$ sẽ tạo ra một regex có độ phức tạp hàm mũ (ReDoS — Regular expression Denial of Service). MongoDB sẽ treo CPU và server ngừng phục vụ.

replace biến mọi ký tự đặc biệt thành ký tự thường: . → \., * → \*...

⚠️ Route /search/:keyword trong routes/index.js gốc không escape:

const products = await productCollection
  .find({ name: new RegExp(req.params.keyword, 'i') }).toArray();

May mà file này không được mount nên chưa gây hại (bài 04).


📚 3. Text index — tìm kiếm nhanh hơn regex

$regex phải quét toàn bộ collection. Với dữ liệu lớn, dùng text index:

// Chạy 1 lần trong mongosh
use lith_perfume
db.products.createIndex(
  { name: "text", description: "text" },
  { default_language: "none" }        // "none" để hỗ trợ tiếng Việt tốt hơn
)

Rồi truy vấn:

filter.$text = { $search: keyword };
$regex $text
Tốc độ Chậm (quét toàn bộ) Nhanh (dùng index)
Tìm giữa từ ✅ "uci" khớp "Gucci" ❌ chỉ khớp từ hoàn chỉnh
Nhiều từ khóa Phải tự ghép ✅ tự động
Xếp hạng liên quan ❌ ✅ { score: { $meta: "textScore" } }
Tiếng Việt có dấu ✅ tốt ⚠️ cần cấu hình

Với dự án nhỏ, $regex đủ dùng và linh hoạt hơn. Tài liệu dùng $regex.

Thêm index cho các trường hay lọc

db.products.createIndex({ categoryId: 1 })
db.products.createIndex({ price: 1 })
db.products.createIndex({ rating: -1 })

💻 4. Frontend: ô tìm kiếm có debounce

Vấn đề

Gõ "gucci" = 5 ký tự = 5 request API. Với 100 người dùng đang gõ, server nhận hàng trăm request/giây trong khi chỉ request cuối là có ý nghĩa.

Debounce = chờ người dùng ngừng gõ 400ms rồi mới gọi API.

Hook useDebounce

Tạo frontend/lib/useDebounce.js:

"use client"
import { useEffect, useState } from "react";

export function useDebounce(value, delay = 400) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);   // hủy timer cũ khi value đổi
  }, [value, delay]);

  return debounced;
}

🔍 Cách nó hoạt động

Gõ "g"     → timer A đặt (400ms)
Gõ "gu"    → cleanup hủy timer A, timer B đặt
Gõ "guc"   → cleanup hủy timer B, timer C đặt
Gõ "gucc"  → cleanup hủy timer C, timer D đặt
Gõ "gucci" → cleanup hủy timer D, timer E đặt
(ngừng gõ 400ms) → timer E chạy → setDebounced("gucci")

Chỉ 1 lần cập nhật thay vì 5. Chìa khóa là hàm dọn dẹp return () => clearTimeout(timer) — React gọi nó trước mỗi lần effect chạy lại.

Cập nhật Navbar

"use client"
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";

export default function Navbar() {
  const pathname = usePathname();
  const router = useRouter();
  const cart = useSelector((state) => state.cart);
  const [keyword, setKeyword] = useState("");

  const totalitem = useMemo(
    () => cart.reduce((total, item) => total + item.quantity, 0),
    [cart]
  );

  const handleSearch = (e) => {
    e.preventDefault();
    if (!keyword.trim()) return;
    router.push(`/search?q=${encodeURIComponent(keyword.trim())}`);
  };

  return (
    <nav className="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
      <div className="container-fluid">
        {/* ... brand, toggler, menu trái như bài 11 ... */}

        <form className="d-flex" role="search" onSubmit={handleSearch}>
          <input
            className="form-control"
            type="search"
            placeholder="Tìm kiếm sản phẩm..."
            aria-label="Search"
            value={keyword}
            onChange={(e) => setKeyword(e.target.value)}
          />
          <button className="btn btn-outline-light ms-2" type="submit">
            <i className="fa-solid fa-magnifying-glass"></i>
          </button>
        </form>

        {/* ... menu phải như bài 11 ... */}
      </div>
    </nav>
  );
}

encodeURIComponent mã hóa ký tự đặc biệt để URL hợp lệ: "nước hoa" → "n%C6%B0%E1%BB%9Bc%20hoa".


💻 5. Trang kết quả tìm kiếm

Tạo frontend/app/(user)/search/page.js:

"use client"
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense, useState, useEffect } from "react";
import useSWR from "swr";
import Product from "../component/product";
import Pagination from "../component/pagination";
import { useDebounce } from "@/lib/useDebounce";
import { fetcher, API } from "@/lib/fetcher";

function SearchContent() {
  const searchParams = useSearchParams();
  const router = useRouter();

  const q = searchParams.get("q") ?? "";
  const page = Number(searchParams.get("page")) || 1;
  const sort = searchParams.get("sort") ?? "";

  const [keyword, setKeyword] = useState(q);
  const debouncedKeyword = useDebounce(keyword, 400);

  // Khi từ khóa đã debounce thay đổi → cập nhật URL
  useEffect(() => {
    if (debouncedKeyword === q) return;
    const params = new URLSearchParams();
    if (debouncedKeyword) params.set("q", debouncedKeyword);
    if (sort) params.set("sort", sort);
    router.replace(`/search?${params.toString()}`);
  }, [debouncedKeyword, q, sort, router]);

  const url = `${API}/products?q=${encodeURIComponent(q)}&page=${page}&limit=12${
    sort ? `&sort=${sort}` : ""
  }`;
  const { data, error, isLoading } = useSWR(url, fetcher, {
    keepPreviousData: true,      // giữ kết quả cũ khi đang tải trang mới
  });

  const changeParam = (key, value) => {
    const params = new URLSearchParams(searchParams.toString());
    if (value) params.set(key, value);
    else params.delete(key);
    if (key !== "page") params.delete("page");   // đổi bộ lọc thì về trang 1
    router.push(`/search?${params.toString()}`);
  };

  return (
    <div className="container mt-5">
      <h2>Tìm kiếm sản phẩm</h2>

      <div className="row g-2 my-4">
        <div className="col-md-8">
          <input
            className="form-control form-control-lg"
            placeholder="Nhập tên sản phẩm..."
            value={keyword}
            onChange={(e) => setKeyword(e.target.value)}
          />
        </div>
        <div className="col-md-4">
          <select
            className="form-select form-select-lg"
            value={sort}
            onChange={(e) => changeParam("sort", e.target.value)}
          >
            <option value="">Mặc định</option>
            <option value="price_asc">Giá tăng dần</option>
            <option value="price_desc">Giá giảm dần</option>
            <option value="rating">Đánh giá cao nhất</option>
            <option value="newest">Mới nhất</option>
          </select>
        </div>
      </div>

      {error && <div className="alert alert-danger">Lỗi khi tải dữ liệu</div>}
      {isLoading && !data && <p>Đang tìm kiếm...</p>}

      {data && (
        <>
          <p className="text-muted">
            {data.pagination.total === 0
              ? `Không tìm thấy kết quả nào cho "${q}"`
              : `Tìm thấy ${data.pagination.total} sản phẩm${q ? ` cho "${q}"` : ""}`}
          </p>

          <div className="row row-cols-lg-4 row-cols-md-3 row-cols-sm-2 row-cols-1 g-3">
            {data.data.map((item) => (
              <div key={item._id} className="col">
                <Product data={item} />
              </div>
            ))}
          </div>

          <Pagination
            pagination={data.pagination}
            onChange={(p) => changeParam("page", p)}
          />
        </>
      )}
    </div>
  );
}

export default function SearchPage() {
  // useSearchParams cần bọc trong Suspense ở App Router
  return (
    <Suspense fallback={<div className="container mt-5">Đang tải...</div>}>
      <SearchContent />
    </Suspense>
  );
}

⚠️ useSearchParams phải bọc trong <Suspense>

Nếu không, Next.js báo lỗi khi build:

useSearchParams() should be wrapped in a suspense boundary at page "/search"

Vì useSearchParams khiến trang phải render động, Next.js cần một ranh giới Suspense để tách phần tĩnh và phần động.

keepPreviousData: true

Khi chuyển sang trang 2, SWR bình thường sẽ trả data = undefined trong lúc tải → màn hình trắng rồi mới hiện lại. Với keepPreviousData, kết quả trang 1 vẫn hiển thị cho tới khi trang 2 về. Trải nghiệm mượt hơn nhiều.


💻 6. Component phân trang

Tạo frontend/app/(user)/component/pagination.js:

"use client"

export default function Pagination({ pagination, onChange }) {
  const { page, totalPages, hasNext, hasPrev } = pagination;

  if (totalPages <= 1) return null;

  // Tính dải số trang hiển thị: tối đa 5 nút quanh trang hiện tại
  const start = Math.max(1, Math.min(page - 2, totalPages - 4));
  const end = Math.min(totalPages, start + 4);
  const pages = [];
  for (let i = start; i <= end; i++) pages.push(i);

  return (
    <nav className="mt-4" aria-label="Phân trang">
      <ul className="pagination justify-content-center">
        <li className={`page-item ${!hasPrev ? "disabled" : ""}`}>
          <button className="page-link" onClick={() => onChange(page - 1)} disabled={!hasPrev}>
            &laquo; Trước
          </button>
        </li>

        {start > 1 && (
          <>
            <li className="page-item">
              <button className="page-link" onClick={() => onChange(1)}>1</button>
            </li>
            {start > 2 && <li className="page-item disabled"><span className="page-link">…</span></li>}
          </>
        )}

        {pages.map((p) => (
          <li key={p} className={`page-item ${p === page ? "active" : ""}`}>
            <button className="page-link" onClick={() => onChange(p)}>{p}</button>
          </li>
        ))}

        {end < totalPages && (
          <>
            {end < totalPages - 1 && <li className="page-item disabled"><span className="page-link">…</span></li>}
            <li className="page-item">
              <button className="page-link" onClick={() => onChange(totalPages)}>{totalPages}</button>
            </li>
          </>
        )}

        <li className={`page-item ${!hasNext ? "disabled" : ""}`}>
          <button className="page-link" onClick={() => onChange(page + 1)} disabled={!hasNext}>
            Sau &raquo;
          </button>
        </li>
      </ul>

      <p className="text-center text-muted small">
        Trang {page} / {totalPages}
      </p>
    </nav>
  );
}

📚 7. skip/limit và vấn đề với dữ liệu lớn

products.find(filter).skip((page - 1) * limit).limit(limit)

Cách này đơn giản nhưng có nhược điểm: để lấy trang 1000 (skip 12.000), MongoDB vẫn phải duyệt qua 12.000 document rồi bỏ đi.

Giải pháp cho dữ liệu rất lớn: cursor-based pagination

// Thay vì ?page=1000, dùng ?after=<id-cuối-của-trang-trước>
const filter = { _id: { $gt: new ObjectId(req.query.after) } };
products.find(filter).sort({ _id: 1 }).limit(limit)

Nhanh hơn nhiều vì dùng index trực tiếp, nhưng không nhảy được tới trang bất kỳ — chỉ có "trang tiếp theo". Phù hợp với dạng cuộn vô hạn (infinite scroll).

Với dự án này, skip/limit là quá đủ.


✅ 8. Kiểm thử

Test API bằng trình duyệt

URL Kiểm tra
localhost:5000/products { data: [...12 sp], pagination: { total: 12, totalPages: 1 } }
localhost:5000/products?limit=5 5 sản phẩm, totalPages: 3
localhost:5000/products?limit=5&page=3 2 sản phẩm cuối, hasNext: false
localhost:5000/products?q=gucci Chỉ sản phẩm có "gucci" trong tên/mô tả
localhost:5000/products?q=GUCCI Kết quả giống trên (không phân biệt hoa thường)
localhost:5000/products?sort=price_desc Giá giảm dần
localhost:5000/products?minPrice=4000000 Chỉ sản phẩm ≥ 4 triệu
localhost:5000/products?q=(a+)+$ Trả về [], không treo server ✅

Test debounce

  1. Mở DevTools → tab Network, lọc Fetch/XHR
  2. Vào /search, gõ gucci với tốc độ bình thường
  3. ✅ Chỉ có 1 request sau khi ngừng gõ
  4. ❌ Nếu thấy 5 request → debounce chưa hoạt động

Test phân trang

  1. Đổi limit=5 trong URL API để có nhiều trang
  2. Bấm số trang → URL đổi thành /search?page=2
  3. ✅ Bấm nút Back của trình duyệt → về trang 1
  4. ✅ Copy URL, mở tab mới → hiện đúng trang 2 với đúng từ khóa

Test tìm kiếm từ navbar

  1. Gõ "gucci" vào ô tìm kiếm trên navbar, Enter
  2. ✅ Chuyển sang /search?q=gucci và hiện kết quả

🆘 Xử lý sự cố

<details> <summary>data.map is not a function</summary>

API giờ trả về object thay vì mảng. Sửa mọi chỗ dùng:

<Productlist data={data} />          // ❌ cũ
<Productlist data={data?.data ?? []} />   // ✅ mới

Các file cần sửa: app/(user)/page.js, menu/page.js, menu/[id]/page.js, (admin)/admin/product/page.js, (admin)/admin/page.js.

</details> <details> <summary>useSearchParams() should be wrapped in a suspense boundary</summary>

Bọc component dùng useSearchParams trong <Suspense> như mục 5. Lỗi này chỉ xuất hiện khi chạy npm run build, không thấy khi npm run dev.

</details> <details> <summary>Tìm tiếng Việt có dấu không ra kết quả</summary>

Regex phân biệt dấu: "nuoc" không khớp "nước".

Giải pháp: thêm trường searchName không dấu vào mỗi sản phẩm:

function boDau(str) {
  return str.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/g, "d").replace(/Đ/g, "D");
}

Lưu searchName: boDau(name).toLowerCase() khi thêm/sửa sản phẩm, rồi tìm trên trường đó.

</details> <details> <summary>URL đổi liên tục, trình duyệt lag</summary>

Bạn dùng router.push trong useEffect với debounce — mỗi ký tự tạo một mục trong lịch sử duyệt web. Dùng router.replace thay vì push (như code mục 5).

</details>

📝 Bài tập

  1. Gợi ý tìm kiếm (autocomplete): hiện dropdown 5 sản phẩm khớp ngay dưới ô tìm kiếm khi gõ, dùng debounce 250ms.

  2. Cuộn vô hạn (infinite scroll) cho trang /menu bằng useSWRInfinite và IntersectionObserver.

  3. Chuyển /menu sang server-side filter: dùng API mới với ?category=...&minPrice=...&sort=... thay cho lọc trong Productlist. So sánh số byte tải về (tab Network) trước và sau.

  4. Lưu lịch sử tìm kiếm vào localStorage, hiện 5 từ khóa gần nhất.

  5. Tìm kiếm không dấu: cài đặt trường searchName như mục "Xử lý sự cố", viết script cập nhật cho 12 sản phẩm hiện có.

  6. Thử tấn công ReDoS: tạm bỏ dòng .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), rồi gọi ?q=(a+)+$. Quan sát CPU của tiến trình node. Sau đó thêm lại. (Chỉ thử trên máy cá nhân, không bao giờ thử trên server người khác.)


⬅️ Bài trước | Mục lục | Bài tiếp theo: Deploy ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí