Picture of the author

Xây dựng React Infinite Scrolling and Lazy Loading - Call Data từ API

React Infinite Scrolling and Lazy Loading - Một Task rất hay gặp khi trong các dự án web application thực tế.

8m read time

Hey you!
Lại là mình đây!

Trong bài viết này mình xin chia sẻ quá trình mình làm task - React Infinite Scrolling and Lazy Loading.

Demo React Infinite Scrolling and Lazy Loading

Infinite Scrolling là gì?

Infinite Scrolling là một kỹ thuật thiết kế web cho phép người dùng tiếp tục cuộn một trang mà không cần đến "cuối" của nó. Thiết lập cuộn vô hạn tải nội dung liên tục khi người dùng cuộn xuống trang, cung cấp lượng tài liệu ngày càng tăng và dường như không bao giờ kết thúc và loại bỏ nhu cầu phân trang.
Ví dụ về Infinite Scrolling có rất nhiều trong thực tế như "Scrolling Load More" của Facebook và YouTube.
Infinite Scrolling sẽ làm tăng trải nhiệm người dùng, thay cho việc phân trang người dùng phải mất thao tác di chuyển để button "Next Page" hoặc button "Number Page" rồi click => làm trải nghiệm người dùng bị ảnh hưởng.  Việc sử dụng Infinite Scrolling sẽ khác phục được điều đó.

Tuy nhiên, nhược điểm của Infinite Scrolling đó là khiến người dùng mất thời gian và công sức nếu muốn nhận được dữ liệu ở gần cuối của danh sách.
Ví dụ : Công ty Tony Tran có khoảng 1000 nhân viên, người dùng muốn nhận được thông tin của một ai đó ở gần cuối danh sách. Nếu sử dụng Infinite Scrolling thì người dùng phải scrolling rất nhiều lần. Để khắc phục vấn đề này cũng có cách.
=> Không kỹ thuật hay phương pháp nào là hoàn hảo cả.

Lazy Loading là gì?

Lazy loading là 1 kĩ thuật tối ưu khi làm web, thay vì tải toàn bộ trang web và render ngay từ đầu, kỹ thuật này cho phép tải ngay các thành phần cần thiết để hiển thị tới người dùng và trì hoãn các tài nguyên còn lại cho đến khi cần.
Kỹ thuật lazy loading cũng được sử dụng rất nhiều trong thực tế như sử dụng Lazy Loading đối với ảnh, sử dụng Lazy Loading đối với hiển thị component trong React Js, Lazy Loading đối với danh sách.

Lazy Loading đối với ảnh


Lazy Loading đối với danh sách


Trong thực tế việc xử lý dữ liệu trong kỹ thuật Infinite Scrolling and Lazy Loading sẽ căn cứ vào yêu cầu của dự án, có thể danh sách dữ liệu sẽ được lấy thông qua API một lần duy nhất ( allPages ) hoặc việc lấy dữ liệu sẽ được thực hiện thông qua mỗi lần gọi API với phân trang.
^^Trong bài viết này mình sẽ thực hiện gọi API với phân trang.^^

Dưới đây là quá trình mình làm task này

Trước tiên ta phải đi tìm một API Free nào đó để lấy dữ liệu. Và mình chọn https://newsapi.org/ tuy nhiên API này chỉ sử dụng được với domain localhost ( môi trường development ). Do nó free mà. nếu muốn sử dụng với môi trường Production thì tất nhiên phải bỏ $. Do vậy mình đã quyết định tự xây dựng API để đỡ phải phụ thuộc họ. Và mình cũng public tại đây : https://tran-dinh-thang-news-api.herokuapp.com/
Mô tả về dùng API :
Cung cấp 3 params : q, page, pageSize.
q : từ khóa muốn tìm kiếm.
page : trang muốn hiển thị.
pageSize : số lượng dữ liệu hiện thị tối đa một trang.


Xây dựng Custom Hook useArticlesQuery thực việc xử lý vấn đề logic và gọi API .

import useArticlesQuery from "./useArticlesQuery";
import {useCallback, useRef, useState} from "react";
import { Spin, Input, Col, Row } from 'antd';
import "./assets/css/ArticlesPage.css";
import 'antd/dist/antd.css';
import VietnamPicture from "./assets/images/vietname.jpg";

