توسعه فرانت‌اند

پیاده‌سازی Infinite Scroll حرفه‌ای

بارگذاری افزایشی با قرارداد Pagination، Observer، خطا، Focus و URL؛ نمونه کامل محلی با مسیر دستی و خودکار اختیاری.

1404/12/23 4 دقیقه مطالعه 856 بازدید

Infinite Scroll هنگام نزدیک شدن کاربر به انتهای محتوا، بخش بعدی را اضافه می‌کند. این الگو برای مرور اکتشافی می‌تواند مناسب باشد، اما پیدا کردن یک آیتم مشخص، رسیدن به Footer و بازگشت به موقعیت قبلی را دشوارتر می‌کند. انتخاب آن باید با کار کاربر توضیح داده شود. فهرست سفارش یا نتیجه دارای تعداد محدود شاید به Pagination روشن نیاز داشته باشد؛ یک Feed طولانی نیاز متفاوتی دارد. هیچ ترکیب ابزار واحدی برای همه این صفحه‌ها بهترین نیست.

بارگذاری بیشتر باید یک قرارداد کامل داشته باشد: چه داده‌ای می‌آید، چه چیزی بعدی را مشخص می‌کند و پایان مجموعه چگونه شناخته می‌شود. Intersection Observer فقط زمان تقریبی رسیدن یک عنصر به محدوده مشاهده را گزارش می‌کند. خودش داده نمی‌گیرد، درخواست تکراری را حذف نمی‌کند و موقعیت کاربر را ذخیره نمی‌کند. کتابخانه Cache هم جای این تصمیم‌ها را نمی‌گیرد. پیش از کد، مسیر موفق، خطا، تغییر فیلتر و بازگشت از جزئیات را بنویسید.

Cursor و ترتیب پایدار را تعریف کنید

API می‌تواند Cursor یا شماره صفحه داشته باشد. Cursor برای برخی مجموعه‌های در حال تغییر مناسب‌تر است، اما معنی آن باید با ترتیب داده هماهنگ باشد. اگر Sort فقط یک تاریخ دارای مقدارهای تکراری است، مرز صفحه ممکن است مبهم شود؛ شناسه پایدار می‌تواند بخشی از ترتیب قطعی باشد. Client نباید Cursor را بدون قرارداد از آخرین شماره ظاهری آیتم حدس بزند. مقاله طراحی API درباره Pagination و خطا زمینه بیشتری دارد.

نتیجه باید پایان مجموعه را روشن کند. خالی بودن یک پاسخ موقت، شکست درخواست یا نبود صفحه بعد همیشه یک معنی ندارند. آیتم تکراری با شناسه پایدار تشخیص داده شود و حذف تکرار نباید داده متفاوت را خاموش کند. اگر محتوا هنگام مرور تغییر می‌کند، تصمیم بگیرید Feed Snapshot است یا مرتب تازه می‌شود. اضافه شدن آیتم در ابتدای مجموعه می‌تواند موقعیت کاربر را جابه‌جا کند؛ این رفتار بخشی از تجربه است و فقط مسئله ذخیره آرایه نیست.

Observer یک محرک است، نه تضمین مشاهده

مستند Intersection Observer مشاهده تقاطع عنصر هدف با Root را توضیح می‌دهد. Sentinel انتهای فهرست می‌تواند محرک دریافت بعدی باشد. rootMargin مثبت فرصت آغاز زودتر را می‌دهد، ولی مقدار خیلی بزرگ ممکن است داده‌ای را بارگذاری کند که کاربر هرگز نمی‌بیند. Callback بر اساس تقاطع است و تضمین نمی‌کند محتوا واقعاً خوانده شده؛ برای سنجش مشاهده تبلیغ یا مطالعه، تعریف دیگری لازم است.

