أساسيات JavaScript

العمل مع واجهات REST APIs

45 دقيقة الدرس 43 من 60

ما هي واجهة REST API؟

REST هو اختصار لـ Representational State Transfer (نقل الحالة التمثيلية). وهو أسلوب معماري لبناء خدمات الويب التي تسمح لتطبيقات البرمجيات المختلفة بالتواصل عبر الإنترنت باستخدام أساليب HTTP القياسية. تكشف واجهة REST API عن موارد -- مثل المستخدمين والمنشورات والمنتجات والطلبات -- على عناوين URL محددة تسمى نقاط النهاية. يمكن إنشاء كل مورد وقراءته وتحديثه وحذفه باستخدام أساليب HTTP، مما يشكل أساس تطبيقات الويب الحديثة.

تتبع واجهات REST APIs مجموعة من المبادئ التي تجعلها قابلة للتنبؤ وسهلة التعامل. فهي عديمة الحالة، مما يعني أن كل طلب يحتوي على جميع المعلومات اللازمة لمعالجته. وتستخدم واجهات موحدة، مما يعني أن الموارد يتم تحديدها بواسطة عناوين URL والتعامل معها من خلال أساليب HTTP القياسية. وتعيد تمثيلات للموارد، عادةً بتنسيق JSON، بدلاً من الموارد نفسها.

مبادئ REST والموارد

في REST، كل شيء يدور حول الموارد. المورد هو أي قطعة من البيانات تديرها واجهتك البرمجية. يتم تنظيم الموارد في مجموعات وعناصر فردية. فكر في واجهة API لمدونة: مجموعة جميع المنشورات تعيش في /posts، بينما منشور واحد يعيش في /posts/1. تسمى بنية URL هذه مسار المورد، ويجب أن تكون بديهية ومتسقة.

تشمل مبادئ REST الرئيسية:

  • فصل العميل والخادم -- الواجهة الأمامية والخلفية مستقلتان. يتعامل العميل مع واجهة المستخدم، بينما يتعامل الخادم مع تخزين البيانات ومنطق الأعمال.
  • عديمة الحالة -- كل طلب من العميل إلى الخادم يجب أن يحتوي على جميع المعلومات اللازمة لفهم ومعالجة ذلك الطلب. لا يخزن الخادم أي سياق للعميل بين الطلبات.
  • واجهة موحدة -- يتم تحديد الموارد بواسطة عناوين URL والتعامل معها باستخدام أساليب HTTP. تتضمن الاستجابات معلومات كافية للعميل لتعديل المورد أو حذفه.
  • عناوين URL قائمة على الموارد -- يجب أن تمثل عناوين URL أسماء وليس أفعال. استخدم /users بدلاً من /getUsers. استخدم /posts/5 بدلاً من /getPostById?id=5.

أساليب HTTP لعمليات CRUD

تربط واجهات REST APIs عمليات البيانات الأربع الأساسية -- الإنشاء والقراءة والتحديث والحذف (CRUD) -- بأساليب HTTP محددة. فهم هذه الربطات ضروري للعمل مع أي واجهة برمجية.

  • GET -- يسترجع البيانات من الخادم. يجب ألا يعدل البيانات أبداً. يستخدم لقراءة الموارد.
  • POST -- ينشئ مورداً جديداً على الخادم. يرسل البيانات في جسم الطلب.
  • PUT -- يستبدل مورداً كاملاً ببيانات جديدة. يتطلب إرسال المورد المحدث بالكامل.
  • PATCH -- يحدث مورداً جزئياً. يرسل فقط الحقول التي تحتاج إلى التغيير.
  • DELETE -- يزيل مورداً من الخادم.

مثال: نظرة عامة على عمليات CRUD

// أنماط نقاط النهاية لواجهة REST API لمورد "posts":
//
// GET    /posts       -- استرجاع جميع المنشورات (مجموعة)
// GET    /posts/1     -- استرجاع منشور واحد (عنصر)
// POST   /posts       -- إنشاء منشور جديد
// PUT    /posts/1     -- استبدال المنشور ذو المعرف 1 بالكامل
// PATCH  /posts/1     -- تحديث حقول محددة من المنشور 1
// DELETE /posts/1     -- حذف المنشور ذو المعرف 1

رموز حالة HTTP

