أساسيات JavaScript

واجهة برمجة Intersection Observer

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

ما هي واجهة برمجة Intersection Observer؟

توفر واجهة برمجة Intersection Observer طريقة لمراقبة التغييرات في تقاطع عنصر مستهدف مع عنصر أب أو مع منفذ العرض (viewport) للمستند بشكل غير متزامن. بكلمات أبسط، تخبرك عندما يدخل عنصر ما منطقة الصفحة المرئية أو يغادرها. قبل وجود هذه الواجهة البرمجية، كان المطورون يعتمدون على مستمعي أحداث التمرير مع getBoundingClientRect() لاكتشاف رؤية العناصر -- وهو نمط كان غير فعال وعرضة للأخطاء وتسبب في مشاكل أداء كبيرة لأنه يعمل على الخيط الرئيسي أثناء كل حدث تمرير.

تحل واجهة برمجة Intersection Observer هذه المشاكل عن طريق نقل اكتشاف الرؤية بعيدا عن الخيط الرئيسي. يتعامل المتصفح مع المراقبة داخليا ويخطر كود JavaScript الخاص بك فقط عندما يحدث تغيير ذو معنى في التقاطع. هذا أكثر كفاءة جوهريا من استطلاع مواقع العناصر عند كل حدث تمرير. الواجهة مدعومة في جميع المتصفحات الحديثة وأصبحت النهج القياسي لتنفيذ التحميل الكسول والتمرير اللانهائي والرسوم المتحركة المشغلة بالتمرير وتتبع الرؤية.

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

مُنشئ IntersectionObserver

تنشئ Intersection Observer باستدعاء مُنشئ IntersectionObserver بمعاملين: دالة رد اتصال تنطلق عند حدوث تغييرات في التقاطع، وكائن تكوين اختياري يتحكم في كيفية عمل المراقبة.

إنشاء Intersection Observer

// تستقبل دالة رد الاتصال معاملين:
// 1. entries: مصفوفة من كائنات IntersectionObserverEntry
// 2. observer: مرجع إلى المراقب نفسه
function handleIntersection(entries, observer) {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            console.log('العنصر مرئي:', entry.target);
            console.log('نسبة الرؤية:', entry.intersectionRatio);
        } else {
            console.log('العنصر غير مرئي:', entry.target);
        }
    });
}

// خيارات التكوين
const options = {
    root: null,           // منفذ العرض كجذر
    rootMargin: '0px',    // بدون هامش حول الجذر
    threshold: 0          // التشغيل بمجرد ظهور بكسل واحد
};

// إنشاء المراقب
const observer = new IntersectionObserver(handleIntersection, options);

// بدء مراقبة عنصر
const targetElement = document.querySelector('.my-element');
observer.observe(targetElement);
ملاحظة: لا تنطلق دالة رد الاتصال عند كل حدث تمرير. تنطلق فقط عندما يعبر العنصر المستهدف حد عتبة -- يدخل أو يغادر منطقة التقاطع المحددة. هذا ما يجعلها أكثر كفاءة بكثير من الاكتشاف القائم على التمرير.

فهم كائن الخيارات

يتحكم كائن الخيارات في سلوك المراقب بثلاث خصائص: root وrootMargin وthreshold. كل واحدة تشكل كيف ومتى يشغل المراقب دالة رد الاتصال.

خيار root

تحدد خاصية root العنصر الذي يعمل كمنفذ عرض لفحص التقاطع. عند تعيينها إلى null (الافتراضي)، يستخدم منفذ عرض المتصفح كجذر. يمكنك أيضا تعيينها لأي عنصر أب قابل للتمرير، وهو مفيد لاكتشاف الرؤية داخل حاوية قابلة للتمرير مثل شريط جانبي أو نافذة حوار ذات تمرير أو نافذة دردشة.

استخدام عنصر جذر مخصص

// مراقبة العناصر داخل حاوية قابلة للتمرير
const scrollContainer = document.querySelector('.scroll-container');

const observer = new IntersectionObserver(callback, {
    root: scrollContainer,   // استخدام هذه الحاوية بدلا من منفذ العرض
    rootMargin: '0px',
    threshold: 0.5           // 50% مرئي داخل الحاوية
});

// مراقبة العناصر داخل الحاوية القابلة للتمرير
scrollContainer.querySelectorAll('.list-item').forEach(item => {
    observer.observe(item);
});

خيار rootMargin

