پیاده‌سازی اسکرول بینهایت در React

اسکرول بینهایت یک تکنیک قدرتمند است که تجربه کاربری را با بارگذاری داینامیک محتوا بهبود می‌بخشد. در این مقاله، سه روش مختلف برای پیاده‌سازی اسکرول بینهایت در React را بررسی خواهیم کرد که شامل راهکارهای سفارشی و مبتنی بر کتابخانه‌های آماده می‌شود.

رایج‌ترین روش برای پیاده‌سازی اسکرول بینهایت، استفاده از کتابخانه‌های آماده React مانند

react-infinite-scroll-component
react-infinite-scroll-component و
react-window-infinite-loader
react-window-infinite-loader است:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import InfiniteScroll from "react-infinite-scroll-component";
import { useState, useEffect } from 'react';
function App() {
const [page, setPage] = useState(1);
const [products, setProducts] = useState([]);
const [totalProducts, setTotalProducts] = useState(0);
const fetchData = async (page: number) => {
try {
const res = await fetch(
`https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}`
);
const data = await res.json();
if (res.ok) {
setProducts((prevItems) => [...prevItems, ...data.products]);
page === 1 && setTotalProducts(() => data.total);
}
} catch (error) {
console.log(error)
}
};
useEffect(() => {
let subscribed = true;
(async () => {
if (subscribed) {
await fetchData(1);
}
})();
return () => {
subscribed = false;
};
}, []);
const handleLoadMoreData = () => {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
};
return (
<InfiniteScroll
dataLength={products.length}
next={handleLoadMoreData}
hasMore={totalProducts > products.length}
loader={<p>Loading...</p>}
endMessage={<p>No more data to load.</p>}
>
<div>
{products.map((product) => (
<div key={product.id}>
<h2>
{product.title} - {product.id}
</h2>
</div>
))}
</div>
</InfiniteScroll>
);
}
import InfiniteScroll from "react-infinite-scroll-component"; import { useState, useEffect } from 'react'; function App() { const [page, setPage] = useState(1); const [products, setProducts] = useState([]); const [totalProducts, setTotalProducts] = useState(0); const fetchData = async (page: number) => { try { const res = await fetch( `https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}` ); const data = await res.json(); if (res.ok) { setProducts((prevItems) => [...prevItems, ...data.products]); page === 1 && setTotalProducts(() => data.total); } } catch (error) { console.log(error) } }; useEffect(() => { let subscribed = true; (async () => { if (subscribed) { await fetchData(1); } })(); return () => { subscribed = false; }; }, []); const handleLoadMoreData = () => { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); }; return ( <InfiniteScroll dataLength={products.length} next={handleLoadMoreData} hasMore={totalProducts > products.length} loader={<p>Loading...</p>} endMessage={<p>No more data to load.</p>} > <div> {products.map((product) => ( <div key={product.id}> <h2> {product.title} - {product.id} </h2> </div> ))} </div> </InfiniteScroll> ); }
import InfiniteScroll from "react-infinite-scroll-component";
import { useState, useEffect } from 'react'; 

function App() {
  const [page, setPage] = useState(1);
  const [products, setProducts] = useState([]);
  const [totalProducts, setTotalProducts] = useState(0);

  const fetchData = async (page: number) => {
    try {
      const res = await fetch(
        `https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}`
      );
      const data = await res.json();
      if (res.ok) {
        setProducts((prevItems) => [...prevItems, ...data.products]);
        page === 1 && setTotalProducts(() => data.total);
      }
    } catch (error) {
      console.log(error)
    }
  };

  useEffect(() => {
    let subscribed = true;
    (async () => {
      if (subscribed) {
        await fetchData(1);
      }
    })();
    return () => {
      subscribed = false;
    };
  }, []);

  const handleLoadMoreData = () => {
    setPage((prevPage) => {
      const nextPage = prevPage + 1;
      fetchData(nextPage);
      return nextPage;
    });
  };
  
  return (
    <InfiniteScroll
      dataLength={products.length}
      next={handleLoadMoreData}
      hasMore={totalProducts > products.length}
      loader={<p>Loading...</p>}
      endMessage={<p>No more data to load.</p>}
    >
      <div>
        {products.map((product) => (
          <div key={product.id}>
            <h2>
              {product.title} - {product.id}
            </h2>
          </div>
        ))}
      </div>
    </InfiniteScroll>
  );
}

با این حال، روش‌های مختلفی برای ایجاد این افکت وجود دارد. در این مقاله، سه رویکرد منحصربه‌فرد برای پیاده‌سازی اسکرول بینهایت در اپلیکیشن‌های React را بررسی خواهیم کرد:

  • ساخت کل پیاده‌سازی از ابتدا: ایجاد یک راهکار سفارشی برای اسکرول بینهایت که کنترل کامل روی سفارشی‌سازی و عملکرد را فراهم می‌کند.
  • استفاده از یک کتابخانه یا کامپوننت آماده برای اسکرول بینهایت: بهره‌گیری از کتابخانه‌ها یا کامپوننت‌های آماده مانند
    react-infinite-scroll-component
    react-infinite-scroll-component و
    react-window-infinite-loader
    react-window-infinite-loader که در زمان و انرژی صرفه‌جویی می‌کنند و همچنان امکان سفارشی‌سازی را ارائه می‌دهند.
  • استفاده از Intersection Observer API: استفاده از Intersection Observer API که امکان تشخیص بهینه و کارآمد نمایش المنت‌ها در صفحه را فراهم می‌کند و در نتیجه باعث بارگذاری محتوا می‌شود.

اسکرول بینهایت چیست؟

اسکرول بینهایت نیاز به صفحه‌بندی سنتی را از بین می‌برد. به جای اینکه کاربران بین صفحات مختلف جابه‌جا شوند، می‌توانند بدون توقف اسکرول کنند و محتوای بیشتری ببینند، که این کار تجربه‌ای جذاب‌تر و شهودی‌تر را فراهم می‌کند.

این تکنیک به طور گسترده در پلتفرم‌های شبکه اجتماعی مانند اینستاگرام، X و تیک‌تاک استفاده می‌شود تا کاربران بتوانند بدون وقفه فیدهای پر از تصاویر و ویدیوها را مرور کنند.

اکنون که با مفهوم اسکرول بینهایت آشنا شدیم، وارد بخش آموزش می‌شویم.

تنظیمات اولیه اپلیکیشن React

در ابتدا، یک پایه مشترک را ایجاد می‌کنیم که در تمام روش‌های مختلف پیاده‌سازی اسکرول بینهایت استفاده خواهد شد.

راه‌اندازی اپلیکیشن React

برای شروع، ابتدا یک اپلیکیشن React را با استفاده از Vite راه‌اندازی می‌کنیم. برای این کار، دستورات زیر را در ترمینال اجرا می‌نماییم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npm create vite@latest ecommerce-app -- --template react-ts
cd ecommerce-app
npm i
npm create vite@latest ecommerce-app -- --template react-ts cd ecommerce-app npm i
npm create vite@latest ecommerce-app -- --template react-ts
cd ecommerce-app
npm i