تتضمن كل استجابة من واجهة API رمز حالة يخبرك بما حدث مع طلبك. يتم تجميع رموز الحالة في نطاقات، كل منها يشير إلى فئة من الاستجابة. معرفة هذه الرموز يساعدك على التعامل مع استجابات API بشكل صحيح في تطبيقك.

  • 2xx -- نجاح: تم الطلب بنجاح.
    • 200 OK -- استجابة نجاح قياسية لطلبات GET و PUT و PATCH.
    • 201 Created -- تم إنشاء مورد جديد بنجاح (عادةً بعد POST).
    • 204 No Content -- نجاح بدون جسم استجابة (عادةً بعد DELETE).
  • 4xx -- خطأ من العميل: شيء ما كان خاطئاً في طلبك.
    • 400 Bad Request -- بيانات الطلب غير صالحة أو مشوهة.
    • 401 Unauthorized -- المصادقة مطلوبة لكن لم يتم تقديمها.
    • 403 Forbidden -- أنت مصادق عليه لكن تفتقر إلى الإذن لهذا الإجراء.
    • 404 Not Found -- المورد المطلوب غير موجود.
    • 429 Too Many Requests -- لقد تجاوزت حد المعدل.
  • 5xx -- خطأ من الخادم: حدث خطأ ما على جانب الخادم.
    • 500 Internal Server Error -- حدث خطأ عام في الخادم.
    • 503 Service Unavailable -- الخادم معطل مؤقتاً أو محمل بشكل زائد.

إجراء طلبات GET باستخدام Fetch

دالة fetch() هي الطريقة الحديثة المدمجة لإجراء طلبات HTTP في JavaScript. تعيد Promise يتحول إلى كائن Response. لنبدأ بأبسط عملية -- جلب البيانات من واجهة API عامة. سنستخدم JSONPlaceholder، وهي واجهة REST API مجانية للاختبار والنماذج الأولية.

مثال: جلب قائمة المستخدمين

// جلب جميع المستخدمين من واجهة JSONPlaceholder API
async function fetchUsers() {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');

    // التحقق مما إذا كانت الاستجابة ناجحة
    if (!response.ok) {
        throw new Error('فشل جلب المستخدمين: ' + response.status);
    }

    // تحليل جسم استجابة JSON
    const users = await response.json();

    console.log('إجمالي المستخدمين:', users.length);
    console.log('أول مستخدم:', users[0].name);

    return users;
}

// استدعاء الدالة
fetchUsers().then(users => {
    users.forEach(user => {
        console.log(user.name + ' - ' + user.email);
    });
});
ملاحظة: خاصية response.ok تكون true عندما يكون رمز الحالة في النطاق 200-299. تحقق دائماً من هذه الخاصية قبل تحليل جسم الاستجابة، لأن fetch() ترفض الوعد فقط عند فشل الشبكة، وليس عند رموز حالة خطأ HTTP مثل 404 أو 500.

جلب مورد واحد

لجلب مورد محدد، أضف معرفه إلى عنوان URL لنقطة النهاية. هذا هو نمط REST القياسي لاسترجاع العناصر الفردية من مجموعة.

مثال: جلب منشور واحد بواسطة المعرف

async function fetchPost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId
    );

    if (!response.ok) {
        if (response.status === 404) {
            console.error('المنشور غير موجود بالمعرف:', postId);
            return null;
        }
        throw new Error('خطأ HTTP: ' + response.status);
    }

    const post = await response.json();
    console.log('العنوان:', post.title);
    console.log('المحتوى:', post.body);

    return post;
}

// جلب المنشور ذو المعرف 1
fetchPost(1);

// جلب منشور غير موجود
fetchPost(9999); // سيسجل "المنشور غير موجود"

إجراء طلبات POST -- إنشاء البيانات

لإنشاء مورد جديد، أرسل طلب POST مع البيانات في جسم الطلب. يجب تحديد ترويسة Content-Type حتى يعرف الخادم كيفية تحليل الجسم، ويجب تسلسل بياناتك باستخدام JSON.stringify().

مثال: إنشاء منشور جديد

async function createPost(title, body, userId) {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            title: title,
            body: body,
            userId: userId,
        }),
    });

    if (!response.ok) {
        throw new Error('فشل إنشاء المنشور: ' + response.status);
    }

    const newPost = await response.json();
    console.log('تم إنشاء منشور بالمعرف:', newPost.id);

    return newPost;
}

// إنشاء منشور جديد
createPost(
    'فهم واجهات REST APIs',
    'واجهات REST APIs هي العمود الفقري لتطبيقات الويب الحديثة...',
    1
);

إجراء طلبات PUT و PATCH -- تحديث البيانات

PUT يستبدل المورد بالكامل، بينما PATCH يحدث فقط الحقول المحددة. اختر الطريقة الصحيحة بناءً على ما إذا كنت ترسل استبدالاً كاملاً أو تحديثاً جزئياً.

مثال: تحديث منشور باستخدام PUT و PATCH

// PUT -- استبدال المنشور بالكامل
async function replacePost(postId, postData) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(postData),
        }
    );

    if (!response.ok) {
        throw new Error('فشل استبدال المنشور: ' + response.status);
    }

    return await response.json();
}