تزيد أو تنقص خاصية rootMargin من مربع حدود عنصر الجذر قبل حساب التقاطعات. تستخدم صيغة هامش CSS: "أعلى يمين أسفل يسار". القيم الموجبة توسع منطقة الجذر (تشغيل التقاطع قبل أن يكون العنصر مرئيا فعلا)، بينما القيم السالبة تقلصها (تتطلب أن يكون العنصر أبعد داخل منفذ العرض قبل التشغيل). هذا مفيد للغاية لتحميل المحتوى مسبقا قبل أن يمرر إلى منطقة العرض.

استخدام rootMargin للتحميل المسبق

// بدء تحميل الصور 200 بكسل قبل دخولها منفذ العرض
const lazyImageObserver = new IntersectionObserver(loadImage, {
    root: null,
    rootMargin: '200px 0px',  // 200 بكسل أعلى وأسفل منفذ العرض
    threshold: 0
});

// ستبدأ الصورة بالتحميل عندما تكون ضمن 200 بكسل
// من دخول منفذ العرض، مما يمنحها بداية مبكرة
document.querySelectorAll('img[data-src]').forEach(img => {
    lazyImageObserver.observe(img);
});

// rootMargin سالب: يتطلب أن يكون العنصر 100 بكسل داخل منفذ العرض
const deepVisibilityObserver = new IntersectionObserver(callback, {
    rootMargin: '-100px 0px',  // تقليص الجذر بمقدار 100 بكسل من الأعلى والأسفل
    threshold: 0
});
// يجب أن يكون العنصر على الأقل 100 بكسل داخل منفذ العرض للتشغيل

خيار threshold

تحدد خاصية threshold عند أي نسبة من رؤية العنصر المستهدف يجب أن تنطلق دالة رد الاتصال. تقبل إما رقما واحدا أو مصفوفة أرقام، كل منها بين 0 و 1. عتبة 0 تعني أن رد الاتصال ينطلق بمجرد ظهور بكسل واحد من العنصر. عتبة 1.0 تعني أن رد الاتصال ينطلق فقط عندما يكون العنصر بالكامل مرئيا. مصفوفة العتبات تطلق رد الاتصال عند كل مستوى رؤية محدد.

العمل مع العتبات

// عتبة واحدة: الإطلاق عند رؤية 50%
const halfVisibleObserver = new IntersectionObserver(callback, {
    threshold: 0.5
});

// عتبات متعددة: الإطلاق عند 0% و 25% و 50% و 75% و 100%
const granularObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        const ratio = entry.intersectionRatio;
        const element = entry.target;

        // التلاشي بالتناسب مع الرؤية
        element.style.opacity = ratio;

        // إضافة فئة عند رؤية أكثر من 50%
        if (ratio > 0.5) {
            element.classList.add('mostly-visible');
        } else {
            element.classList.remove('mostly-visible');
        }
    });
}, {
    threshold: [0, 0.25, 0.5, 0.75, 1.0]
});

// توليد عتبات عند كل زيادة 10%
const thresholds = Array.from({ length: 11 }, (_, i) => i / 10);
// النتيجة: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]

const smoothObserver = new IntersectionObserver(callback, {
    threshold: thresholds
});
نصيحة احترافية: كلما زادت العتبات زادت استدعاءات رد الاتصال. للفحوصات البسيطة "هل هو مرئي؟"، استخدم threshold: 0. للرسوم المتحركة التي تتقدم بناء على الرؤية، استخدم مصفوفة عتبات. تجنب استخدام عتبات كثيرة دون داعٍ، لأن كل عبور يشغل رد الاتصال.

خصائص IntersectionObserverEntry

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

فحص خصائص الإدخال

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        // منطقي: هل العنصر يتقاطع حاليا مع الجذر؟
        console.log('isIntersecting:', entry.isIntersecting);

        // رقم (0 إلى 1): ما نسبة العنصر المرئية؟
        console.log('intersectionRatio:', entry.intersectionRatio);

        // DOMRectReadOnly: مستطيل حدود العنصر المستهدف
        console.log('boundingClientRect:', entry.boundingClientRect);
        // الخصائص: top, right, bottom, left, width, height, x, y

        // DOMRectReadOnly: الجزء المرئي من المستهدف
        console.log('intersectionRect:', entry.intersectionRect);

        // DOMRectReadOnly: مستطيل حدود عنصر الجذر
        console.log('rootBounds:', entry.rootBounds);

        // Element: العنصر المستهدف المراقب
        console.log('target:', entry.target);

        // DOMHighResTimeStamp: متى حدث تغيير التقاطع
        console.log('time:', entry.time);
    });
}, { threshold: [0, 0.25, 0.5, 0.75, 1.0] });

