التعامل مع الـ Pagination: كيف تجلب آلاف البيانات دون انهيار السكربت.


يا هلا بالشباب! اليوم بنتكلم عن مشكلة الكل يطيح فيها لما يتعامل مع البيانات الكبيرة: كيف تجلب عشرات الآلاف أو حتى الملايين من السجلات بدون ما ينهار السيرفر أو السكربت؟ الحل هو الـ Pagination.

ليش نحتاج Pagination أصلاً؟

تخيل إن عندك قاعدة بيانات فيها مليون عميل، وكل عميل له تفاصيله. لو حاولت تجيب كل بيانات المليون عميل في طلب واحد، وش بيصير؟

كارثة! استهلاك ذاكرة خرافي في السيرفر والعميل، بطء استجابة يخلي المستخدم ينام ويصحى، وضغط مهول على قاعدة البيانات ممكن يوقفها. غير كذا، شبكياً الموضوع بيكون بطيء جداً.

الـ Pagination هنا يجي كمنقذ. فكرته بسيطة: بدل ما تجيب الكل، جيب جزء صغير (صفحة) في كل مرة.

أنواع الـ Pagination والطرق الشائعة

1. Offset/Limit (الترقيم التقليدي للصفحات)

هذي الطريقة هي الأكثر شيوعاً وسهولة في التطبيق. تعتمد على بارامترين أساسيين:

  • LIMIT: يحدد كم سجل تبغى تجلب في الصفحة الواحدة.
  • OFFSET: يحدد كم سجل تبغى تتخطاه من البداية (يعني من أي سجل تبدأ الجلب).

مثال في SQL:

SELECT * FROM products
LIMIT 10 OFFSET 20;

هنا، أنا أقول لقاعدة البيانات: جيب لي 10 منتجات، بس ابدأ العد من بعد أول 20 منتج. يعني بجيب المنتجات من 21 إلى 30.

عيوبها: مع البيانات الكبيرة جداً (مئات الآلاف أو الملايين)، الـ OFFSET ممكن يكون بطيء جداً. ليش؟ لأن قاعدة البيانات لازالت تحتاج تعد السجلات اللي قبل الـ OFFSET عشان تعرف من وين تبدأ، وهذا يستهلك وقت وموارد.

2. Cursor-based Pagination (الترقيم المبني على المؤشر)

هذي الطريقة أفضل للأداء مع البيانات الكبيرة جداً، خصوصاً في تطبيقات الـ Infinite Scroll (التمرير اللانهائي). الفكرة هنا هي إنك ما تستخدم رقم صفحة أو OFFSET، بل تحدد 'آخر سجل' تم جلبه وتستخدمه كنقطة بداية للطلب التالي.

غالباً نستخدم عمود فريد ومفهرس مثل id أو created_at.

مثال في SQL:

SELECT * FROM products
WHERE id > [last_id]
ORDER BY id ASC
LIMIT 10;

هنا، أنا أقول لقاعدة البيانات: جيب لي 10 منتجات، بس اللي الـ id حقها أكبر من [last_id] اللي جبته في الطلب السابق، ورتبها تصاعدياً حسب الـ id.

مميزاتها: أسرع بكثير لأنها تستخدم الفهارس مباشرة، وما تحتاج تعد السجلات السابقة. مثالية للـ Infinite Scroll حيث المستخدم ما يهتم برقم الصفحة، بل بالبيانات التالية.

عيوبها: أصعب شوي في تطبيقها مع ترقيم الصفحات التقليدي (اللي فيه أزرار 1، 2، 3...). ما تقدر تنتقل لصفحة معينة مباشرة إلا إذا عرفت آخر id لتلك الصفحة.

كيف نطبقها في الباك إند (مثال Python/Node.js)

في العادة، الـ API يستقبل بارامترات من الفرونت إند. مثلاً، لـ Offset/Limit ممكن يستقبل page و limit، ولـ Cursor-based ممكن يستقبل last_id و limit.

مثال لـ Offset/Limit (Python Flask):

from flask import Flask, request, jsonify

app = Flask(__name__)

# بيانات وهمية
products_db = [{"id": i, "name": f"Product {i}"} for i in range(1, 101)]

@app.route('/api/products_offset')
def get_products_offset():
    page = int(request.args.get('page', 1))
    limit = int(request.args.get('limit', 10))

    if page < 1: page = 1
    if limit < 1: limit = 10

    offset = (page - 1) * limit

    paginated_products = products_db[offset : offset + limit]
    total_products = len(products_db)
    total_pages = (total_products + limit - 1) // limit

    return jsonify({
        "products": paginated_products,
        "page": page,
        "limit": limit,
        "total_products": total_products,
        "total_pages": total_pages
    })

if __name__ == '__main__':
    app.run(debug=True)

مثال لـ Cursor-based (Python Flask):

from flask import Flask, request, jsonify

app = Flask(__name__)

# بيانات وهمية (مهم تكون مرتبة حسب الـ id)
products_db = sorted([{"id": i, "name": f"Product {i}"} for i in range(1, 101)], key=lambda x: x['id'])