دستورات بالا یک اپلیکیشن React با تایپ اسکریپت ایجاد کرده و تمام وابستگی‌های لازم را نصب می‌کنند. در مرحله بعد، در فایل

App.tsx
App.tsx state اولیه کامپوننت را تنظیم می‌کنیم. این شامل لیست آیتم‌هایی که باید نمایش داده شوند، نشانگرهای بارگذاری و خطا، و یک متغیر برای پیگیری تعداد کل محصولات موجود خواهد بود.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// App.tsx
import React, { useState, useEffect } from 'react';
type ProductItem = {
id: number;
title: string;
description: string;
category: string;
price: number;
rating: number;
thumbnail: string;
brand: string;
discountPercentage: number;
};
function App() {
const [loading, setLoading] = useState(true);
const [products, setProducts] = useState<ProductItem[]>([]);
const [totalProducts, setTotalProducts] = useState(0);
const [error, setError] = useState<null|Error>(null);
// rest of component
}
// App.tsx import React, { useState, useEffect } from 'react'; type ProductItem = { id: number; title: string; description: string; category: string; price: number; rating: number; thumbnail: string; brand: string; discountPercentage: number; }; function App() { const [loading, setLoading] = useState(true); const [products, setProducts] = useState<ProductItem[]>([]); const [totalProducts, setTotalProducts] = useState(0); const [error, setError] = useState<null|Error>(null); // rest of component }
// App.tsx
import React, { useState, useEffect } from 'react';

type ProductItem = {
  id: number;
  title: string;
  description: string;
  category: string;
  price: number;
  rating: number;
  thumbnail: string;
  brand: string;
  discountPercentage: number;
};

function App() {
  const [loading, setLoading] = useState(true);
  const [products, setProducts] = useState<ProductItem[]>([]);
  const [totalProducts, setTotalProducts] = useState(0);
  const [error, setError] = useState<null|Error>(null);
  // rest of component
}

بارگذاری داده‌ها