الخصائص الأكثر استخداما هي isIntersecting (فحص منطقي بسيط)، وintersectionRatio (للتأثيرات التدريجية)، وtarget (لتحديد العنصر الذي شغل رد الاتصال). خاصية boundingClientRect مفيدة عندما تحتاج معلومات الموقع دون استدعاء getBoundingClientRect() بشكل منفصل، مما يفرض إعادة حساب التخطيط.

مراقبة وإلغاء مراقبة العناصر

يوفر مثيل المراقب طرقا لبدء وإيقاف مراقبة العناصر. التنظيف السليم مهم لمنع تسريبات الذاكرة، خاصة في تطبيقات الصفحة الواحدة حيث تضاف العناصر وتزال من DOM ديناميكيا.

طرق دورة حياة المراقب

const observer = new IntersectionObserver(callback, options);

// بدء مراقبة عنصر واحد
const element = document.querySelector('.target');
observer.observe(element);

// مراقبة عناصر متعددة بنفس المراقب
document.querySelectorAll('.animate-on-scroll').forEach(el => {
    observer.observe(el);
});

// إيقاف مراقبة عنصر محدد
observer.unobserve(element);

// إيقاف مراقبة جميع العناصر والتنظيف
observer.disconnect();

// النمط الشائع: إلغاء المراقبة بعد أول تقاطع (مشغل مرة واحدة)
const oneTimeObserver = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            // تنفيذ الإجراء
            entry.target.classList.add('animated');

            // إيقاف مراقبة هذا العنصر -- يحتاج للتشغيل مرة واحدة فقط
            obs.unobserve(entry.target);
        }
    });
});

// الحصول على جميع العناصر المراقبة حاليا
const currentEntries = observer.takeRecords();
تحذير: استدعِ دائما observer.disconnect() أو observer.unobserve() عندما لم تعد بحاجة إلى المراقب. في تطبيقات الصفحة الواحدة، عدم فصل المراقبين عند إلغاء تحميل المكونات يسبب تسريبات ذاكرة لأن المراقب يحتفظ بمراجع لعناصر DOM التي قد لا تكون موجودة في المستند بعد الآن.

التحميل الكسول للصور

التحميل الكسول للصور هو حالة الاستخدام الأكثر شيوعا وتأثيرا لـ Intersection Observer. بدلا من تحميل جميع الصور عند تحميل الصفحة (مما يهدر عرض النطاق الترددي ويبطئ العرض الأولي)، تحمل الصور فقط عندما توشك على التمرير إلى منطقة العرض. يمكن أن يحسن هذا بشكل كبير وقت تحميل الصفحة الأولي، خاصة على الصفحات الغنية بالصور.

تطبيق التحميل الكسول الكامل

// بنية HTML للصور الكسولة:
// <img data-src="photo.jpg" data-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
//      alt="الوصف" class="lazy" width="800" height="600">

class LazyImageLoader {
    constructor(options = {}) {
        this.loadedCount = 0;
        this.observedCount = 0;

        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: options.root || null,
                rootMargin: options.rootMargin || '200px 0px',
                threshold: 0
            }
        );
    }

    init(selector = 'img[data-src]') {
        const images = document.querySelectorAll(selector);
        this.observedCount = images.length;

        images.forEach(img => {
            // إضافة نمط مؤقت أثناء التحميل
            img.classList.add('lazy--pending');
            this.observer.observe(img);
        });

        console.log(`تم تهيئة المحمل الكسول: ${this.observedCount} صورة`);
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            if (!entry.isIntersecting) return;

            const img = entry.target;
            this.loadImage(img);
            this.observer.unobserve(img);
        });
    }

    loadImage(img) {
        // إعداد معالجات التحميل والخطأ
        img.addEventListener('load', () => {
            img.classList.remove('lazy--pending');
            img.classList.add('lazy--loaded');
            this.loadedCount++;
        }, { once: true });

        img.addEventListener('error', () => {
            img.classList.remove('lazy--pending');
            img.classList.add('lazy--error');
            console.warn('فشل تحميل الصورة:', img.dataset.src);
        }, { once: true });

        // تعيين سمات المصدر الحقيقية
        if (img.dataset.srcset) {
            img.srcset = img.dataset.srcset;
            delete img.dataset.srcset;
        }

        if (img.dataset.src) {
            img.src = img.dataset.src;
            delete img.dataset.src;
        }
    }

    destroy() {
        this.observer.disconnect();
    }
}

// تهيئة التحميل الكسول
const lazyLoader = new LazyImageLoader({
    rootMargin: '300px 0px'  // بدء التحميل 300 بكسل قبل الرؤية
});

lazyLoader.init();