// PATCH -- تحديث العنوان فقط
async function updatePostTitle(postId, newTitle) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'PATCH',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ title: newTitle }),
        }
    );

    if (!response.ok) {
        throw new Error('فشل تحديث المنشور: ' + response.status);
    }

    return await response.json();
}

// استبدال المنشور بالكامل
replacePost(1, {
    title: 'استبدال كامل',
    body: 'هذا محتوى جديد بالكامل.',
    userId: 1,
});

// تحديث العنوان فقط
updatePostTitle(1, 'عنوان محدث فقط');

إجراء طلبات DELETE

طلبات DELETE تزيل مورداً من الخادم. عادةً لا تتطلب جسم طلب. الحذف الناجح يعيد عادةً رمز حالة 200 أو 204.

مثال: حذف منشور

async function deletePost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'DELETE',
        }
    );

    if (!response.ok) {
        throw new Error('فشل حذف المنشور: ' + response.status);
    }

    console.log('تم حذف المنشور ' + postId + ' بنجاح');
    return true;
}

deletePost(1);

معلمات الاستعلام وبناء عناوين URL

تسمح لك معلمات الاستعلام بتصفية نتائج API وترتيبها والبحث فيها وتقسيمها إلى صفحات. يتم إلحاقها بعنوان URL بعد حرف ?. توفر فئة URLSearchParams طريقة نظيفة وآمنة لبناء سلاسل الاستعلام دون القلق بشأن ترميز الأحرف الخاصة.

مثال: بناء عناوين URL باستخدام URLSearchParams

// الطريقة اليدوية -- عرضة للأخطاء مع الأحرف الخاصة
const unsafeUrl = 'https://api.example.com/search?q=hello world&page=1';

// الطريقة الآمنة -- URLSearchParams تتولى الترميز
function buildApiUrl(baseUrl, params) {
    const url = new URL(baseUrl);
    const searchParams = new URLSearchParams(params);
    url.search = searchParams.toString();
    return url.toString();
}

// بناء عنوان URL للبحث مع معلمات متعددة
const searchUrl = buildApiUrl('https://api.example.com/products', {
    q: 'حافظة لابتوب',
    category: 'إلكترونيات',
    min_price: '25',
    max_price: '100',
    sort: 'price_asc',
    page: '1',
});

console.log(searchUrl);

// الجلب مع معلمات الاستعلام
async function searchProducts(query, page) {
    const params = new URLSearchParams({
        q: query,
        page: page.toString(),
        limit: '20',
    });

    const response = await fetch(
        'https://api.example.com/products?' + params.toString()
    );

    if (!response.ok) {
        throw new Error('فشل البحث: ' + response.status);
    }

    return await response.json();
}
نصيحة احترافية: استخدم دائماً URLSearchParams أو مُنشئ URL لبناء سلاسل الاستعلام. فهي تتعامل تلقائياً مع الأحرف الخاصة مثل المسافات وعلامات العطف وعلامات الاستفهام التي قد تكسر عنوان URL المبني يدوياً. هذا يمنع الأخطاء الدقيقة التي يصعب تتبعها.

التصفية والاستعلام مع JSONPlaceholder

العديد من واجهات REST APIs تدعم التصفية عبر معلمات الاستعلام. يدعم JSONPlaceholder التصفية حسب أي حقل على المورد. هذا نمط شائع ستواجهه في واجهات API الحقيقية.

مثال: تصفية المنشورات حسب معرف المستخدم

// الحصول على جميع منشورات مستخدم محدد
async function fetchPostsByUser(userId) {
    const params = new URLSearchParams({ userId: userId.toString() });
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts?' + params.toString()
    );

    if (!response.ok) {
        throw new Error('فشل جلب المنشورات: ' + response.status);
    }

    const posts = await response.json();
    console.log('المستخدم ' + userId + ' لديه ' + posts.length + ' منشورات');

    return posts;
}

// الحصول على التعليقات لمنشور محدد
async function fetchCommentsForPost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId + '/comments'
    );

    if (!response.ok) {
        throw new Error('فشل جلب التعليقات: ' + response.status);
    }

    return await response.json();
}

// مورد متداخل: الحصول على تعليقات المنشور 1
fetchCommentsForPost(1).then(comments => {
    comments.forEach(comment => {
        console.log(comment.name + ': ' + comment.body.substring(0, 50) + '...');
    });
});

التعامل مع التقسيم إلى صفحات

معظم واجهات API لا تعيد جميع السجلات دفعة واحدة. بدلاً من ذلك، تقسم النتائج إلى صفحات. التقسيم إلى صفحات أمر بالغ الأهمية للأداء -- تخيل تحميل 10,000 منتج دفعة واحدة. تشمل أنماط التقسيم الشائعة التقسيم القائم على الصفحة والإزاحة والمؤشر.