در مرحله بعد، یک تابع برای دریافت داده‌ها از یک API ایجاد می‌کنیم، شماره صفحه را افزایش می‌دهیم و state را با آیتم‌های دریافت‌شده به‌روزرسانی می‌کنیم. علاوه بر این، هنگام دریافت داده‌ها، هرگونه خطا را نیز مدیریت خواهیم کرد:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const fetchData = async (page: number) => {
try {
setLoading(true);
const res = await fetch(
`https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}`
);
const data = await res.json();
if (res.ok) {
setProducts((prevItems) => [...prevItems, ...data.products]);
page === 1 && setTotalProducts(() => data.total); // only set this once
}
setLoading(false);
} catch (error) {
setLoading(false);
if (error instanceof Error) {
setError(error);
}
}
};
const fetchData = async (page: number) => { try { setLoading(true); const res = await fetch( `https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}` ); const data = await res.json(); if (res.ok) { setProducts((prevItems) => [...prevItems, ...data.products]); page === 1 && setTotalProducts(() => data.total); // only set this once } setLoading(false); } catch (error) { setLoading(false); if (error instanceof Error) { setError(error); } } };
const fetchData = async (page: number) => {
  try {
    setLoading(true);
    const res = await fetch(
      `https://dummyjson.com/products/?limit=10&skip=${(page - 1) * 10}`
    );
    const data = await res.json();
    if (res.ok) {
      setProducts((prevItems) => [...prevItems, ...data.products]);
      page === 1 && setTotalProducts(() => data.total); // only set this once
    }
    setLoading(false);
  } catch (error) {
    setLoading(false);
    if (error instanceof Error) {
      setError(error);
    }
  }
};

استفاده از API محصولات DummyJSON

در این مقاله، از API محصولات DummyJSON استفاده خواهیم کرد. این API پارامتر

page
page صریحی برای صفحه‌بندی ندارد. در عوض، از دو پارامتر
limit
limit و
skip
skip برای صفحه‌بندی استفاده می‌کند.

  • limit
    limit حداکثر تعداد محصولاتی است که در هر درخواست API دریافت می‌کنیم.
  • skip
    skip تعداد آیتم‌هایی است که قصد داریم در هر صفحه از آن‌ها بگذریم. در اینجا مقدار skip برابر با شماره صفحه قبلی ضربدر ۱۰ خواهد بود.

فراخوانی fetchData هنگام بارگذاری کامپوننت

سپس از هوک

useEffect برای فراخوانی تابع
fetchData
fetchData در زمان بارگذاری اولیه کامپوننت استفاده خواهیم کرد:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
useEffect(() => {
let subscribed = true;
(async () => {
if (subscribed) {
await fetchData(1);
}
})();
return () => {
subscribed = false;
};
}, []);
useEffect(() => { let subscribed = true; (async () => { if (subscribed) { await fetchData(1); } })(); return () => { subscribed = false; }; }, []);
useEffect(() => {
  let subscribed = true;
  (async () => {
    if (subscribed) {
      await fetchData(1);
    }
  })();

  return () => {
    subscribed = false;
  };
}, []);

درون

useEffect
useEffect، باید مطمئن شویم که درخواست‌های asynchronous را پاک‌سازی می‌کنیم تا از به‌روزرسانی state پس از آن که کامپوننت از بین رفت، جلوگیری شود. یکی از روش‌های لغو درخواست fetch، استفاده از
AbortController
AbortController است.

کامپوننت
ProductCard
ProductCard

در نهایت، کامپوننت

ProductCard
ProductCard را ایجاد می‌کنیم که برای نمایش هر محصول استفاده خواهد شد و استایل‌های CSS مربوط به آن را نیز تعریف می‌کنیم. برای این کار، یک پوشه
component
component درون مسیر
src
src ایجاد کرده و فایل
ProductCard.tsx
ProductCard.tsx را درون آن می‌سازیم. سپس کد زیر را در آن قرار می‌دهیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
export const ProductCard = ({ product }: { product: ProductItem }) => {
const discountedPrice = (
product.price -
(product.price * product.discountPercentage) / 100
).toFixed(2);
return (
<div className="product-card">
<img
src={product.thumbnail}
alt={product.title}
className="product-image"
/>
<div className="product-info">
<h2 className="product-title">
{product.title} - {product.id}
</h2>
<span className="product-category">{product.category}</span>
{product.brand && (
<span className="product-brand">{product.brand}</span>
)}
<p className="product-description">{product.description}</p>
<div className="product-props">
<div className="product-price">
${discountedPrice}
<span className="product-original-price">
${product.price.toFixed(2)}
</span>
</div>
<div className="product-rating">
<span className="star-rating">{"★"}</span>
<span>{Math.floor(product.rating)}</span>
</div>
</div>
<button className="add-to-cart">Add to Cart</button>
</div>
</div>
);
};
export const ProductCard = ({ product }: { product: ProductItem }) => { const discountedPrice = ( product.price - (product.price * product.discountPercentage) / 100 ).toFixed(2); return ( <div className="product-card"> <img src={product.thumbnail} alt={product.title} className="product-image" /> <div className="product-info"> <h2 className="product-title"> {product.title} - {product.id} </h2> <span className="product-category">{product.category}</span> {product.brand && ( <span className="product-brand">{product.brand}</span> )} <p className="product-description">{product.description}</p> <div className="product-props"> <div className="product-price"> ${discountedPrice} <span className="product-original-price"> ${product.price.toFixed(2)} </span> </div> <div className="product-rating"> <span className="star-rating">{"★"}</span> <span>{Math.floor(product.rating)}</span> </div> </div> <button className="add-to-cart">Add to Cart</button> </div> </div> ); };
export const ProductCard = ({ product }: { product: ProductItem }) => {
  const discountedPrice = (
    product.price -
    (product.price * product.discountPercentage) / 100
  ).toFixed(2);

  return (
    <div className="product-card">
      <img
        src={product.thumbnail}
        alt={product.title}
        className="product-image"
      />
      <div className="product-info">
        <h2 className="product-title">
          {product.title} - {product.id}
        </h2>
        <span className="product-category">{product.category}</span>
        {product.brand && (
          <span className="product-brand">{product.brand}</span>
        )}
        <p className="product-description">{product.description}</p>
        <div className="product-props">
          <div className="product-price">
            ${discountedPrice}
            <span className="product-original-price">
              ${product.price.toFixed(2)}
            </span>
          </div>
          <div className="product-rating">
            <span className="star-rating">{"★"}</span>
            <span>{Math.floor(product.rating)}</span>
          </div>
        </div>
        <button className="add-to-cart">Add to Cart</button>
      </div>
    </div>
  );
};

قالب Vite که برای ساخت اپلیکیشن React استفاده کردیم، به‌طور پیش‌فرض دو فایل CSS دارد، اما ما فقط به یکی از آن‌ها نیاز داریم.

  • فایل
    App.css
    App.css را حذف کرده و import آن را از
    App.tsx
    App.tsx پاک می‌کنیم.
  • در
    index.css
    index.css، استایل‌های پیش‌فرض را با کد زیر جایگزین می‌نماییم.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap");
.App {
font-family: "Poppins", sans-serif;
}
.products-list {
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
max-width: 768px;
margin: 0 auto;
}
@media screen and (min-width: 768px) {
.products-list {
grid-template-columns: 1fr 1fr;
}
}
.product-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
overflow: hidden;
font-family: "Poppins", sans-serif;
}
.product-image {
width: 100%;
height: 250px;
object-fit: cover;
}
.product-info {
padding: 20px;
}
.product-title {
font-size: 24px;
margin: 0 0 10px;
}
.product-category,
.product-brand {
display: inline-block;
background-color: #e0e0e0;
padding: 5px 10px;
border-radius: 15px;
font-size: 12px;
margin-right: 5px;
}
.product-description {
font-size: 14px;
color: #666;
margin: 10px 0;
}
.product-props {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.product-original-price {
text-decoration: line-through;
color: #999;
font-size: 16px;
margin-left: 10px;
}
.product-rating {
display: flex;
align-items: center;
margin: 10px 0;
}
.star-rating {
color: #ffd700;
font-size: 18px;
margin-right: 5px;
}
.add-to-cart {
display: block;
width: 100%;
padding: 10px;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
}
.add-to-cart:hover {
background-color: #45a049;
}
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"); .App { font-family: "Poppins", sans-serif; } .products-list { display: grid; grid-template-columns: 1fr; grid-gap: 10px; max-width: 768px; margin: 0 auto; } @media screen and (min-width: 768px) { .products-list { grid-template-columns: 1fr 1fr; } } .product-card { background-color: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); max-width: 400px; width: 100%; overflow: hidden; font-family: "Poppins", sans-serif; } .product-image { width: 100%; height: 250px; object-fit: cover; } .product-info { padding: 20px; } .product-title { font-size: 24px; margin: 0 0 10px; } .product-category, .product-brand { display: inline-block; background-color: #e0e0e0; padding: 5px 10px; border-radius: 15px; font-size: 12px; margin-right: 5px; } .product-description { font-size: 14px; color: #666; margin: 10px 0; } .product-props { display: flex; justify-content: space-between; align-items: center; } .product-price { font-size: 24px; font-weight: bold; margin: 10px 0; } .product-original-price { text-decoration: line-through; color: #999; font-size: 16px; margin-left: 10px; } .product-rating { display: flex; align-items: center; margin: 10px 0; } .star-rating { color: #ffd700; font-size: 18px; margin-right: 5px; } .add-to-cart { display: block; width: 100%; padding: 10px; background-color: #4caf50; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 20px; } .add-to-cart:hover { background-color: #45a049; }
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap");

.App {
  font-family: "Poppins", sans-serif;
}
.products-list {
  display: grid;
  grid-template-columns: 1fr;
  grid-gap: 10px;
  max-width: 768px;
  margin: 0 auto;
}
@media screen and (min-width: 768px) {
  .products-list {
    grid-template-columns: 1fr 1fr;
  }
}
.product-card {
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  max-width: 400px;
  width: 100%;
  overflow: hidden;
  font-family: "Poppins", sans-serif;
}
.product-image {
  width: 100%;
  height: 250px;
  object-fit: cover;
}
.product-info {
  padding: 20px;
}
.product-title {
  font-size: 24px;
  margin: 0 0 10px;
}
.product-category,
.product-brand {
  display: inline-block;
  background-color: #e0e0e0;
  padding: 5px 10px;
  border-radius: 15px;
  font-size: 12px;
  margin-right: 5px;
}
.product-description {
  font-size: 14px;
  color: #666;
  margin: 10px 0;
}
.product-props {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.product-price {
  font-size: 24px;
  font-weight: bold;
  margin: 10px 0;
}
.product-original-price {
  text-decoration: line-through;
  color: #999;
  font-size: 16px;
  margin-left: 10px;
}
.product-rating {
  display: flex;
  align-items: center;
  margin: 10px 0;
}
.star-rating {
  color: #ffd700;
  font-size: 18px;
  margin-right: 5px;
}
.add-to-cart {
  display: block;
  width: 100%;
  padding: 10px;
  background-color: #4caf50;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  margin-top: 20px;
}
.add-to-cart:hover {
  background-color: #45a049;
}

این مراحل پایه در تمام روش‌هایی که در این مقاله بررسی خواهیم کرد، وجود دارند. ما آن‌ها را اصلاح کرده و به عنوان پایه‌ای برای پیاده‌سازی‌های مختلف توسعه خواهیم داد.

سه روش برای پیاده‌سازی اسکرول بینهایت در React

۱-  پیاده‌سازی کامل از صفر

در این روش، ما کل مکانیزم اسکرول بینهایت را از ابتدا می‌سازیم. این روش شامل کنترل event

scroll
scroll، بارگذاری داده‌های بیشتر و به‌روزرسانی state در برنامه React است. این روش امکان کنترل کامل بر سفارشی‌سازی و عملکرد را به ما می‌دهد.

برای شروع، یک کامپوننت به نام

FromScratch.tsx
FromScratch.tsx در دایرکتوری components ایجاد کرده و مقدار اولیه آن را تنظیم می‌کنیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import { useEffect, useState } from "react";
import { ProductCard } from "./ProductCard";
import { ProductItem } from "../types";
export const FromScratch = ({
products,
fetchData,
loading,
error
}: {
products: ProductItem[];
fetchData: (page: number) => Promise;
loading: boolean;
error: null|Error
}) => {
const [page, setPage] = useState(1);
// scroll logic
return (
<div>
<div className="products-list">
{products.map((product, index) => (
<ProductCard product={product} key={index} />
))}
</div>
{loading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
};
import { useEffect, useState } from "react"; import { ProductCard } from "./ProductCard"; import { ProductItem } from "../types"; export const FromScratch = ({ products, fetchData, loading, error }: { products: ProductItem[]; fetchData: (page: number) => Promise; loading: boolean; error: null|Error }) => { const [page, setPage] = useState(1); // scroll logic return ( <div> <div className="products-list"> {products.map((product, index) => ( <ProductCard product={product} key={index} /> ))} </div> {loading && <p>Loading...</p>} {error && <p>Error: {error.message}</p>} </div> ); };
import { useEffect, useState } from "react";
import { ProductCard } from "./ProductCard";
import { ProductItem } from "../types";

export const FromScratch = ({
  products,
  fetchData,
  loading,
  error
}: {
  products: ProductItem[];
  fetchData: (page: number) => Promise;
  loading: boolean;
  error: null|Error
}) => {
  const [page, setPage] = useState(1);
  
  // scroll logic  

  return (
    <div>
      <div className="products-list">
        {products.map((product, index) => (
          <ProductCard product={product} key={index} />
        ))}
      </div>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
    </div>
  );
};

کنترل event
scroll
scroll

سپس، یک تابع برای مدیریت event

scroll
scroll می‌سازیم. این تابع بررسی می‌کند که آیا کاربر به انتهای صفحه رسیده است یا نه؛ و در صورت نیاز، تابع
fetchData
fetchData را برای دریافت داده‌های بیشتر فراخوانی می‌کند. همچنین، یک event listener برای event 
scroll
scroll روی
window
window اضافه می‌کنیم و هنگام unmount شدن کامپوننت، آن را حذف می‌نماییم. در کدی که داشتیم، به جای کامنت
// scroll logic
// scroll logic کد زیر را اضافه می‌کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const handleScroll = () => {
const bottom =
Math.ceil(window.innerHeight + window.scrollY) >=
document.documentElement.scrollHeight - 200;
if (bottom) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
};
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
const handleScroll = () => { const bottom = Math.ceil(window.innerHeight + window.scrollY) >= document.documentElement.scrollHeight - 200; if (bottom) { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); } }; useEffect(() => { window.addEventListener("scroll", handleScroll); return () => { window.removeEventListener("scroll", handleScroll); }; }, []);
const handleScroll = () => {
  const bottom =
    Math.ceil(window.innerHeight + window.scrollY) >=
    document.documentElement.scrollHeight - 200;
  if (bottom) {
    setPage((prevPage) => {
      const nextPage = prevPage + 1;
      fetchData(nextPage);
      return nextPage;
    });
  }
};

useEffect(() => {
  window.addEventListener("scroll", handleScroll);
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, []);

دلیل استفاده از
fetchData
fetchData
 در
setPage
setPage

سوالی که ممکن است در این مرحله پیش بیاید این است که چرا

fetchData
fetchData در داخل callback
setPage
setPage قرار گرفته است. دلیل این اتفاق این است که
fetchData
fetchData به مقدار به‌روز state صفحه نیاز دارد.

به‌روزرسانی state در React به‌صورت asynchronous انجام می‌شود، بنابراین اگر بلافاصله بعد از setState به مقدار جدید state وابسته باشیم، ممکن است دچار race condition شویم (race condition زمانی رخ می‌دهد که دو فرآیند سعی می‌کنند به‌طور هم‌زمان به یک منبع دسترسی پیدا کنند). راه دیگر این است که از هوک

useEffect
useEffect برای دریافت داده‌ها هنگام تغییر
page
page استفاده کنیم، اما در این مورد، این امکان را نداریم.

اکنون، کامپوننتی که ساختیم را در

App.tsx
App.tsx ایمپورت کرده و اپلیکیشن را اجرا می‌کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// App.tsx
<div>
<FromScratch
products={products}
fetchData={fetchData}
loading={loading}
error={error}
/>
</div>
// App.tsx <div> <FromScratch products={products} fetchData={fetchData} loading={loading} error={error} /> </div>
// App.tsx
<div>
  <FromScratch
    products={products}
    fetchData={fetchData}
    loading={loading}
    error={error}
  />
</div>

برای اجرای برنامه می‌توانیم از دستور زیر استفاده کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npm run dev
npm run dev
npm run dev

پس از اجرای برنامه، می‌توانیم خروجی آن را در مرورگر مشاهده کنیم. اگر پروژه را هم‌زمان با این آموزش ایجاد نمی‌کنید، می‌توانید از نمونه کد Codesandbox استفاده نمایید.

Debouncing

تابع

handleScroll
handleScroll در هر بار اسکرول کاربر، بررسی می‌کند که آیا به انتهای صفحه رسیده یا نه، اما این کار بهینه نیست. در عوض، بهتر است فقط زمانی که کاربر اسکرول را متوقف کرد، بررسی انجام شود. برای این کار، از Debouncing استفاده می‌کنیم که یک وقفه ایجاد می‌کند و فقط بعد از متوقف شدن اسکرول، تابع بررسی اجرا می‌شود:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// FromScratch.tsx
const debounce = (func: (args: any) => void, delay: number) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (...args: any) {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(args);
}, delay);
};
};
// FromScratch.tsx const debounce = (func: (args: any) => void, delay: number) => { let timeoutId: ReturnType<typeof setTimeout>; return function (...args: any) { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { func(args); }, delay); }; };
// FromScratch.tsx
const debounce = (func: (args: any) => void, delay: number) => {
  let timeoutId: ReturnType<typeof setTimeout>;

  return function (...args: any) {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      func(args);
    }, delay);
  };
};

سپس، تابع

handleScroll
handleScroll را به‌روزرسانی می‌کنیم تا از یک تابع
debounce
debounce استفاده کند.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const handleScroll = debounce(() => {
const bottom =
Math.ceil(window.innerHeight + window.scrollY) >=
document.documentElement.scrollHeight - 200;
if (bottom) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
}, ۳۰۰);
const handleScroll = debounce(() => { const bottom = Math.ceil(window.innerHeight + window.scrollY) >= document.documentElement.scrollHeight - 200; if (bottom) { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); } }, ۳۰۰);
const handleScroll = debounce(() => {
  const bottom =
    Math.ceil(window.innerHeight + window.scrollY) >=
    document.documentElement.scrollHeight - 200;

  if (bottom) {
    setPage((prevPage) => {
      const nextPage = prevPage + 1;
      fetchData(nextPage);
      return nextPage;
    });
  }
}, ۳۰۰);