اگر فهرست داخل Container دارای Scroll است، Root باید با همان ساختار هماهنگ شود. مشاهده viewport در حالی که کاربر داخل یک پنل مستقل می‌گردد ممکن است رفتار نامناسب بسازد. نبود پشتیبانی API نباید همه محتوا را غیرقابل دسترس کند. دکمه Load More مسیر قابل کنترل و مفیدی است. کاربر باید بتواند بارگذاری خودکار را متوقف کند، به‌ویژه وقتی اضافه شدن محتوا مانع رسیدن به بخش‌های پایانی صفحه می‌شود یا مصرف داده را بالا می‌برد.

درخواست هم‌زمان و خطا را مدیریت کنید

Guard بارگذاری مانع می‌شود چند Callback برای همان Cursor درخواست موازی بسازند. Cursor فقط پس از موفقیت تغییر کند تا خطا صفحه بعد را جا نیندازد. تغییر Query باید داده و درخواست قبلی را با سیاست روشن کنار بگذارد. پاسخ دیررس مربوط به فیلتر قبلی نباید به فهرست تازه اضافه شود. برای هر درخواست، زمینه فیلتر یا شناسه مناسب نگه دارید. مقاله Event Loop درباره ترتیب پاسخ و لغو توضیح دارد.

پس از شکست، پیام نزدیک کنترل و راه تلاش دوباره فراهم باشد. Observer نباید بی‌وقفه همان خطا را تکرار کند. حفظ آیتم‌های دریافت‌شده معمولاً از پاک کردن کل فهرست قابل پیش‌بینی‌تر است. حالت بارگذاری اولیه با بارگذاری صفحه بعد فرق دارد؛ Spinner تمام‌صفحه برای هر دسته تازه می‌تواند کار جاری را قطع کند. Retry خودکار باید حد و فاصله داشته باشد و درباره نتیجه نامعلوم، وعده موفقیت ندهد. کاربر باید بداند داده قبلی باقی است و فقط ادامه دریافت نشده است.

نمونه کامل با کنترل دستی و خودکار

فایل زیر را infinite-demo.html ذخیره کنید. نمونه بیست آیتم آموزشی در چهار دسته پنج‌تایی دارد و هیچ شبکه یا وابستگی بیرونی لازم ندارد. API محلی با تأخیر کوتاه شبیه‌سازی شده تا وضعیت انتظار دیده شود؛ این تأخیر Benchmark نیست. برای رفتار URL قابل پیش‌بینی، با Python نصب‌شده از python -m http.server 8000 در پوشه فایل استفاده و http://localhost:8000/infinite-demo.html را باز کنید. پارامتر page تعداد دسته‌های اولیه نمایش‌داده‌شده را در همین داده محدود مشخص می‌کند.

