IntersectionObserver في سطرين
بدل ما تسأل كل شوية “العنصر ده ظاهر؟”، بتقول للمتصفح “قولّي لما يظهر”:
observe.ts
const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { // العنصر دخل } } }, { root: null, // null = الشاشة. أو عنصر scroller معيّن rootMargin: "0px 0px -10% 0px", // كبّر أو صغّر حدود الـ root threshold: 0.25, // يتنده لما ربع العنصر يبان },);io.observe(element);- العنصر ← حد الـ root: scroll لحد ما يعدّي
- حد الـ root ← الـ callback: عدّى الـ threshold
Scroll Triggered: يظهر لما يدخل
انزل… الكروت بتظهر أول ما تدخل
كارت 1
كارت 2
كارت 3
كارت 4
كارت 5
كارت 6
reveal.ts
const io = new IntersectionObserver( (entries) => { for (const e of entries) { if (!e.isIntersecting) continue; e.target.classList.add("is-in"); io.unobserve(e.target); // مرة واحدة: مش هيختفي لو رجعت لفوق } }, { threshold: 0.35 },);document.querySelectorAll(".reveal").forEach((el) => io.observe(el));reveal.css
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s, transform .5s; }.reveal.is-in { opacity: 1; transform: none; }@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; }}Infinite Scroll: هات أكتر قرب الآخر
بوست 1
بوست 2
بوست 3
بوست 4
بوست 5
بوست 6
بنحمّل…
الفكرة: عنصر صغير مخفي في آخر اللستة اسمه sentinel (الحارس). أول ما يقرب من الشاشة، بنجيب الصفحة اللي بعدها.
InfiniteList.tsx
import { useEffect, useRef } from "react";import { useInfiniteQuery } from "@tanstack/react-query";export function InfiniteList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ["posts"], queryFn: ({ pageParam }) => fetch(`/api/posts?cursor=${pageParam}`).then((r) => r.json()), initialPageParam: 0, getNextPageParam: (last) => last.nextCursor ?? undefined, }); const sentinel = useRef<HTMLDivElement>(null); useEffect(() => { const el = sentinel.current; if (!el) return; const io = new IntersectionObserver( ([e]) => { if (e?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { rootMargin: "0px 0px 400px 0px" }, // ابدأ تجيب قبل الآخر بـ 400px ); io.observe(el); return () => io.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); return ( <> {data?.pages.flatMap((p) => p.items).map((post) => <article key={post.id}>{post.title}</article>)} <div ref={sentinel} aria-hidden="true" /> {isFetchingNextPage && <p role="status">بنحمّل…</p>} </> );}rootMargin الموجب من تحت بيخلّي الطلب يبدأ قبل ما المستخدم يوصل للآخر، فميشوفش loading خالص لو النت كويس. ولو عايز تفاصيل useInfiniteQuery، في فصل كامل عنه في مذكرة TanStack Query.
عيوب الـ infinite scroll
الـ footer مبيتوصلّوشزرار back بيرجعك لأول اللستةصعب على قارئ الشاشة يعرف إن في جديدالـ DOM بيكبر لحد ما الصفحة تتقل
| المشكلة | الحل |
|---|---|
| الـ footer مستخبي | زرار “حمّل أكتر” بدل التحميل التلقائي، أو حط اللينكات المهمة فوق |
| back بيضيّع المكان | خزّن الصفحات في الكاش (TanStack Query بيعمل ده) و scrollRestoration |
| قارئ الشاشة | role="status" للـ loading، وأعلن عدد العناصر الجديدة |
| الـ DOM التقيل | virtualization (@tanstack/react-virtual) بعد كام مية عنصر |
خلّصت الفصل لما
🧠 اختبر نفسك
١) ليه unobserve في الـ reveal؟
عشان الـ animation تحصل مرة. من غيره ممكن تتكرر أو تشغّل كود من غير لازمة.
٢) rootMargin بـ 400px من تحت بيعمل إيه؟
بيعتبر العنصر ظاهر قبل ما يوصل للشاشة بـ 400px، فالتحميل يبدأ بدري.
٣) امتى زرار حمّل أكتر أحسن من التلقائي؟
لما في footer مهم، أو المستخدم محتاج يحس بالتحكم، أو قارئ الشاشة.