هدف از پاک کردن timeout درون تابع debounce این است که هر بار که کاربر دوباره اسکرول را شروع کند، تایمر قبلی لغو شود و تابع بررسی اسکرول بی‌دلیل اجرا نشود.

ساخت یک هوک قابل استفاده مجدد

در اپلیکیشن‌های بزرگ، معمولاً اسکرول بینهایت را در صفحات مختلف نیاز خواهیم داشت. بنابراین، در این بخش، تابع اسکرول را به‌صورت یک هوک سفارشی با قابلیت استفاده مجدد پیاده‌سازی می‌کنیم.

برای این کار:

  • درون مسیر
    src
    src، یک دایرکتوری جدید به نام
    hooks
    hooks می‌سازیم.
  • درون آن، یک فایل
    useInfiniteScroll.ts
    useInfiniteScroll.ts ایجاد کرده و منطق مربوط به اسکرول را به آن منتقل می‌کنیم:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import { useEffect, useState } from "react";
const debounce = (func: (args: any) => void, delay: number) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (...args: any) {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(args);
}, delay);
};
};
export const useInfiniteScroll = (fetchData: (page: number) => Promise<void>) => {
const [page, setPage] = useState(1);
const handleScroll = debounce(() => {
const bottom =
Math.ceil(window.innerHeight + window.scrollY) >=
document.documentElement.scrollHeight - 200;
if (bottom) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
}, ۳۰۰);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
};
import { useEffect, useState } from "react"; const debounce = (func: (args: any) => void, delay: number) => { let timeoutId: ReturnType<typeof setTimeout>; return function (...args: any) { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { func(args); }, delay); }; }; export const useInfiniteScroll = (fetchData: (page: number) => Promise<void>) => { const [page, setPage] = useState(1); const handleScroll = debounce(() => { const bottom = Math.ceil(window.innerHeight + window.scrollY) >= document.documentElement.scrollHeight - 200; if (bottom) { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); } }, ۳۰۰); useEffect(() => { window.addEventListener("scroll", handleScroll); return () => { window.removeEventListener("scroll", handleScroll); }; }, []); };
import { useEffect, useState } from "react";
const debounce = (func: (args: any) => void, delay: number) => {
  let timeoutId: ReturnType<typeof setTimeout>;
  return function (...args: any) {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(() => {
      func(args);
    }, delay);
  };
};