// CSS لحالات التحميل الكسول:
// .lazy--pending { background: #f0f0f0; filter: blur(5px); }
// .lazy--loaded { animation: fadeIn 0.3s ease-in; }
// .lazy--error { background: #fee; }
ملاحظة: تدعم المتصفحات الحديثة التحميل الكسول الأصلي مع <img loading="lazy">. ومع ذلك، نهج Intersection Observer يمنحك مزيدا من التحكم في rootMargin (كيفية البدء مبكرا في التحميل) ورسوم التحميل المتحركة ومعالجة الأخطاء وتتبع المقاييس. يمكنك الجمع بين الاثنين: استخدم التحميل الكسول الأصلي كخط أساس وعززه بـ Intersection Observer للميزات المتقدمة.

التمرير اللانهائي

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

التمرير اللانهائي مع Intersection Observer

class InfiniteScroll {
    constructor(container, options = {}) {
        this.container = container;
        this.endpoint = options.endpoint;
        this.page = 1;
        this.isLoading = false;
        this.hasMore = true;

        // إنشاء عنصر حارس في الأسفل
        this.sentinel = document.createElement('div');
        this.sentinel.className = 'infinite-scroll-sentinel';
        this.sentinel.setAttribute('aria-hidden', 'true');
        this.container.appendChild(this.sentinel);

        // مراقبة الحارس
        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: null,
                rootMargin: '400px 0px',  // تحميل 400 بكسل قبل الوصول للأسفل
                threshold: 0
            }
        );

        this.observer.observe(this.sentinel);
    }

    handleIntersection(entries) {
        const sentinelEntry = entries[0];

        if (sentinelEntry.isIntersecting && !this.isLoading && this.hasMore) {
            this.loadMore();
        }
    }

    async loadMore() {
        this.isLoading = true;
        this.showLoadingIndicator();

        try {
            const response = await fetch(
                `${this.endpoint}?page=${this.page + 1}`
            );

            if (!response.ok) throw new Error('فشل التحميل');

            const data = await response.json();

            if (data.items.length === 0) {
                this.hasMore = false;
                this.showEndMessage();
                this.observer.disconnect();
                return;
            }

            this.page++;
            this.appendItems(data.items);
            this.hasMore = data.hasMore;

            if (!this.hasMore) {
                this.showEndMessage();
                this.observer.disconnect();
            }
        } catch (error) {
            this.showError('فشل تحميل المزيد. اضغط لإعادة المحاولة.');
        } finally {
            this.isLoading = false;
            this.hideLoadingIndicator();
        }
    }

    appendItems(items) {
        const fragment = document.createDocumentFragment();

        items.forEach(item => {
            const element = this.createItemElement(item);
            fragment.appendChild(element);
        });

        // إدراج العناصر قبل الحارس
        this.container.insertBefore(fragment, this.sentinel);
    }

    createItemElement(item) {
        const article = document.createElement('article');
        article.className = 'feed-item';
        article.innerHTML = `
            <h3>${item.title}</h3>
            <p>${item.excerpt}</p>
            <time datetime="${item.date}">${item.formattedDate}</time>
        `;
        return article;
    }

    showLoadingIndicator() {
        this.sentinel.innerHTML = '<div class="spinner">جارٍ التحميل...</div>';
    }

    hideLoadingIndicator() {
        this.sentinel.innerHTML = '';
    }

    showEndMessage() {
        this.sentinel.innerHTML = '<p class="end-message">وصلت إلى النهاية.</p>';
    }

    showError(message) {
        this.sentinel.innerHTML = `<button class="retry-btn">${message}</button>`;
        this.sentinel.querySelector('.retry-btn').addEventListener('click', () => {
            this.loadMore();
        }, { once: true });
    }

    destroy() {
        this.observer.disconnect();
        this.sentinel.remove();
    }
}

// تهيئة التمرير اللانهائي
const feed = new InfiniteScroll(
    document.querySelector('.feed-container'),
    { endpoint: '/api/articles' }
);

الرسوم المتحركة المشغلة بالتمرير

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

نظام الرسوم المتحركة المشغلة بالتمرير