<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>بارگذاری افزایشی</title><style>body{font:1rem/1.8 Tahoma,sans-serif;max-width:40rem;margin:2rem auto;padding:1rem}li{padding:1.5rem;border:1px solid #111;margin:1rem 0}button,input{font:inherit;margin:.4rem;padding:.5rem}button:focus-visible,a:focus-visible{outline:3px solid #111;outline-offset:3px}#sentinel{height:1px}</style></head><body><h1>بیست آیتم آموزشی</h1><p>داده محلی است؛ پارامتر page تعداد دسته‌های اولیه را مشخص می‌کند.</p><label><input id="auto" type="checkbox">بارگذاری خودکار</label><ul id="list"></ul><div id="sentinel" aria-hidden="true"></div><p id="status" role="status"></p><button id="more" type="button">Load More</button><button id="fail" type="button">یک خطای آموزشی در درخواست بعدی</button><footer><p>پایان صفحه؛ بارگذاری خودکار اختیاری است.</p></footer><script>
const data=Array.from({length:20},(_,i)=>({id:'item-'+(i+1),title:'آیتم آموزشی '+(i+1)}));const list=document.getElementById('list'),more=document.getElementById('more'),auto=document.getElementById('auto'),status=document.getElementById('status'),sentinel=document.getElementById('sentinel');let cursor=0,loading=false,failNext=false;
function append(items){for(const item of items){const li=document.createElement('li');li.id=item.id;const link=document.createElement('a');link.href='#'+item.id;link.textContent=item.title;li.append(link);list.append(li)}}
function update(){more.disabled=loading||cursor>=data.length;more.textContent=cursor>=data.length?'پایان مجموعه':loading?'در حال دریافت':'Load More';if(cursor>=data.length)auto.checked=false}
function remember(){const url=new URL(location.href);url.searchParams.set('page',String(Math.ceil(cursor/5)));history.replaceState({pages:Math.ceil(cursor/5)},'',url)}
const requested=Number(new URL(location.href).searchParams.get('page')||1);const pages=Number.isInteger(requested)?Math.min(4,Math.max(1,requested)):1;append(data.slice(0,pages*5));cursor=pages*5;status.textContent=cursor+' از ۲۰ آیتم نمایش داده شد.';update();
async function load(){if(loading||cursor>=data.length)return;loading=true;update();status.textContent='در حال دریافت دسته بعدی';try{await new Promise(resolve=>setTimeout(resolve,300));if(failNext){failNext=false;throw new Error('خطای آموزشی؛ با Load More دوباره تلاش کنید.')}const items=data.slice(cursor,cursor+5);append(items);cursor+=items.length;remember();status.textContent=cursor+' از ۲۰ آیتم نمایش داده شد.'}catch(error){auto.checked=false;status.textContent=error.message}finally{loading=false;update()}}
more.onclick=load;document.getElementById('fail').onclick=()=>{failNext=true;status.textContent='درخواست بعدی یک بار شکست می‌خورد.'};let observer=null;if('IntersectionObserver' in window){observer=new IntersectionObserver(entries=>{if(auto.checked&&entries.some(entry=>entry.isIntersecting))load()},{rootMargin:'100px'});observer.observe(sentinel)}else{auto.disabled=true}
auto.onchange=()=>{if(auto.checked&&sentinel.getBoundingClientRect().top<innerHeight+100)load()};window.addEventListener('pagehide',()=>observer?.disconnect());
</script></body></html>

Load More همیشه مسیر دستی است و بارگذاری خودکار با Checkbox فعال می‌شود. اگر API مرورگر موجود نباشد، همان مسیر دستی باقی می‌ماند. loading از دریافت هم‌زمان جلوگیری می‌کند و cursor پس از دریافت موفق جلو می‌رود. دکمه خطای آموزشی فقط درخواست بعدی را یک بار شکست می‌دهد؛ پس از آن، کاربر با Load More دوباره تلاش می‌کند. در خطا، بارگذاری خودکار خاموش می‌شود تا حلقه تلاش بی‌پایان ایجاد نشود.

هر آیتم شناسه پایدار و لینک Anchor داخل همان صفحه دارد. URL پس از موفقیت با page تازه به‌روزرسانی می‌شود؛ باز کردن همان URL، دسته‌های اولیه مربوط را نشان می‌دهد. این روش برای داده محلی محدود نمونه است و Cache یا Scroll Restoration کامل یک Feed واقعی را پیاده نمی‌کند. در محصول، باید داده لازم قبل از بازگرداندن موقعیت دریافت شود. از ثبت یک شماره صفحه به تنهایی تضمین بازگشت دقیق به آیتم قبلی استخراج نکنید.

Focus و اعلان را پایدار نگه دارید

افزودن محتوا نباید Focus کاربر را خودکار به آیتم تازه ببرد. کاربر صفحه‌کلید ممکن است در حال خواندن یا فعال کردن کنترل قبلی باشد. در Load More دستی، روش رسیدن به نخستین آیتم تازه باید قابل فهم باشد، ولی پرش اجباری همیشه مناسب نیست. اعلان کوتاه تعداد دریافت‌شده کمک می‌کند و خواندن خودکار همه متن‌های تازه می‌تواند مزاحم باشد. status در نمونه فقط وضعیت کلی را می‌گوید و محتوای فهرست ساختار خواندنی خودش را دارد.

در پایان، کنترل باید پیام واضح داشته باشد و Footer قابل دسترس بماند. اگر تعداد کل نامعلوم است، عدد ساختگی نمایش ندهید. «ده آیتم بارگذاری شد» با «ده از صد آیتم» اطلاعات متفاوتی دارد. حالت Empty را هم از پایان یک فهرست دارای آیتم جدا کنید. مقاله دسترسی‌پذیری وب درباره حفظ تعامل قابل استفاده توضیح دارد. ظاهر حرکت نرم جای اعلام وضعیت و ترتیب منطقی لینک‌ها را نمی‌گیرد.

URL و کشف محتوا را مستقل طراحی کنید

راهنمای Google برای Pagination مسیرهای قابل کشف و تفاوت بارگذاری افزایشی را توضیح می‌دهد. موتور جست‌وجو نباید برای کشف همه محتوا به کلیک دکمه یا Scroll کاربر وابسته باشد. صفحه‌های قابل دسترسی و لینک‌های مناسب لازم‌اند. Fragment تنها یا تغییر وضعیت داخلی برنامه، به خودی خود مسیر جدا برای کشف همه صفحات نمی‌سازد. محتوای مهم باید نشانی و ساختار قابل فهم داشته باشد.

برای بازگشت از صفحه جزئیات، Query، Sort، دسته‌های دریافت‌شده و Anchor آیتم اهمیت دارند. Offset Scroll بدون داده‌ای که همان ارتفاع را بسازد کافی نیست. تغییر اندازه تصاویر یا حذف آیتم نیز موقعیت را عوض می‌کند. می‌توانید شناسه آیتم و فاصله آن را نگه دارید و پس از آماده شدن محتوا موقعیت را بازیابی کنید. سیاست Cache و تازگی باید با این انتظار سازگار باشد؛ داده تازه الزاماً همان ارتفاع و ترتیب Snapshot قبلی را ندارد.

کتابخانه و حافظه را با نیاز انتخاب کنید

Infinite Queries در TanStack Query مدیریت Pageها، پارامتر بعدی و وضعیت دریافت را توضیح می‌دهد. استفاده از آن می‌تواند Cache و چرخه درخواست را منظم کند، اما شرط Next Page و رفتار فیلتر هنوز به API شما وابسته‌اند. نام قدیمی React Query نباید باعث نادیده گرفتن نسخه و API نصب‌شده شود. ادعای بهترین ترکیب Observer و یک کتابخانه برای همه پروژه‌ها حذف می‌شود؛ ظرفیت تیم و نیاز محصول تعیین‌کننده‌اند.

افزودن همه آیتم‌ها به DOM در فهرست بسیار بلند هزینه دارد. Virtualization می‌تواند تعداد عناصر را محدود کند، اما Focus، ارتفاع و بازیابی Scroll نیازمند توجه‌اند. حذف صفحه‌های قدیمی از Cache هم روی بازگشت اثر می‌گذارد. مقاله عملکرد React درباره کاهش حجم کار توضیح دارد. محدودیت حافظه را به عنوان تصمیم محصول ثبت کنید تا مصرف‌کننده نداند چرا آیتمی که قبلاً دیده بود بعداً به دریافت دوباره نیاز دارد.

سناریو را همراه نتیجه تحویل دهید

در تحویل، مسیر بارگذاری دستی، خودکار، شکست، پایان و تغییر فیلتر را بنویسید. نمونه باید نشان دهد بعد از خطا Cursor کجا مانده و کاربر چگونه ادامه می‌دهد. اگر موقعیت ذخیره می‌شود، محدوده تضمین آن مشخص باشد. شروع مناسب یک فهرست با ترتیب و قرارداد روشن است؛ سپس بارگذاری افزایشی را اضافه کنید. Infinite Scroll زمانی قابل نگهداری می‌ماند که ادامه محتوا، اختیار کاربر و بازیابی کار قبلی همگی در همان مسیر طراحی شده باشند.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!