مثال: التقسيم القائم على الصفحة

// متحكم التقسيم إلى صفحات
class PaginatedFetcher {
    constructor(baseUrl, pageSize) {
        this.baseUrl = baseUrl;
        this.pageSize = pageSize || 10;
        this.currentPage = 1;
        this.totalPages = null;
        this.isLoading = false;
    }

    async fetchPage(page) {
        if (this.isLoading) {
            console.log('جارٍ التحميل بالفعل، يرجى الانتظار...');
            return null;
        }

        this.isLoading = true;

        try {
            const params = new URLSearchParams({
                _page: page.toString(),
                _limit: this.pageSize.toString(),
            });

            const response = await fetch(
                this.baseUrl + '?' + params.toString()
            );

            if (!response.ok) {
                throw new Error('فشل جلب الصفحة: ' + response.status);
            }

            // العديد من الواجهات تتضمن العدد الإجمالي في الترويسات
            const totalCount = response.headers.get('X-Total-Count');
            if (totalCount) {
                this.totalPages = Math.ceil(
                    parseInt(totalCount) / this.pageSize
                );
            }

            const data = await response.json();
            this.currentPage = page;

            return {
                data: data,
                page: this.currentPage,
                totalPages: this.totalPages,
                hasNext: this.totalPages ? page < this.totalPages : data.length === this.pageSize,
                hasPrevious: page > 1,
            };
        } finally {
            this.isLoading = false;
        }
    }

    async nextPage() {
        return await this.fetchPage(this.currentPage + 1);
    }

    async previousPage() {
        if (this.currentPage > 1) {
            return await this.fetchPage(this.currentPage - 1);
        }
        return null;
    }
}

// الاستخدام
const postFetcher = new PaginatedFetcher(
    'https://jsonplaceholder.typicode.com/posts',
    5
);

// جلب الصفحة الأولى
postFetcher.fetchPage(1).then(result => {
    console.log('الصفحة', result.page, 'من', result.totalPages);
    console.log('المنشورات:', result.data.length);
    console.log('يوجد صفحة تالية:', result.hasNext);
});

جلب جميع الصفحات تلقائياً

أحياناً تحتاج إلى جمع البيانات من جميع الصفحات. هذا يتطلب إجراء طلبات متتابعة حتى لا يتوفر المزيد من البيانات. كن حذراً مع هذا النمط -- احترم دائماً حدود المعدل وفكر فيما إذا كنت حقاً بحاجة إلى جميع البيانات دفعة واحدة.

مثال: جمع جميع الصفحات

async function fetchAllPages(baseUrl, pageSize) {
    const allData = [];
    let page = 1;
    let hasMore = true;

    while (hasMore) {
        const params = new URLSearchParams({
            _page: page.toString(),
            _limit: pageSize.toString(),
        });

        const response = await fetch(baseUrl + '?' + params.toString());

        if (!response.ok) {
            throw new Error('فشل في الصفحة ' + page + ': ' + response.status);
        }

        const data = await response.json();
        allData.push(...data);

        // إذا حصلنا على عناصر أقل من حجم الصفحة، فنحن في الصفحة الأخيرة
        hasMore = data.length === pageSize;
        page++;

        console.log('تم جلب الصفحة ' + (page - 1) + '، إجمالي العناصر: ' + allData.length);
    }

    return allData;
}

// جلب جميع المنشورات على دفعات من 10
fetchAllPages('https://jsonplaceholder.typicode.com/posts', 10)
    .then(allPosts => {
        console.log('إجمالي المنشورات المجلوبة:', allPosts.length);
    });

المصادقة: مفاتيح API ورموز Bearer

معظم واجهات API الحقيقية تتطلب المصادقة لتحديد من يقوم بإجراء الطلبات. الطريقتان الأكثر شيوعاً هما مفاتيح API (ترسل كمعلمة استعلام أو ترويسة) ورموز Bearer (ترسل في ترويسة التفويض). فهم كلا النمطين ضروري للعمل مع واجهات API الإنتاجية.

مثال: طرق المصادقة

// الطريقة 1: مفتاح API في معلمة الاستعلام
async function fetchWithApiKey(endpoint) {
    const params = new URLSearchParams({
        api_key: 'your_api_key_here',
    });

    const response = await fetch(endpoint + '?' + params.toString());
    return await response.json();
}

// الطريقة 2: مفتاح API في ترويسة مخصصة
async function fetchWithHeaderKey(endpoint) {
    const response = await fetch(endpoint, {
        headers: {
            'X-API-Key': 'your_api_key_here',
        },
    });

    return await response.json();
}