class ScrollAnimator {
    constructor(options = {}) {
        this.animatedCount = 0;

        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: null,
                rootMargin: options.rootMargin || '-50px 0px',
                threshold: options.threshold || 0.15
            }
        );
    }

    init(selector = '[data-animate]') {
        const elements = document.querySelectorAll(selector);

        elements.forEach(el => {
            // تعيين حالة الإخفاء الأولية
            el.classList.add('scroll-hidden');

            // تحليل تأخير الرسوم المتحركة من سمة البيانات
            const delay = el.dataset.animateDelay || '0';
            el.style.transitionDelay = delay + 'ms';

            this.observer.observe(el);
        });
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const el = entry.target;
                const animationType = el.dataset.animate || 'fade-up';

                // إضافة فئة الرسوم المتحركة
                el.classList.remove('scroll-hidden');
                el.classList.add('scroll-visible', `animate-${animationType}`);

                this.animatedCount++;

                // إلغاء المراقبة: تحريك مرة واحدة فقط
                this.observer.unobserve(el);
            }
        });
    }

    destroy() {
        this.observer.disconnect();
    }
}

// تهيئة نظام الرسوم المتحركة
const animator = new ScrollAnimator({
    rootMargin: '-80px 0px',  // التشغيل 80 بكسل بعد دخول منفذ العرض
    threshold: 0.15             // 15% مرئي على الأقل
});
animator.init();

// استخدام HTML:
// <div data-animate="fade-up">يتلاشى لأعلى عند الظهور</div>
// <div data-animate="fade-left" data-animate-delay="200">ينزلق من اليسار</div>
// <div data-animate="scale" data-animate-delay="400">يتكبر</div>

// CSS:
// .scroll-hidden {
//     opacity: 0;
//     transform: translateY(30px);
//     transition: opacity 0.6s ease, transform 0.6s ease;
// }
// .scroll-visible { opacity: 1; transform: none; }
// .animate-fade-left.scroll-hidden { transform: translateX(-30px); }
// .animate-scale.scroll-hidden { transform: scale(0.9); }

هذا النمط قوي لأن Intersection Observer يتعامل مع الاكتشاف بكفاءة، بينما يتعامل CSS مع الرسوم المتحركة بسلاسة على GPU. طبقة JavaScript ضئيلة -- تقوم فقط بتبديل الفئات. سمة data-animate على كل عنصر تحدد نمط الرسوم المتحركة المطبق، وسمة data-animate-delay الاختيارية تنشئ تأثيرات دخول متتابعة عندما تدخل عناصر متعددة منفذ العرض في وقت واحد.

اكتشاف الرأس اللاصق

نمط شائع في واجهة المستخدم يتضمن تغيير مظهر الرأس عندما يمرر المستخدم بعد نقطة معينة -- إضافة ظل، تغيير لون الخلفية، أو تقليص الارتفاع. بدلا من فحص window.scrollY عند كل حدث تمرير، يمكنك مراقبة عنصر حارس موضوع أسفل الرأس مباشرة. عندما يخرج الحارس من منطقة العرض، يصبح الرأس "ملتصقا".

الرأس اللاصق مع Intersection Observer

// HTML:
// <div id="header-sentinel"></div>
// <header class="site-header">...</header>

class StickyHeaderDetector {
    constructor(headerEl, sentinelEl) {
        this.header = headerEl;
        this.sentinel = sentinelEl;

        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: null,
                rootMargin: '0px',
                threshold: 0
            }
        );

        this.observer.observe(this.sentinel);
    }

    handleIntersection(entries) {
        const entry = entries[0];

        if (!entry.isIntersecting) {
            // الحارس فوق منفذ العرض: الرأس ملتصق
            this.header.classList.add('header--stuck');
            this.header.setAttribute('aria-label', 'تنقل ثابت');
        } else {
            // الحارس مرئي: الرأس في وضعه الطبيعي
            this.header.classList.remove('header--stuck');
            this.header.removeAttribute('aria-label');
        }
    }

    destroy() {
        this.observer.disconnect();
    }
}

// CSS للرأس اللاصق:
// .site-header {
//     position: sticky;
//     top: 0;
//     transition: box-shadow 0.3s ease, background-color 0.3s ease;
// }
// .header--stuck {
//     box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
//     background-color: rgba(255, 255, 255, 0.98);
// }

const header = document.querySelector('.site-header');
const sentinel = document.getElementById('header-sentinel');
const stickyDetector = new StickyHeaderDetector(header, sentinel);

تتبع قابلية مشاهدة الإعلانات

في الإعلانات الرقمية، تقيس قابلية المشاهدة ما إذا كان الإعلان قد شوهد فعلا من قبل المستخدم. يتطلب المعيار الصناعي (المحدد من قبل مجلس تصنيف الوسائط) أن يكون 50% على الأقل من بكسلات الإعلان مرئية في منفذ العرض لمدة ثانية واحدة متواصلة على الأقل. Intersection Observer مناسب تماما لهذا القياس لأنه يمكنه تتبع نسبة الرؤية الدقيقة دون حسابات تمرير مكلفة.

متتبع قابلية مشاهدة الإعلانات