export const useInfiniteScroll = (fetchData: (page: number) => Promise<void>) => {
  const [page, setPage] = useState(1);
  
  const handleScroll = debounce(() => {
    const bottom =
      Math.ceil(window.innerHeight + window.scrollY) >=
      document.documentElement.scrollHeight - 200;
    if (bottom) {
      setPage((prevPage) => {
        const nextPage = prevPage + 1;
        fetchData(nextPage);
        return nextPage;
      });
    }
  }, ۳۰۰);

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);
};

حال در

FromScratch.tsx
FromScratch.tsx، منطق اسکرول را با این هوک جدید جایگزین می‌کنیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
export const FromScratch = ({
products,
fetchData,
loading,
error,
}: {
products: ProductItem[];
fetchData: (page: number) => Promise<void>;
loading: boolean;
error: null | Error;
}) => {
useInfiniteScroll(fetchData);
// rest of component
};
export const FromScratch = ({ products, fetchData, loading, error, }: { products: ProductItem[]; fetchData: (page: number) => Promise<void>; loading: boolean; error: null | Error; }) => { useInfiniteScroll(fetchData); // rest of component };
export const FromScratch = ({
  products,
  fetchData,
  loading,
  error,
}: {
  products: ProductItem[];
  fetchData: (page: number) => Promise<void>;
  loading: boolean;
  error: null | Error;
}) => {
  useInfiniteScroll(fetchData);

  // rest of component
};

با این روش، یک پیاده‌سازی کاملاً سفارشی و قابل توسعه برای اسکرول بینهایت در React داریم. این روش امکان کنترل کامل و سفارشی‌سازی بالا را فراهم می‌کند، اما در عین حال، زمان‌برتر است و نسبت به استفاده از کتابخانه‌های آماده، به نگه‌داری بیشتری نیاز دارد.

۲- استفاده از کتابخانه‌های موجود برای اسکرول بینهایت

استفاده از یک کتابخانه آماده برای اسکرول بینهایت باعث صرفه‌جویی در زمان و انرژی می‌شود، زیرا از راهکارهای آماده و تست شده بهره می‌بریم و در عین حال امکان سفارشی‌سازی را نیز خواهیم داشت. در این بخش، دو مورد از این کتابخانه‌ها را بررسی می‌کنیم.

react-infinite-scroll-component

یکی از محبوب‌ترین کتابخانه‌ها برای پیاده‌سازی اسکرول بینهایت در React، کتابخانه react-infinite-scroll-component است. در اینجا یاد می‌گیریم که چگونه از این کتابخانه برای ایجاد اسکرول بینهایت در یک فروشگاه اینترنتی استفاده کنیم.

ابتدا react-infinite-scroll-component را نصب می‌نماییم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npm install react-infinite-scroll-component
npm install react-infinite-scroll-component
npm install react-infinite-scroll-component

اکنون، یک کامپوننت جدید در دایرکتوری

components
components ایجاد می‌کنیم و آن را
WithReactScroll.tsx
WithReactScroll.tsx می‌نامیم. در این کامپوننت:

  • کامپوننت
    InfiniteScroll
    InfiniteScroll را از کتابخانه react-infinite-scroll-component ایمپورت می‌کنیم.
  • لیست محصولات را داخل آن قرار می‌دهیم.
  • با استفاده از props مانند
    dataLength
    dataLength،
    next
    next،
    hasMore
    hasMore و
    loader
    loader، رفتار اسکرول را تنظیم می‌کنیم.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import InfiniteScroll from "react-infinite-scroll-component";
import { ProductCard } from "./ProductCard";
import { useState } from "react";
export const WithReactScroll = ({
products,
fetchData,
totalProducts,
}: {
products: ProductItem[];
fetchData: (page: number) => Promise<void>;
totalProducts: number;
}) => {
const [page, setPage] = useState(1);
const handleLoadMoreData = () => {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
};
return (
<InfiniteScroll
dataLength={products.length}
next={handleLoadMoreData}
hasMore={totalProducts > products.length}
loader={<p>Loading...</p>}
endMessage={<p>No more data to load.</p>}
>
<div className="products-list">
{products.map((item) => (
<ProductCard product={item} key={item.id} />
))}
</div>
</InfiniteScroll>
);
};
import InfiniteScroll from "react-infinite-scroll-component"; import { ProductCard } from "./ProductCard"; import { useState } from "react"; export const WithReactScroll = ({ products, fetchData, totalProducts, }: { products: ProductItem[]; fetchData: (page: number) => Promise<void>; totalProducts: number; }) => { const [page, setPage] = useState(1); const handleLoadMoreData = () => { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); }; return ( <InfiniteScroll dataLength={products.length} next={handleLoadMoreData} hasMore={totalProducts > products.length} loader={<p>Loading...</p>} endMessage={<p>No more data to load.</p>} > <div className="products-list"> {products.map((item) => ( <ProductCard product={item} key={item.id} /> ))} </div> </InfiniteScroll> ); };
import InfiniteScroll from "react-infinite-scroll-component";
import { ProductCard } from "./ProductCard";
import { useState } from "react";