// الطريقة 3: رمز Bearer في ترويسة التفويض
async function fetchWithBearerToken(endpoint, token) {
    const response = await fetch(endpoint, {
        headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json',
        },
    });

    if (response.status === 401) {
        console.error('الرمز منتهي الصلاحية أو غير صالح. يرجى تسجيل الدخول مرة أخرى.');
        return null;
    }

    if (!response.ok) {
        throw new Error('فشل الطلب: ' + response.status);
    }

    return await response.json();
}

// الطريقة 4: تخزين وتحديث الرموز
class AuthenticatedClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
        this.accessToken = null;
        this.refreshToken = null;
    }

    async login(username, password) {
        const response = await fetch(this.baseUrl + '/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username, password }),
        });

        if (!response.ok) {
            throw new Error('فشل تسجيل الدخول: ' + response.status);
        }

        const data = await response.json();
        this.accessToken = data.accessToken;
        this.refreshToken = data.refreshToken;

        return data;
    }

    async request(endpoint, options) {
        if (!options) options = {};
        if (!options.headers) options.headers = {};

        options.headers['Authorization'] = 'Bearer ' + this.accessToken;

        const response = await fetch(this.baseUrl + endpoint, options);

        // إذا انتهت صلاحية الرمز، حاول التحديث
        if (response.status === 401 && this.refreshToken) {
            await this.refreshAccessToken();
            options.headers['Authorization'] = 'Bearer ' + this.accessToken;
            return await fetch(this.baseUrl + endpoint, options);
        }

        return response;
    }

    async refreshAccessToken() {
        const response = await fetch(this.baseUrl + '/auth/refresh', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken: this.refreshToken }),
        });

        if (!response.ok) {
            throw new Error('فشل تحديث الرمز. يرجى تسجيل الدخول مرة أخرى.');
        }

        const data = await response.json();
        this.accessToken = data.accessToken;
    }
}
تحذير أمني: لا تكشف أبداً مفاتيح API أو الرموز في كود JavaScript من جهة العميل الذي يعمل في المتصفح. يمكن لأي شخص عرض كود المصدر الخاص بك وسرقة بيانات اعتمادك. في الإنتاج، مرر استدعاءات API عبر خادمك الخلفي الذي يخزن المفاتيح بشكل آمن. لأغراض التعلم نعرض النمط هنا، لكن التطبيقات الحقيقية يجب أن تحتفظ بالأسرار على جانب الخادم.

تحديد المعدل

تحد واجهات API عدد الطلبات التي يمكنك إجراؤها في فترة زمنية معينة لمنع إساءة الاستخدام وضمان الاستخدام العادل. عندما تصل إلى حد المعدل، تعيد الواجهة رمز حالة 429 Too Many Requests. غالباً ما تخبرك ترويسات الاستجابة بموعد إمكانية إعادة المحاولة. التعامل مع حدود المعدل بأناقة أمر ضروري لبناء تطبيقات موثوقة.

مثال: معالج حد المعدل

class RateLimitedFetcher {
    constructor(requestsPerSecond) {
        this.minInterval = 1000 / requestsPerSecond;
        this.lastRequestTime = 0;
        this.queue = [];
        this.processing = false;
    }

    async fetch(url, options) {
        return new Promise((resolve, reject) => {
            this.queue.push({ url, options, resolve, reject });
            this.processQueue();
        });
    }

    async processQueue() {
        if (this.processing || this.queue.length === 0) return;
        this.processing = true;

        while (this.queue.length > 0) {
            const now = Date.now();
            const timeSinceLastRequest = now - this.lastRequestTime;

            if (timeSinceLastRequest < this.minInterval) {
                await new Promise(resolve =>
                    setTimeout(resolve, this.minInterval - timeSinceLastRequest)
                );
            }

            const { url, options, resolve, reject } = this.queue.shift();

            try {
                this.lastRequestTime = Date.now();
                const response = await fetch(url, options);

                // التعامل مع استجابة حد المعدل
                if (response.status === 429) {
                    const retryAfter = response.headers.get('Retry-After');
                    const waitTime = retryAfter
                        ? parseInt(retryAfter) * 1000
                        : 5000;

                    console.log('تم تحديد المعدل. الانتظار ' + waitTime + 'ms...');
                    await new Promise(r => setTimeout(r, waitTime));

                    // إعادة محاولة الطلب
                    this.lastRequestTime = Date.now();
                    const retryResponse = await fetch(url, options);
                    resolve(retryResponse);
                } else {
                    resolve(response);
                }
            } catch (error) {
                reject(error);
            }
        }

        this.processing = false;
    }
}

// السماح بحد أقصى طلبين في الثانية
const limiter = new RateLimitedFetcher(2);