const RenderArticle = (props) => {
    const {
        article
    } = props;
    return (
        <Row>
            <Col xs={24} sm={24} md={8} lg={6} className="article_image_wrapper">
                <img src={article["urlToImage"] ?? VietnamPicture} alt=""/>
            </Col>
            <Col xs={24} sm={24} md={16} lg={18} className="article_content">
                <a href={article["url"]} target="_blank" className="article_title">
                    {article["title"]}
                </a>
                <div className="article_description">
                    {article["description"]}
                </div>
            </Col>
        </Row>
    )
}

const ArticlesPage = (props) => {
    const [query, setQuery] = useState('')
    const [pageNumber, setPageNumber] = useState(1);
    const {
        articles,
        hasMore,
        loading,
        error
    } = useArticlesQuery(query, pageNumber);
    const observer = useRef();
    const lastArticlesElementRef = useCallback(node => {
        if (loading) return
        if (observer.current) observer.current.disconnect()
        observer.current = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting && hasMore) {
                setPageNumber(prevPageNumber => prevPageNumber + 1)
            }
        })
        if (node) observer.current.observe(node)
    }, [loading, hasMore])

    console.log(pageNumber)
    console.log(query)
    return (
        <div className="articles_page_wrapper">
            <div className="articles_page_search">
                <Input.Search value={query} onChange={(event) => {
                    setQuery(event.target.value);
                    setPageNumber(1);
                }} allowClear style={{ width: 300 }} placeholder="Enter keyword ..." className="search_input"/>
            </div>
            <div className="articles_page_body">
                {
                    articles.map((article, index) => {
                        if (articles.length === index + 1) {
                            return (
                                <div className="article_item" ref={lastArticlesElementRef}>
                                    <RenderArticle article={article}/>
                                </div>
                            )
                        } else {
                            return (
                                <div className="article_item">
                                    <RenderArticle article={article}/>
                                </div>
                            )
                        }
                    })
                }
                {
                    loading ?
                        <div className="loading">
                            <Spin />
                        </div>
                        :
                        <>
                            {articles.length === 0 && <div>
                                No article
                            </div>}
                        </>
                }
            </div>
        </div>
    )
}

export default ArticlesPage;


Tiếp theo ta xây dựng giao diện :
JSX

<div className="articles_page_wrapper">
    <div className="articles_page_search">
        <Input.Search value={query} onChange={(event) => {
            setQuery(event.target.value);
            setPageNumber(1);
        }} allowClear style={{ width: 300 }} placeholder="Enter keyword ..." className="search_input"/>
    </div>
    <div className="articles_page_body">
        {
            articles.map((article, index) => {
                if (articles.length === index + 1) {
                    return (
                        <div className="article_item" ref={lastArticlesElementRef}>
                            <RenderArticle article={article}/>
                        </div>
                    )
                } else {
                    return (
                        <div className="article_item">
                            <RenderArticle article={article}/>
                        </div>
                    )
                }
            })
        }
        {
            loading ?
                <div className="loading">
                    <Spin />
                </div>
                :
                <>
                    {articles.length === 0 && <div>
                        No article
                    </div>}
                </>
        }
    </div>
</div>



// renderArticle
const RenderArticle = (props) => {
    const {
        article
    } = props;
    return (
        <Row>
            <Col xs={24} sm={24} md={8} lg={6} className="article_image_wrapper">
                <img src={article["urlToImage"] ?? VietnamPicture} alt=""/>
            </Col>
            <Col xs={24} sm={24} md={16} lg={18} className="article_content">
                <a href={article["url"]} target="_blank" className="article_title">
                    {article["title"]}
                </a>
                <div className="article_description">
                    {article["description"]}
                </div>
            </Col>
        </Row>
    )
}

CSS

.App {
    text-align: center;
}

.App-logo {
    height: 40vmin;
    pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
    .App-logo {
        animation: App-logo-spin infinite 20s linear;
    }
}

.App-header {
    background-color: #282c34;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: calc(10px + 2vmin);
    color: white;
}

.App-link {
    color: #61dafb;
}

@keyframes App-logo-spin {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}


Xử lý việc nhận biết event khi người dùng scroll đến cuối danh sách :

const [query, setQuery] = useState('')
const [pageNumber, setPageNumber] = useState(1);
const {
    articles,
    hasMore,
    loading,
    error
} = useArticlesQuery(query, pageNumber);
const observer = useRef();
const lastArticlesElementRef = useCallback(node => {
    if (loading) return
    if (observer.current) observer.current.disconnect()
    observer.current = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting && hasMore) {
            setPageNumber(prevPageNumber => prevPageNumber + 1)
        }
    })
    if (node) observer.current.observe(node)
}, [loading, hasMore])