export const WithReactScroll = ({
  products,
  fetchData,
  totalProducts,
}: {
  products: ProductItem[];
  fetchData: (page: number) => Promise<void>;
  totalProducts: number;
}) => {
  const [page, setPage] = useState(1);

  const handleLoadMoreData = () => {
    setPage((prevPage) => {
      const nextPage = prevPage + 1;
      fetchData(nextPage);
      return nextPage;
    });
  };

  return (
    <InfiniteScroll
      dataLength={products.length}
      next={handleLoadMoreData}
      hasMore={totalProducts > products.length}
      loader={<p>Loading...</p>}
      endMessage={<p>No more data to load.</p>}
    >
      <div className="products-list">
        {products.map((item) => (
          <ProductCard product={item} key={item.id} />
        ))}
      </div>
    </InfiniteScroll>
  );
};

اکنون می‌توانیم کامپوننت

WithReactScroll
WithReactScroll را در
App.tsx
App.tsx ایمپورت کرده و نتیجه را مشاهده کنیم. همچنین می‌توانید نتیجه را در Codesandbox نیز ببینیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<div>
<WithReactScroll
products={products}
fetchData={fetchData}
totalProducts={totalProducts}
/>
</div>
<div> <WithReactScroll products={products} fetchData={fetchData} totalProducts={totalProducts} /> </div>
<div>
  <WithReactScroll
    products={products}
    fetchData={fetchData}
    totalProducts={totalProducts}
  />
</div>
مزایا و معایب این روش

با این روش، توانستیم اسکرول بینهایت را در اپلیکیشن خود پیاده‌سازی کنیم، بدون اینکه نیاز باشد event

scroll
scroll ویندوز را به‌صورت دستی مدیریت نماییم. کتابخانه
react-infinite-scroll-component
react-infinite-scroll-component این کار را به طور خودکار برای ما انجام می‌دهد.

مزایا:

  • پیاده‌سازی سریع و ساده
  • امکان سفارشی‌سازی ویژگی‌هایی مانند ارتفاع اسکرول و overflow

معایب:

  • افزودن وابستگی اضافی به پروژه (ممکن است حجم نهایی برنامه را افزایش دهد)

استفاده از react-window-infinite-loader و react-window

دومین روش استفاده از کتابخانه react-window است که برای رندر کردن لیست‌های بزرگ بهینه شده است. همراه با این کتابخانه، از react-window-infinite-loader برای بارگذاری داده‌های بیشتر هنگام اسکرول استفاده می‌کنیم.

ابتدا react-window و react-window-infinite-loader را نصب می‌نماییم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npm install react-window-infinite-loader react-window
npm install react-window-infinite-loader react-window
npm install react-window-infinite-loader react-window

سپس، یک کامپوننت جدید در دایرکتوری

components
components با نام
WithReactWindow
WithReactWindow ایجاد می‌کنیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import { useState } from "react";
import { FixedSizeList as List } from "react-window";
import InfiniteLoader from "react-window-infinite-loader";
import { ProductCard } from "./ProductCard";
export const WithReactWindow = ({
fetchData,
products,
totalProducts,
loading,
}: {
products: ProductItem[];
fetchData: (page: number) => Promise<void>;
totalProducts: number;
loading: boolean;
}) => {
const [page, setPage] = useState(1);
const hasNextPage = totalProducts > products.length;
const handleLoadMoreData = () => {
if (loading) return;
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
};
const isItemLoaded = (index: number) => !hasNextPage || index < products.length;
const Row = ({ index, style }: { index: number, style: { [key:string]:any } }) => {
return (
<div style={style}>
{isItemLoaded(index) ? (
<ProductCard product={products[index]} />
) : (
"Loading..."
)}
</div>
);
};
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={hasNextPage ? products.length + 1 : products.length}
loadMoreItems={handleLoadMoreData}
>
{({ onItemsRendered, ref }) => (
<List
height={window.innerHeight}
itemCount={products.length}
itemSize={600}
onItemsRendered={onItemsRendered}
ref={ref}
width={450}
>
{Row}
</List>
)}
</InfiniteLoader>
);
};
import { useState } from "react"; import { FixedSizeList as List } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; import { ProductCard } from "./ProductCard"; export const WithReactWindow = ({ fetchData, products, totalProducts, loading, }: { products: ProductItem[]; fetchData: (page: number) => Promise<void>; totalProducts: number; loading: boolean; }) => { const [page, setPage] = useState(1); const hasNextPage = totalProducts > products.length; const handleLoadMoreData = () => { if (loading) return; setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); }; const isItemLoaded = (index: number) => !hasNextPage || index < products.length; const Row = ({ index, style }: { index: number, style: { [key:string]:any } }) => { return ( <div style={style}> {isItemLoaded(index) ? ( <ProductCard product={products[index]} /> ) : ( "Loading..." )} </div> ); }; return ( <InfiniteLoader isItemLoaded={isItemLoaded} itemCount={hasNextPage ? products.length + 1 : products.length} loadMoreItems={handleLoadMoreData} > {({ onItemsRendered, ref }) => ( <List height={window.innerHeight} itemCount={products.length} itemSize={600} onItemsRendered={onItemsRendered} ref={ref} width={450} > {Row} </List> )} </InfiniteLoader> ); };
import { useState } from "react";
import { FixedSizeList as List } from "react-window";
import InfiniteLoader from "react-window-infinite-loader";
import { ProductCard } from "./ProductCard";