// هذه الطلبات ستتباعد تلقائياً
async function fetchMultipleUsers() {
    const userIds = [1, 2, 3, 4, 5];
    const users = [];

    for (const id of userIds) {
        const response = await limiter.fetch(
            'https://jsonplaceholder.typicode.com/users/' + id
        );
        const user = await response.json();
        users.push(user);
        console.log('تم جلب المستخدم:', user.name);
    }

    return users;
}

حالات التحميل وتغذية واجهة المستخدم الراجعة

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

مثال: إدارة حالات التحميل

class UIStateManager {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
    }

    showLoading(message) {
        this.container.innerHTML =
            '<div class="loading-state">' +
            '  <div class="spinner"></div>' +
            '  <p>' + (message || 'جارٍ التحميل...') + '</p>' +
            '</div>';
    }

    showError(message, retryCallback) {
        this.container.innerHTML =
            '<div class="error-state">' +
            '  <p class="error-message">' + message + '</p>' +
            '  <button class="retry-button">حاول مرة أخرى</button>' +
            '</div>';

        if (retryCallback) {
            this.container
                .querySelector('.retry-button')
                .addEventListener('click', retryCallback);
        }
    }

    showEmpty(message) {
        this.container.innerHTML =
            '<div class="empty-state">' +
            '  <p>' + (message || 'لا توجد بيانات متاحة.') + '</p>' +
            '</div>';
    }

    showContent(html) {
        this.container.innerHTML = html;
    }
}

// الاستخدام مع استدعاء API
async function loadUserList() {
    const ui = new UIStateManager('user-list');
    ui.showLoading('جارٍ تحميل المستخدمين...');

    try {
        const response = await fetch(
            'https://jsonplaceholder.typicode.com/users'
        );

        if (!response.ok) {
            throw new Error('الخادم أعاد ' + response.status);
        }

        const users = await response.json();

        if (users.length === 0) {
            ui.showEmpty('لم يتم العثور على مستخدمين.');
            return;
        }

        let html = '<ul class="user-list">';
        users.forEach(user => {
            html += '<li class="user-card">';
            html += '  <h3>' + user.name + '</h3>';
            html += '  <p>' + user.email + '</p>';
            html += '  <p>' + user.company.name + '</p>';
            html += '</li>';
        });
        html += '</ul>';

        ui.showContent(html);
    } catch (error) {
        ui.showError(
            'فشل تحميل المستخدمين: ' + error.message,
            loadUserList
        );
    }
}

بناء عميل API كامل قابل لإعادة الاستخدام

لأي مشروع حقيقي، يجب إنشاء فئة عميل API قابلة لإعادة الاستخدام تتعامل مع المهام الشائعة مثل إدارة عنوان URL الأساسي والترويسات الافتراضية ومعالجة الأخطاء وتحليل الاستجابات. هذا يتجنب تكرار نفس الكود المتكرر في كل استدعاء API.

مثال: فئة عميل API قابلة لإعادة الاستخدام

class ApiClient {
    constructor(baseUrl, defaultHeaders) {
        this.baseUrl = baseUrl;
        this.defaultHeaders = Object.assign(
            { 'Content-Type': 'application/json' },
            defaultHeaders || {}
        );
    }

    async request(endpoint, options) {
        const url = this.baseUrl + endpoint;

        const config = {
            headers: Object.assign({}, this.defaultHeaders, options.headers || {}),
            method: options.method || 'GET',
        };

        if (options.body) {
            config.body = JSON.stringify(options.body);
        }

        if (options.params) {
            const searchParams = new URLSearchParams(options.params);
            const fullUrl = url + '?' + searchParams.toString();
            const response = await fetch(fullUrl, config);
            return await this.handleResponse(response);
        }

        const response = await fetch(url, config);
        return await this.handleResponse(response);
    }

    async handleResponse(response) {
        if (!response.ok) {
            const errorBody = await response.text();
            let errorMessage;

            try {
                const errorJson = JSON.parse(errorBody);
                errorMessage = errorJson.message || errorBody;
            } catch (e) {
                errorMessage = errorBody;
            }

            const error = new Error(errorMessage);
            error.status = response.status;
            error.statusText = response.statusText;
            throw error;
        }

        if (response.status === 204) {
            return null;
        }

        return await response.json();
    }

    async get(endpoint, params) {
        return await this.request(endpoint, { method: 'GET', params: params });
    }

    async post(endpoint, body) {
        return await this.request(endpoint, { method: 'POST', body: body });
    }

    async put(endpoint, body) {
        return await this.request(endpoint, { method: 'PUT', body: body });
    }

    async patch(endpoint, body) {
        return await this.request(endpoint, { method: 'PATCH', body: body });
    }