class AdViewabilityTracker {
    constructor() {
        this.viewabilityTimers = new Map();
        this.reportedAds = new Set();
        this.VIEWABILITY_THRESHOLD = 0.5;  // 50% مرئي
        this.VIEWABILITY_DURATION = 1000;  // ثانية واحدة

        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: null,
                rootMargin: '0px',
                threshold: [0, 0.25, 0.5, 0.75, 1.0]
            }
        );
    }

    trackAd(adElement) {
        if (!adElement.id) {
            adElement.id = 'ad-' + Math.random().toString(36).slice(2, 9);
        }
        this.observer.observe(adElement);
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            const adId = entry.target.id;

            // تم الإبلاغ بالفعل -- تخطي
            if (this.reportedAds.has(adId)) return;

            if (entry.intersectionRatio >= this.VIEWABILITY_THRESHOLD) {
                // الإعلان يلبي عتبة الرؤية -- بدء المؤقت
                if (!this.viewabilityTimers.has(adId)) {
                    const timerId = setTimeout(() => {
                        this.reportViewable(entry.target);
                    }, this.VIEWABILITY_DURATION);

                    this.viewabilityTimers.set(adId, timerId);
                }
            } else {
                // الإعلان لم يعد يلبي العتبة -- إلغاء المؤقت
                if (this.viewabilityTimers.has(adId)) {
                    clearTimeout(this.viewabilityTimers.get(adId));
                    this.viewabilityTimers.delete(adId);
                }
            }
        });
    }

    reportViewable(adElement) {
        const adId = adElement.id;
        this.reportedAds.add(adId);
        this.viewabilityTimers.delete(adId);

        // إرسال حدث قابلية المشاهدة إلى التحليلات
        console.log(`الإعلان ${adId} قابل للمشاهدة (50%+ مرئي لمدة ثانية واحدة+)`);

        // إيقاف مراقبة هذا الإعلان
        this.observer.unobserve(adElement);

        // إرسال إلى نقطة نهاية التحليلات
        navigator.sendBeacon('/api/analytics/viewability', JSON.stringify({
            adId: adId,
            timestamp: Date.now(),
            placement: adElement.dataset.placement
        }));
    }

    destroy() {
        this.viewabilityTimers.forEach(timerId => clearTimeout(timerId));
        this.viewabilityTimers.clear();
        this.observer.disconnect();
    }
}

// تتبع جميع وحدات الإعلانات في الصفحة
const viewabilityTracker = new AdViewabilityTracker();
document.querySelectorAll('.ad-unit').forEach(ad => {
    viewabilityTracker.trackAd(ad);
});

تمييز التنقل القائم على الأقسام

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

تمييز التنقل بالقسم النشط

class SectionNavigator {
    constructor(navSelector, sectionSelector) {
        this.navLinks = document.querySelectorAll(`${navSelector} a`);
        this.sections = document.querySelectorAll(sectionSelector);
        this.currentSection = null;

        // استخدام rootMargin سالب لتتطلب أن يكون القسم
        // جزئيا في النصف العلوي من منفذ العرض
        this.observer = new IntersectionObserver(
            this.handleIntersection.bind(this),
            {
                root: null,
                rootMargin: '-20% 0px -60% 0px',
                threshold: 0
            }
        );

        this.sections.forEach(section => {
            this.observer.observe(section);
        });
    }

    handleIntersection(entries) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                this.setActiveSection(entry.target.id);
            }
        });
    }

    setActiveSection(sectionId) {
        if (this.currentSection === sectionId) return;
        this.currentSection = sectionId;

        // إزالة الفئة النشطة من جميع الروابط
        this.navLinks.forEach(link => {
            link.classList.remove('nav-active');
            link.removeAttribute('aria-current');
        });

        // إضافة الفئة النشطة للرابط المطابق
        const activeLink = document.querySelector(
            `a[href="#${sectionId}"]`
        );

        if (activeLink) {
            activeLink.classList.add('nav-active');
            activeLink.setAttribute('aria-current', 'true');
        }
    }

    destroy() {
        this.observer.disconnect();
    }
}

// بنية HTML:
// <nav class="table-of-contents">
//     <a href="#introduction">المقدمة</a>
//     <a href="#getting-started">البدء</a>
//     <a href="#advanced">الاستخدام المتقدم</a>
//     <a href="#api-reference">مرجع API</a>
// </nav>
//
// <section id="introduction">...</section>
// <section id="getting-started">...</section>
// <section id="advanced">...</section>
// <section id="api-reference">...</section>