export const WithReactWindow = ({
  fetchData,
  products,
  totalProducts,
  loading,
}: {
  products: ProductItem[];
  fetchData: (page: number) => Promise<void>;
  totalProducts: number;
  loading: boolean;
}) => {
  const [page, setPage] = useState(1);
  const hasNextPage = totalProducts > products.length;

  const handleLoadMoreData = () => {
    if (loading) return;
    setPage((prevPage) => {
      const nextPage = prevPage + 1;
      fetchData(nextPage);
      return nextPage;
    });
  };

  const isItemLoaded = (index: number) => !hasNextPage || index < products.length;

  const Row = ({ index, style }: { index: number, style: { [key:string]:any } }) => {
    return (
      <div style={style}>
        {isItemLoaded(index) ? (
          <ProductCard product={products[index]} />
        ) : (
          "Loading..."
        )}
      </div>
    );
  };

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={hasNextPage ? products.length + 1 : products.length}
      loadMoreItems={handleLoadMoreData}
    >
      {({ onItemsRendered, ref }) => (
        <List
          height={window.innerHeight}
          itemCount={products.length}
          itemSize={600}
          onItemsRendered={onItemsRendered}
          ref={ref}
          width={450}
        >
          {Row}
        </List>
      )}
    </InfiniteLoader>
  );
};

در کد بالا، از ترکیب

InfiniteLoader()
InfiniteLoader() و
FixedSizeList()
FixedSizeList() استفاده می‌کنیم. این ترکیب باعث می‌شود فقط آیتم‌های قابل مشاهده در صفحه رندر شوند. هنگام اسکرول، داده‌های جدید بارگذاری می‌شوند و ویژگی Infinite Scroll را فراهم می‌کند.

اکنون می‌توانیم این کامپوننت را در

App
App ایمپورت کنیم تا نتیجه را مشاهده نماییم. همچنین می‌توانیم نتیجه را در Codesandbox ببینیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<div>
<WithReactWindow
products={products}
fetchData={fetchData}
totalProducts={totalProducts}
loading={loading}
/>
</div>
<div> <WithReactWindow products={products} fetchData={fetchData} totalProducts={totalProducts} loading={loading} /> </div>
<div>
  <WithReactWindow
    products={products}
    fetchData={fetchData}
    totalProducts={totalProducts}
    loading={loading}
  />
</div>

۳- استفاده از Intersection Observer API

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

ما یک کامپوننت جدید در دایرکتوری

components
components ایجاد می‌کنیم و آن را
WithIntersectionObserver
WithIntersectionObserver می‌نامیم. سپس، یک
ref
ref برای المنت هدف
observer
observer ایجاد کرده و Intersection Observer را در یک هوک از نوع
useEffect
useEffect تنظیم می‌کنیم. زمانی که المنت هدف به نمایش درآید، تابع
fetchData
fetchData را فراخوانی خواهیم کرد.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import { useEffect, useRef, useState } from "react";
import { ProductCard } from "./ProductCard";
export const WithIntersectionObserver = ({
products,
fetchData,
error,
loading
}: {
products: ProductItem[];
fetchData: (page: number) => Promise<void>;
error: null|Error,
loading: boolean
}) => {
const [page, setPage] = useState(1);
const observerTarget = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
},
{ threshold: 1 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
if (observerTarget.current) {
observer.unobserve(observerTarget.current);
}
};
}, [observerTarget]);
// rest of component
};
import { useEffect, useRef, useState } from "react"; import { ProductCard } from "./ProductCard"; export const WithIntersectionObserver = ({ products, fetchData, error, loading }: { products: ProductItem[]; fetchData: (page: number) => Promise<void>; error: null|Error, loading: boolean }) => { const [page, setPage] = useState(1); const observerTarget = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { setPage((prevPage) => { const nextPage = prevPage + 1; fetchData(nextPage); return nextPage; }); } }, { threshold: 1 } ); if (observerTarget.current) { observer.observe(observerTarget.current); } return () => { if (observerTarget.current) { observer.unobserve(observerTarget.current); } }; }, [observerTarget]); // rest of component };
import { useEffect, useRef, useState } from "react";
import { ProductCard } from "./ProductCard";

export const WithIntersectionObserver = ({
  products,
  fetchData,
  error,
  loading
}: {
  products: ProductItem[];
  fetchData: (page: number) => Promise<void>;
  error: null|Error,
  loading: boolean
}) => {
  const [page, setPage] = useState(1);
  const observerTarget = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setPage((prevPage) => {
            const nextPage = prevPage + 1;
            fetchData(nextPage);
            return nextPage;
          });
        }
      },
      { threshold: 1 }
    );
    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }
    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [observerTarget]);

  // rest of component
};

سپس، آیتم‌ها، نشانگر بارگذاری، پیام‌های خطا و المنت هدف