Đặt ref tại item cuối cùng của danh sách : 

if (articles.length === index + 1) {
    return (
        <div className="article_item" ref={lastArticlesElementRef}>
            <RenderArticle article={article}/>
        </div>
    )
} else {
    return (
        <div className="article_item">
            <RenderArticle article={article}/>
        </div>
    )
}

File Source :
ArticlesPage.js

import useArticlesQuery from "./useArticlesQuery";
import {useCallback, useRef, useState} from "react";
import { Spin, Input, Col, Row } from 'antd';
import "./assets/css/ArticlesPage.css";
import 'antd/dist/antd.css';
import VietnamPicture from "./assets/images/vietname.jpg";

const RenderArticle = (props) => {
    const {
        article
    } = props;
    return (
        <Row>
            <Col xs={24} sm={24} md={8} lg={6} className="article_image_wrapper">
                <img src={article["urlToImage"] ?? VietnamPicture} alt=""/>
            </Col>
            <Col xs={24} sm={24} md={16} lg={18} className="article_content">
                <a href={article["url"]} target="_blank" className="article_title">
                    {article["title"]}
                </a>
                <div className="article_description">
                    {article["description"]}
                </div>
            </Col>
        </Row>
    )
}

const ArticlesPage = (props) => {
    const [query, setQuery] = useState('')
    const [pageNumber, setPageNumber] = useState(1);
    const {
        articles,
        hasMore,
        loading,
        error
    } = useArticlesQuery(query, pageNumber);
    const observer = useRef();
    const lastArticlesElementRef = useCallback(node => {
        if (loading) return
        if (observer.current) observer.current.disconnect()
        observer.current = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting && hasMore) {
                setPageNumber(prevPageNumber => prevPageNumber + 1)
            }
        })
        if (node) observer.current.observe(node)
    }, [loading, hasMore])

    console.log(pageNumber)
    console.log(query)
    return (
        <div className="articles_page_wrapper">
            <div className="articles_page_search">
                <Input.Search value={query} onChange={(event) => {
                    setQuery(event.target.value);
                    setPageNumber(1);
                }} allowClear style={{ width: 300 }} placeholder="Enter keyword ..." className="search_input"/>
            </div>
            <div className="articles_page_body">
                {
                    articles.map((article, index) => {
                        if (articles.length === index + 1) {
                            return (
                                <div className="article_item" ref={lastArticlesElementRef}>
                                    <RenderArticle article={article}/>
                                </div>
                            )
                        } else {
                            return (
                                <div className="article_item">
                                    <RenderArticle article={article}/>
                                </div>
                            )
                        }
                    })
                }
                {
                    loading ?
                        <div className="loading">
                            <Spin />
                        </div>
                        :
                        <>
                            {articles.length === 0 && <div>
                                No article
                            </div>}
                        </>
                }
            </div>
        </div>
    )
}

export default ArticlesPage;

useArticlesQuery.js

import {useEffect, useState} from "react";
import axios from "axios";
import {API_KEY, BASE_API, PAGE_SIZE} from "./constants";

export default function useArticlesQuery(query, pageNumber) {
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(false);
    const [articles, setArticles] = useState([]);
    const [hasMore, setHasMore] = useState(false);

    useEffect(() => {
        setArticles([]);
    }, [query])

    useEffect(() => {
        let cancel
        setLoading(true);
        setError(false);
        let url = `${BASE_API}/articles?page=${pageNumber}&pageSize=${PAGE_SIZE}`;
        if (query) {
            url += `&q=${query}`
        }
        axios({
            method: 'GET',
            url: url,
            cancelToken: new axios.CancelToken(c => cancel = c)
        }).then(res => {
            setArticles(prevArticles => {
                return [...new Set([...prevArticles, ...res.data.articles])]
            })
            setHasMore(pageNumber < res.data.maxPerPage)
            setLoading(false)
        }).catch(e => {
            setLoading(false)
            setError(true)
            if (axios.isCancel(e)) return
        })
        return () => cancel()
    }, [query, pageNumber])

    return { loading, error, articles, hasMore }
}

Source Code : 

https://github.com/trandinhthangdev/react-infinite-scrolling-and-lazy-loading-from-api

Demo :

...

2023 © Thang Tran