@app.route('/api/products_cursor')
def get_products_cursor():
    last_id = int(request.args.get('last_id', 0))
    limit = int(request.args.get('limit', 10))

    if limit < 1: limit = 10

    # فلترة المنتجات اللي الـ id حقها أكبر من last_id
    filtered_products = [p for p in products_db if p['id'] > last_id]

    # جلب العدد المطلوب فقط
    paginated_products = filtered_products[:limit]

    # تحديد الـ last_id الجديد للطلب القادم
    next_last_id = paginated_products[-1]['id'] if paginated_products else last_id

    return jsonify({
        "products": paginated_products,
        "last_id": next_last_id,
        "limit": limit,
        "has_more": len(filtered_products) > limit # هل يوجد المزيد من البيانات بعد هذه الصفحة؟
    })

if __name__ == '__main__':
    app.run(debug=True)

التعامل معها في الفرونت إند (مثال JavaScript)

سواء كنت تستخدم أزرار ترقيم صفحات أو Infinite Scroll، الفكرة هي إرسال البارامترات الصحيحة للباك إند وتحديث الواجهة بالبيانات الجديدة.

مثال لـ Offset/Limit (أزرار ترقيم):

let currentPage = 1;
const limit = 10;

const fetchProductsOffset = async () => {
    const response = await fetch(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">/api/products_offset?page=${currentPage}&limit=${limit}</code>);
    const data = await response.json();

    const productsDiv = document.getElementById('products-list');
    productsDiv.innerHTML = ''; // مسح القديم
    data.products.forEach(product => {
        const p = document.createElement('p');
        p.textContent = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">ID: ${product.id}, Name: ${product.name}</code>;
        productsDiv.appendChild(p);
    });

    document.getElementById('current-page').textContent = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">الصفحة: ${data.page} من ${data.total_pages}</code>;
    document.getElementById('prev-btn').disabled = (data.page === 1);
    document.getElementById('next-btn').disabled = (data.page === data.total_pages);
};

document.getElementById('prev-btn').addEventListener('click', () => {
    if (currentPage > 1) {
        currentPage--;
        fetchProductsOffset();
    }
});

document.getElementById('next-btn').addEventListener('click', () => {
    // تحتاج تجلب total_pages أولاً أو من الـ API
    // نفترض إنها متاحة في 'data' بعد أول جلب
    if (currentPage < /* total_pages من الـ API */ 10) { // مثال بسيط
        currentPage++;
        fetchProductsOffset();
    }
});

// استدعاء أول مرة عند تحميل الصفحة
// fetchProductsOffset();

مثال لـ Cursor-based (Infinite Scroll):

let lastProductId = 0; // تبدأ من 0 أو من آخر ID معروف لديك
const limit = 10;
let isLoading = false;
let hasMore = true;

const fetchProductsCursor = async () => {
    if (isLoading || !hasMore) return;
    isLoading = true;

    const response = await fetch(<code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">/api/products_cursor?last_id=${lastProductId}&limit=${limit}</code>);
    const data = await response.json();

    const productsDiv = document.getElementById('products-list-infinite');
    data.products.forEach(product => {
        const p = document.createElement('p');
        p.textContent = <code dir="ltr" style="background:#f3f4f6; color:#0056b3; padding:2px 6px; border-radius:4px; font-family:monospace; direction:ltr !important; display:inline-block;">ID: ${product.id}, Name: ${product.name}</code>;
        productsDiv.appendChild(p);
    });

    if (data.products.length > 0) {
        lastProductId = data.products[data.products.length - 1].id;
    }
    hasMore = data.has_more;
    isLoading = false;
};

window.addEventListener('scroll', () => {
    // إذا وصل المستخدم لآخر الصفحة
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) { // -100 بكسل عشان يجلب قبل ما يوصل للآخر بالضبط
        fetchProductsCursor();
    }
});

// استدعاء أول مرة عند تحميل الصفحة
// fetchProductsCursor();

نصائح ذهبية للتعامل مع الـ Pagination

  • التحقق من المدخلات (Input Validation): دائماً تحقق من قيم page، limit، last_id اللي تجيك من الفرونت إند. تأكد إنها أرقام صحيحة وإيجابية وضمن نطاق معقول عشان تتجنب أي مشاكل أمنية أو أخطاء في السكربت.
  • الفهارس (Indexes): تأكد إن الأعمدة اللي تستخدمها في جملة ORDER BY أو WHERE (مثل id أو created_at) عليها فهارس في قاعدة البيانات. هذا بيسرع عمليات الجلب بشكل خيالي، خصوصاً مع الـ Cursor-based pagination.
  • تجربة المستخدم (UX): وضح للمستخدم عدد الصفحات الكلي (إذا كنت تستخدم Offset/Limit) أو إذا كان فيه بيانات زيادة ممكن يجلبها (لـ Infinite Scroll). لا تخليه يتساءل هل فيه المزيد أو لا.
  • التعامل مع الفشل (Error Handling): كيف السكربت يتصرف لو ما لقى بيانات أو صار خطأ في الاتصال بقاعدة البيانات؟ دائماً جهز رسائل خطأ واضحة.
  • الحماية من هجمات DoS: لا تسمح بقيم limit كبيرة جداً (مثلاً، limit=1000000). حدد أقصى قيمة مسموحة للـ limit في الباك إند.

الـ Pagination مو بس ميزة، هي ضرورة لأي تطبيق يتعامل مع كميات كبيرة من البيانات. بتخلي تطبيقك أسرع، أكثر استقراراً، وأكثر قابلية للتوسع. يلا طبقوها صح واستمتعوا بأداء أفضل!