const sectionNav = new SectionNavigator(
    '.table-of-contents',
    'section[id]'
);
نصيحة احترافية: قيمة rootMargin '-20% 0px -60% 0px' تنشئ منطقة اكتشاف في الجزء العلوي من منفذ العرض. هذا يعني أن القسم يعتبر "نشطا" عندما يحتل أعلى 20-40% من الشاشة، مما يتوافق مع كيفية إدراك المستخدمين بشكل طبيعي لأي قسم يقرؤون. اضبط هذه النسب بناء على تخطيط المحتوى وارتفاع الرأس.

الأداء: Intersection Observer مقابل أحداث التمرير

ميزة أداء Intersection Observer على مستمعي أحداث التمرير كبيرة وقابلة للقياس. أحداث التمرير تنطلق بشكل متزامن على الخيط الرئيسي، مما يمنع تنفيذ JavaScript الآخر ويمكن أن يسبب تأخرا. كل استدعاء لـ getBoundingClientRect() داخل معالج التمرير يجبر المتصفح على إجراء إعادة حساب تخطيط متزامنة. عندما تراقب عناصر متعددة، تتضاعف هذه التكلفة.

يعمل Intersection Observer بشكل غير متزامن ومحسن من قبل المتصفح على مستوى منخفض. يجمع المتصفح فحوصات التقاطع ويشغلها في وقت مناسب، عادة خلال فترات الخمول أو قبل الرسم. لا يفرض إعادة حسابات التخطيط لأنه يستخدم بيانات هندسية مخزنة مؤقتا من خط أنابيب العرض الداخلي للمتصفح.

مقارنة الأداء: حدث التمرير مقابل Intersection Observer

// سيء: نهج حدث التمرير (متزامن، الخيط الرئيسي)
function checkVisibilityWithScroll() {
    const elements = document.querySelectorAll('.track-visibility');

    window.addEventListener('scroll', function() {
        // يعمل عند كل حدث تمرير (100+ في الثانية محتملة)
        elements.forEach(el => {
            // getBoundingClientRect() يفرض تخطيطا متزامنا
            const rect = el.getBoundingClientRect();
            const isVisible = (
                rect.top < window.innerHeight &&
                rect.bottom > 0
            );

            if (isVisible) {
                el.classList.add('visible');
            }
        });
    });
}
// المشاكل:
// - يعمل عند كل حدث تمرير (تكرار عالٍ)
// - يفرض تخطيطا متزامنا لكل عنصر
// - يمنع الخيط الرئيسي
// - ارتفاع استخدام المعالج أثناء التمرير

// جيد: نهج Intersection Observer (غير متزامن، خارج الخيط الرئيسي)
function checkVisibilityWithObserver() {
    const observer = new IntersectionObserver((entries) => {
        // ينطلق فقط عند تغيير التقاطع -- ليس عند كل تمرير
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                entry.target.classList.add('visible');
                observer.unobserve(entry.target);
            }
        });
    }, { threshold: 0 });

    document.querySelectorAll('.track-visibility').forEach(el => {
        observer.observe(el);
    });
}
// الفوائد:
// - ينطلق فقط عند تغيير التقاطع (تكرار منخفض)
// - بدون إعادة حساب التخطيط
// - يعمل بشكل غير متزامن خارج الخيط الرئيسي
// - استخدام ضئيل للمعالج أثناء التمرير

// القياس: تحديد الفرق كميا
let scrollCallCount = 0;
let observerCallCount = 0;

window.addEventListener('scroll', () => scrollCallCount++, { passive: true });

const measureObserver = new IntersectionObserver(() => {
    observerCallCount++;
}, { threshold: [0, 0.5, 1.0] });

// بعد 10 ثوانٍ من التمرير:
// scrollCallCount قد يكون 800+
// observerCallCount قد يكون 12

في اختبارات الأداء لمراقبة 100 عنصر في صفحة، يمكن أن يستهلك نهج حدث التمرير 15-25 مللي ثانية من وقت الخيط الرئيسي لكل حدث تمرير، مما يسبب تأخرا مرئيا عند 60 إطارا في الثانية (الذي يسمح بـ 16.7 مللي ثانية فقط لكل إطار). نهج Intersection Observer لنفس 100 عنصر عادة يضيف أقل من 1 مللي ثانية من الحمل الإضافي لكل تغيير في التقاطع، وتحدث هذه الفحوصات بشكل غير متزامن، دون أن تمنع خط أنابيب العرض أبدا.

دمج مراقبين متعددين

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

تنسيق مراقبين متعددين في صفحة واحدة

class PageIntersectionManager {
    constructor() {
        this.observers = new Map();
    }