observer
observer را درون کامپوننت رندر می‌کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
return (
<>
<div className="products-list">
{products.map((product) => (
<ProductCard product={product} key={product.id} />
))}
</div>
<div ref={observerTarget}></div>
{loading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
</>
);
return ( <> <div className="products-list"> {products.map((product) => ( <ProductCard product={product} key={product.id} /> ))} </div> <div ref={observerTarget}></div> {loading && <p>Loading...</p>} {error && <p>Error: {error.message}</p>} </> );
return (
  <>
    <div className="products-list">
      {products.map((product) => (
        <ProductCard product={product} key={product.id} />
      ))}
    </div>
    <div ref={observerTarget}></div>
    {loading && <p>Loading...</p>}
    {error && <p>Error: {error.message}</p>}
  </>
);

با استفاده از Intersection Observer API، ما یک راه‌حل اسکرول بینهایت کارآمد و با عملکرد بهینه در برنامه React خود ایجاد کرده‌ایم. این روش یک روش مدرن و browser-native برای تشخیص نمایش المنت‌ها است، اما ممکن است در مرورگرهای قدیمی بدون استفاده از polyfill پشتیبانی نشود.

سپس، این کامپوننت جدید را در کامپوننت

App
App ایمپورت می‌کنیم تا نتیجه را ببینیم. همچنین می‌توانیم نتیجه را در Codesandbox نیز مشاهده نماییم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
//App.tsx
<div>
<WithIntersectionObserver
products={products}
fetchData={fetchData}
loading={loading}
error={error}
/>
</div>
//App.tsx <div> <WithIntersectionObserver products={products} fetchData={fetchData} loading={loading} error={error} /> </div>
//App.tsx

<div>
  <WithIntersectionObserver
    products={products}
    fetchData={fetchData}
    loading={loading}
    error={error}
  />
</div>

انتخاب بهترین روش

ما سه روش مختلف برای پیاده‌سازی اسکرول بینهایت در React را بررسی کردیم. از آنجایی که احتمالاً فقط به یکی از این روش‌ها در پروژه خود نیاز داریم، انتخاب روش مناسب بستگی به نیازهای خاص برنامه ما دارد. در اینجا برخی نکات کلیدی برای کمک به تصمیم‌گیری را باهم بررسی می‌کنیم:

  • آیا وقت و دانش لازم برای ایجاد و نگه‌داری یک راه‌حل سفارشی را داریم؟
  • آیا برنامه ما در حال حاضر از کتابخانه‌های third-party زیادی استفاده می‌کند یا سعی داریم وابستگی‌ها را به حداقل برسانیم؟
  • آیا برنامه ما نیاز به پشتیبانی از مرورگرهای قدیمی دارد یا می‌تواند فقط مرورگرهای مدرن را هدف قرار دهد؟

بررسی مزایا و معایب هر روش

پیاده‌سازی از صفر

این روش کنترل کامل بر پیاده‌سازی را فراهم می‌کند و امکان سفارشی‌سازی نامحدود را می‌دهد. با این حال، برای بهینه‌سازی عملکرد (مثلاً کاهش دفعات اجرای event

scroll
scroll، به‌روزرسانی کارآمد DOM) نیاز به تلاش بیشتری دارد و در صورت پیاده‌سازی نادرست ممکن است دارای اشکالاتی باشد.

چه زمانی از این روش استفاده کنیم؟

  • اگر عجله نداریم و می‌توانیم زمان کافی برای توسعه و نگه‌داری راه‌حل صرف کنیم.
  • اگر به پیاده‌سازی کاملاً سفارشی نیاز داریم.
  • اگر می‌خواهیم از وابستگی‌های خارجی در پروژه خود اجتناب کنیم.

استفاده از کتابخانه‌ها

کتابخانه‌ها اغلب با بهینه‌سازی‌های داخلی و پشتیبانی community، راه‌حلی سریع و مطمئن برای پیاده‌سازی اسکرول بینهایت ارائه می‌دهند. با این حال، ممکن است باعث افزایش حجم کد برنامه ما شوند و برای به‌روزرسانی‌ها و رفع اشکالات به نگه‌داری‌کنندگان کتابخانه وابسته خواهیم بود.

چه زمانی از این روش استفاده کنیم؟

  • اگر به یک راه‌حل سریع و آسان نیاز داریم.
  • اگر برنامه ما امکان اضافه کردن وابستگی‌های جدید را دارد.
  • اگر می‌خواهیم از کدهای تست شده توسط community و پشتیبانی مداوم بهره ببریم.

استفاده از Intersection Observer

این روش مدرن نیازی به استفاده از eventهای

scroll
scroll و کاهش فراخوانی آن‌ها ندارد، بنابراین کارایی بیشتری دارد و نگه‌داری آن آسان‌تر است. با این حال، در مرورگرهای قدیمی مانند Internet Explorer پشتیبانی نمی‌شود.

چه زمانی از این روش استفاده کنیم؟

  • اگر برنامه ما فقط مرورگرهای مدرن را هدف قرار می‌دهد یا می‌توانیم از polyfill برای مرورگرهای قدیمی استفاده کنیم.
  • اگر به دنبال تعادلی بین سفارشی‌سازی و سادگی در پیاده‌سازی هستیم.
  • اگر یک راه‌حل بهینه و آینده‌نگر را ترجیح می‌دهیم.

پیاده‌سازی اسکرول به بالا در React با استفاده از ref و scrollTop API

اسکرول به بالا یک قابلیت اضافی است که معمولاً در اسکرول بینهایت پیاده‌سازی می‌شود و تجربه کاربری را بهبود می‌بخشد.

مثلاً در  X، هنگامی که در صفحه For You اسکرول می‌کنیم، این صفحه هیچ‌وقت به پایان نمی‌رسد؛ این نمونه‌ای از اسکرول بینهایت است. سپس، وقتی روی آیکون خانه در منوی ناوبری X کلیک می‌کنیم، صفحه دوباره به بالای لیست برمی‌گردد. این آیکون دو وظیفه دارد: به‌روزرسانی و بارگذاری داده‌های جدید در For You و ارائه قابلیت اسکرول به بالا.

برای بهبود تجربه کاربری، بهتر است تمام پیاده‌سازی‌های اسکرول بینهایت دارای گزینه‌ای برای برگشت به بالای صفحه باشند. برای پیاده‌سازی این قابلیت در React، از ویژگی

scrollTop()
scrollTop() و هوک
useRef()
useRef()
 استفاده خواهیم کرد تا کنترل بهتری بر موقعیت اسکرول داشته باشیم.

ترکیب این دو، امکان پیاده‌سازی قابلیت‌هایی مانند دکمه‌های اسکرول به بالا یا بارگذاری محتوای پویا هنگام اسکرول را فراهم می‌کند، همان‌طور که در مثالی که از ابتدا پیاده‌سازی کردیم دیدیم. این قابلیت را در فایل

App.tsx
App.tsx پیاده‌سازی می‌کنیم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import React, { useRef } from 'react';
function App() {
const scrollableDiv = useRef<HTMLDivElement | null>(null);
// scroll logic</span>
const scrollToTop = () => {
if (scrollableDiv.current) {
scrollableDiv.current.scrollTop = 0;
}
};
return (
<div ref={scrollableDiv}>
<!-- Infinite Scroll content -->
<button onClick={scrollToTop}>Scroll to Top</button>
</div>
);
}
export default ScrollableComponent;
import React, { useRef } from 'react'; function App() { const scrollableDiv = useRef<HTMLDivElement | null>(null); // scroll logic</span> const scrollToTop = () => { if (scrollableDiv.current) { scrollableDiv.current.scrollTop = 0; } }; return ( <div ref={scrollableDiv}> <!-- Infinite Scroll content --> <button onClick={scrollToTop}>Scroll to Top</button> </div> ); } export default ScrollableComponent;
import React, { useRef } from 'react';

function App() {
  const scrollableDiv = useRef<HTMLDivElement | null>(null);

  // scroll logic</span>
  const scrollToTop = () => {
    if (scrollableDiv.current) {
      scrollableDiv.current.scrollTop = 0;
    }
  };

  return (
    <div ref={scrollableDiv}>
      <!-- Infinite Scroll content -->
      <button onClick={scrollToTop}>Scroll to Top</button>
    </div>
  );
}

export default ScrollableComponent;

در کد بالا، اسکرول به بالا با استفاده از

ref
ref و
scrollTop()
scrollTop() انجام می‌شود که مستقیماً موقعیت اسکرول
div
div را دستکاری می‌کند. با این کار، می‌توانیم تجربه کاربری در اسکرول بینهایت را به‌طور قابل‌توجهی بهبود دهیم.

جمع‌بندی

اسکرول بینهایت یک تکنیک قدرتمند در طراحی وب است که تجربه کاربری را با بارگذاری تدریجی محتوا هنگام اسکرول به پایین صفحه بهبود می‌بخشد و نیاز به صفحه‌بندی را از بین می‌برد. در این مقاله، سه روش مختلف برای پیاده‌سازی اسکرول بینهایت در برنامه‌های React را بررسی کردیم و یک صفحه محصولات برای یک فروشگاه اینترنتی ایجاد نمودیم.

هر تکنیک مزایا و معایب خاص خود را دارد، بنابراین انتخاب روش مناسب بستگی به نیازهای خاص پروژه ما و کاربران دارد. با پیاده‌سازی اسکرول بینهایت در برنامه‌های React، می‌توانیم یک تجربه کاربری جذاب و روان ایجاد کنیم که کاربران را به تعامل بیشتر با محتوای ما ترغیب کند.

دیدگاه‌ها:

افزودن دیدگاه جدید