    async delete(endpoint) {
        return await this.request(endpoint, { method: 'DELETE' });
    }
}

// الاستخدام
const api = new ApiClient('https://jsonplaceholder.typicode.com');

// GET جميع المنشورات
api.get('/posts').then(posts => {
    console.log('المنشورات:', posts.length);
});

// GET منشورات مع معلمات استعلام
api.get('/posts', { userId: '1' }).then(posts => {
    console.log('منشورات المستخدم 1:', posts.length);
});

// POST منشور جديد
api.post('/posts', {
    title: 'منشور جديد',
    body: 'هذا هو المحتوى.',
    userId: 1,
}).then(post => {
    console.log('تم الإنشاء:', post.id);
});

عرض البيانات الواقعية: تطبيق قائمة المستخدمين

لنبنِ مثالاً عملياً يجلب المستخدمين من واجهة JSONPlaceholder API ويعرضهم في قائمة ديناميكية تفاعلية مع تصفية البحث. هذا يوضح كيفية الجمع بين استدعاءات API والتعامل مع DOM لبناء ميزة حقيقية.

مثال: قائمة مستخدمين تفاعلية مع بحث

class UserListApp {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
        this.users = [];
        this.filteredUsers = [];
        this.api = new ApiClient('https://jsonplaceholder.typicode.com');
    }

    async initialize() {
        this.renderSearchBar();
        await this.loadUsers();
    }

    renderSearchBar() {
        const searchHtml =
            '<div class="search-container">' +
            '  <input type="text" id="user-search"' +
            '    placeholder="ابحث عن المستخدمين بالاسم أو البريد الإلكتروني...">' +
            '</div>' +
            '<div id="user-results"></div>';

        this.container.innerHTML = searchHtml;

        document.getElementById('user-search')
            .addEventListener('input', (e) => {
                this.filterUsers(e.target.value);
            });
    }

    async loadUsers() {
        const resultsDiv = document.getElementById('user-results');
        resultsDiv.innerHTML = '<p>جارٍ تحميل المستخدمين...</p>';

        try {
            this.users = await this.api.get('/users');
            this.filteredUsers = this.users;
            this.renderUsers();
        } catch (error) {
            resultsDiv.innerHTML =
                '<p class="error">فشل تحميل المستخدمين: ' +
                error.message + '</p>';
        }
    }

    filterUsers(query) {
        const lowerQuery = query.toLowerCase();
        this.filteredUsers = this.users.filter(user => {
            return (
                user.name.toLowerCase().includes(lowerQuery) ||
                user.email.toLowerCase().includes(lowerQuery) ||
                user.company.name.toLowerCase().includes(lowerQuery)
            );
        });
        this.renderUsers();
    }

    renderUsers() {
        const resultsDiv = document.getElementById('user-results');

        if (this.filteredUsers.length === 0) {
            resultsDiv.innerHTML = '<p>لا يوجد مستخدمون مطابقون لبحثك.</p>';
            return;
        }

        let html = '<div class="user-grid">';
        this.filteredUsers.forEach(user => {
            html += '<div class="user-card" data-user-id="' + user.id + '">';
            html += '  <h3>' + user.name + '</h3>';
            html += '  <p class="email">' + user.email + '</p>';
            html += '  <p class="company">' + user.company.name + '</p>';
            html += '  <p class="city">' + user.address.city + '</p>';
            html += '  <button onclick="app.viewUserPosts(' + user.id + ')">';
            html += '    عرض المنشورات';
            html += '  </button>';
            html += '</div>';
        });
        html += '</div>';

        resultsDiv.innerHTML = html;
    }

    async viewUserPosts(userId) {
        const user = this.users.find(u => u.id === userId);

        try {
            const posts = await this.api.get('/posts', {
                userId: userId.toString()
            });

            let html = '<button onclick="app.renderUsers()">العودة للمستخدمين</button>';
            html += '<h2>منشورات ' + user.name + '</h2>';

            posts.forEach(post => {
                html += '<div class="post-card">';
                html += '  <h4>' + post.title + '</h4>';
                html += '  <p>' + post.body + '</p>';
                html += '</div>';
            });

            document.getElementById('user-results').innerHTML = html;
        } catch (error) {
            console.error('فشل تحميل المنشورات:', error);
        }
    }
}

// تهيئة التطبيق
const app = new UserListApp('app');
app.initialize();

إدارة المنشورات: واجهة CRUD كاملة

لنبنِ واجهة إدارة منشورات كاملة توضح جميع عمليات CRUD الأربع معاً. هذا يمثل لوحة إدارة واقعية حيث يمكنك إنشاء الموارد وعرضها وتحريرها وحذفها من خلال واجهة REST API.