    createObserver(name, callback, options = {}) {
        const observer = new IntersectionObserver(callback, {
            root: options.root || null,
            rootMargin: options.rootMargin || '0px',
            threshold: options.threshold || 0
        });

        this.observers.set(name, observer);
        return observer;
    }

    initAll() {
        // 1. التحميل الكسول: rootMargin سخي، عتبة بسيطة
        const lazyObserver = this.createObserver('lazy', (entries, obs) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    loadLazyImage(entry.target);
                    obs.unobserve(entry.target);
                }
            });
        }, { rootMargin: '300px 0px', threshold: 0 });

        document.querySelectorAll('img[data-src]').forEach(img => {
            lazyObserver.observe(img);
        });

        // 2. رسوم التمرير المتحركة: rootMargin سالب، عتبة منخفضة
        const animObserver = this.createObserver('animations', (entries, obs) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    entry.target.classList.add('animate-in');
                    obs.unobserve(entry.target);
                }
            });
        }, { rootMargin: '-50px 0px', threshold: 0.15 });

        document.querySelectorAll('[data-animate]').forEach(el => {
            animObserver.observe(el);
        });

        // 3. تنقل الأقسام: rootMargin مخصص لموقع القراءة
        const navObserver = this.createObserver('navigation', (entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    highlightNavLink(entry.target.id);
                }
            });
        }, { rootMargin: '-20% 0px -60% 0px', threshold: 0 });

        document.querySelectorAll('section[id]').forEach(section => {
            navObserver.observe(section);
        });

        // 4. التحليلات: تتبع الوقت المقضي في كل قسم
        const analyticsObserver = this.createObserver('analytics', (entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    startSectionTimer(entry.target.id);
                } else {
                    stopSectionTimer(entry.target.id);
                }
            });
        }, { threshold: 0.5 });

        document.querySelectorAll('.trackable-section').forEach(section => {
            analyticsObserver.observe(section);
        });
    }

    destroyAll() {
        this.observers.forEach(observer => observer.disconnect());
        this.observers.clear();
    }

    destroy(name) {
        const observer = this.observers.get(name);
        if (observer) {
            observer.disconnect();
            this.observers.delete(name);
        }
    }
}

// الاستخدام
const pageManager = new PageIntersectionManager();
pageManager.initAll();

// التنظيف عند تنقل الصفحة
window.addEventListener('beforeunload', () => {
    pageManager.destroyAll();
});
خطأ شائع: إنشاء Intersection Observer جديد لكل عنصر بدلا من مشاركة مراقب واحد عبر عناصر متعددة. كل مراقب له بعض تكلفة الذاكرة والإعداد. النمط الصحيح هو إنشاء مراقب واحد لكل مجموعة تكوين واستدعاء observe() لكل عنصر يشترك في ذلك التكوين. مراقب واحد يمكنه مراقبة مئات العناصر بكفاءة.

تمرين عملي

ابنِ صفحة عرض شاملة تستخدم واجهة برمجة Intersection Observer لأربع ميزات مختلفة تعمل معا. أولا، أنشئ صفحة تحتوي على 20 بطاقة صور مؤقتة تستخدم التحميل الكسول مع rootMargin بقيمة 300 بكسل. اعرض عدادا يوضح عدد الصور التي تم تحميلها من الإجمالي. ثانيا، نفذ رسوما متحركة مشغلة بالتمرير على 10 كتل محتوى على الأقل باستخدام ثلاثة أنواع رسوم متحركة مختلفة (fade-up و fade-left و scale) مع تأخيرات متتابعة. ثالثا، أضف شريطا جانبيا لاصقا للتنقل يميز القسم الحالي أثناء تمرير المستخدم عبر خمسة أقسام محتوى على الأقل. يجب أن يستخدم التمييز rootMargin بقيمة -20% 0px -60% 0px بحيث ينشط عندما تصل الأقسام إلى الجزء العلوي من منفذ العرض. رابعا، أضف قسم تمرير لانهائي في الأسفل يحمل مقتطفات مقالات محاكاة (استخدم setTimeout لمحاكاة طلب شبكة) ويعرض مؤشر تحميل أثناء الجلب. أضف لوحة أداء تعرض عدد استدعاءات رد اتصال المراقب مقابل عدد أحداث التمرير التي حدثت خلال نفس الفترة. أخيرا، تأكد من تنظيف جميع المراقبين بشكل صحيح عند إلغاء تحميل الصفحة. هذا التمرين سيمنحك خبرة عملية مع كل حالة استخدام رئيسية لواجهة برمجة Intersection Observer ويوضح مزايا أدائها على أحداث التمرير.