مثال: مدير منشورات كامل

class PostManager {
    constructor(api) {
        this.api = api;
        this.posts = [];
    }

    async loadPosts(page, limit) {
        const params = {
            _page: (page || 1).toString(),
            _limit: (limit || 10).toString(),
        };

        this.posts = await this.api.get('/posts', params);
        return this.posts;
    }

    async createPost(title, body, userId) {
        const newPost = await this.api.post('/posts', {
            title: title,
            body: body,
            userId: userId,
        });

        this.posts.unshift(newPost);
        console.log('تم إنشاء منشور بالمعرف:', newPost.id);
        return newPost;
    }

    async updatePost(postId, updates) {
        const updatedPost = await this.api.patch(
            '/posts/' + postId,
            updates
        );

        const index = this.posts.findIndex(p => p.id === postId);
        if (index !== -1) {
            this.posts[index] = Object.assign(this.posts[index], updatedPost);
        }

        console.log('تم تحديث المنشور ' + postId);
        return updatedPost;
    }

    async deletePost(postId) {
        await this.api.delete('/posts/' + postId);

        this.posts = this.posts.filter(p => p.id !== postId);
        console.log('تم حذف المنشور ' + postId);
    }

    async getPostWithComments(postId) {
        const post = await this.api.get('/posts/' + postId);
        const comments = await this.api.get(
            '/posts/' + postId + '/comments'
        );

        post.comments = comments;
        return post;
    }
}

// الاستخدام
const api = new ApiClient('https://jsonplaceholder.typicode.com');
const manager = new PostManager(api);

// سير عمل CRUD الكامل
async function demonstrateCrud() {
    // قراءة -- تحميل الصفحة الأولى من المنشورات
    const posts = await manager.loadPosts(1, 5);
    console.log('تم تحميل', posts.length, 'منشورات');

    // إنشاء -- إضافة منشور جديد
    const newPost = await manager.createPost(
        'منشوري الجديد',
        'هذا محتوى منشوري الجديد.',
        1
    );

    // تحديث -- تغيير العنوان
    await manager.updatePost(newPost.id, {
        title: 'عنوان محدث لمنشوري',
    });

    // قراءة مع تفاصيل -- الحصول على المنشور وتعليقاته
    const detailed = await manager.getPostWithComments(1);
    console.log('المنشور يحتوي على', detailed.comments.length, 'تعليقات');

    // حذف -- إزالة المنشور
    await manager.deletePost(newPost.id);
    console.log('اكتمل سير عمل CRUD');
}

demonstrateCrud();
نصيحة احترافية: عند بناء تطبيقات إنتاجية، فكر في إضافة تخزين مؤقت للطلبات إلى عميل API الخاص بك. خزن استجابات GET في Map مع عنوان URL كمفتاح وحدد وقت انتهاء الصلاحية (TTL) لكل إدخال. هذا يقلل من استدعاءات الشبكة غير الضرورية ويسرع تطبيقك ويساعدك على البقاء ضمن حدود معدل API.
ملاحظة: JSONPlaceholder هي واجهة API وهمية، لذا فإن طلبات POST و PUT و PATCH و DELETE ستعيد استجابات محاكاة لكنها لن تعدل البيانات فعلياً على الخادم. هذا مثالي للتعلم والاختبار، لكن تذكر أن تحديث الصفحة سيعرض دائماً البيانات الأصلية.

تمرين عملي

ابنِ تطبيق مهام كامل باستخدام واجهة JSONPlaceholder API (https://jsonplaceholder.typicode.com/todos). يجب أن يتضمن تطبيقك الميزات التالية: فئة عميل API قابلة لإعادة الاستخدام مع أساليب لعمليات GET و POST و PATCH و DELETE؛ دالة تحمل أول 20 مهمة وتعرضها في الصفحة مع مربعات اختيار تظهر حالة إكمالها؛ نموذج لإنشاء مهام جديدة باستخدام طلب POST؛ القدرة على تبديل حالة إكمال المهمة باستخدام طلب PATCH؛ زر حذف على كل مهمة يزيلها بطلب DELETE؛ عناصر تحكم في التقسيم إلى صفحات تسمح للمستخدم بالتنقل عبر صفحات من 20 مهمة لكل منها؛ ومرشح يسمح بعرض جميع المهام أو المهام المكتملة فقط أو المهام غير المكتملة فقط. نفذ حالات تحميل مناسبة مع مؤشر دوران أثناء تحميل البيانات ورسائل خطأ عند فشل الطلبات وزر إعادة محاولة يعيد تحميل البيانات. استخدم URLSearchParams لبناء جميع معلمات الاستعلام. اختبر تطبيقك بإنشاء وتحديث وحذف عدة مهام بالتتابع.