※当サイトの記事には、広告・プロモーションが含まれます。

Reactで無限スクロールを実施してみる

www.itmedia.co.jp

 仕様案には、「位置追跡端末を誤用すると、ストーカー行為、嫌がらせ、盗難などの悪意のある目的で、個人やアイテムが不要に追跡される可能性がある」が、「メーカー向けの一連のベストプラクティスを明文化することで、さまざまなプラットフォームでの悪用検出技術とのスケーラブルな互換が可能になる」とある。

AppleとGoogle、AirTagなどの位置追跡端末悪用対策で共闘 業界仕様案提出 - ITmedia NEWS

⇧ 悪用検出を完全に網羅できるものなのかね?

無限スクロールとは?

Wikitionaryによると、

(Internet) A web design technique that loads more content as the user scrolls towards the end of the loaded content. This prevents the browser scroll bar from scrolling to the bottom of the page, causing the page to grow with additional content instead, until there are no more results to be displayed.

https://en.wiktionary.org/wiki/infinite_scroll

⇧ とのこと。

Googleさんによりますと、

developers.google.com

長いリストの一部だけを表示する方法として、以下の UX パターンがあります。

  • ページ分け: 「次へ」「前へ」、ページ番号などのリンクを使用して、検索結果を一度に 1 ページずつ表示するページの間を移動できます。
  • さらに読み込む: このボタンをクリックすると、最初に表示された検索結果が拡張されます。
  • 無限スクロール: ページの末尾にスクロールすると、コンテンツがさらに読み込まれます(詳しくは、検索に適した無限スクロールのおすすめの方法をご覧ください)。

ページネーション、無限スクロールや「もっと見る」ボタン | Google 検索セントラル  |  ドキュメント  |  Google Developers

⇧ 大量のコンテンツを表示する際のUXパターンの内の1つが「無限スクロール」ってことらしい。

「フロントエンド」での制御が変わってくるけども、「バックエンド」ではいずれのUXパターンでも「フロントエンド」に返すデータは変わらないと思われる。

Reactで無限スクロールするには

「フロントエンド」側で何が必要なのか。

qiita.com

zenn.dev

qiita.com

⇧ 上記サイト様によりますと、「無限スクロール」用のライブラリなしで実装できるらしい。

必要なのは、

  1. Intersection Observer API
  2. Reactのhook
  3. サーバーサイドへrequestするAPI

ということみたい。

「1.Intersection Observer API」については、JavaScriptの標準APIらしい。

「3.サーバーサイドへrequestするAPI」については、

  • fetch
  • axios

のどちらかが圧倒的に多いイメージ。

「TanStack Query (旧:React Query)」は、fetchやaxiosをシンプルに書けるようにしてくれるものらしい。

「サーバーサイド」は、外部のシステムから取得する感じにしたいと思います。

chocolat5.com

⇧ 上記サイト様を参考に、TMDbのアカウント作成・登録、TMDb APIの利用申請を行うことにしました。

あと、.envファイルの扱いで、

blog.okaryo.io

⇧「Vite」でReactプロジェクトを作成している場合、独自の読み込み方が必要になるっぽい。

Reactで無限スクロールを実施してみる

そんでは、実装してみたいと思います。

利用するプロジェクトは、

ts0818.hatenablog.com

⇧ 上記の記事の時のもので。

とりあえず、axiosをインストールしときます。

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\package.json

{
  "name": "my-react-spa-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@types/react-redux": "^7.1.25",
    "@types/react-router-dom": "^5.3.3",
    "axios": "^1.4.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-redux": "^8.0.5",
    "react-router-dom": "^6.10.0",
    "redux": "^4.2.1"
  },
  "devDependencies": {
    "@types/react": "^18.0.28",
    "@types/react-dom": "^18.0.11",
    "@vitejs/plugin-react": "^3.1.0",
    "typescript": "^4.9.3",
    "vite": "^4.2.0"
  }
}

プロジェクトの構成は以下のようになりました。

C:.
│  .env
│  .gitignore
│  index.html
│  package-lock.json
│  package.json
│  tsconfig.json
│  tsconfig.node.json
│  vite.config.ts
│  
├─node_modules
│  │  .package-lock.json
│  │  
│     ...省略
│      
│  ├─.bin
│  │
│     ...省略
│
│
├─public
│      vite.svg
│      
└─src
    │  App.css
    │  App.tsx
    │  index.css
    │  main.tsx
    │  tree.txt
    │  vite-env.d.ts
    │  
    ├─assets
    │      react.svg
    │      
    ├─components
    │  ├─functional
    │  │  └─form
    │  │          index.ts
    │  │          
    │  ├─model
    │  │  ├─dto
    │  │  └─form
    │  │      ├─book
    │  │      │      index.ts
    │  │      │      
    │  │      └─food-beverage-liqour
    │  ├─page
    │  │  ├─book
    │  │  │  │  index.tsx
    │  │  │  │  
    │  │  │  ├─children-book
    │  │  │  ├─commic
    │  │  │  │      index.tsx
    │  │  │  │      
    │  │  │  ├─foreign-book
    │  │  │  ├─hard-cover
    │  │  │  ├─magazine
    │  │  │  ├─paper-book
    │  │  │  ├─picture-book
    │  │  │  └─pocket-edition-book
    │  │  └─food-beverage-liqour
    │  │      ├─food-beverage
    │  │      └─liqour
    │  │          ├─beer
    │  │          ├─chuhai-and-cocktail
    │  │          ├─low-malt-beer
    │  │          ├─non-alcoholic
    │  │          ├─sake
    │  │          ├─shouchu
    │  │          ├─western-liqours-and-liquers
    │  │          └─wine
    │  └─ui
    │      └─button
    │          │  Button.tsx
    │          │  
    │          └─form
    │                  Button.tsx
    │                  
    ├─hooks
    │  └─util
    │      └─infinity-scroll
    │              searchElements.ts
    │              searchResult.tsx
    │              useIntersectionObserver.ts
    │              
    ├─page
    │  │  404.tsx
    │  │  
    │  ├─about
    │  │      index.tsx
    │  │      
    │  ├─book
    │  │  │  index.tsx
    │  │  │  
    │  │  ├─children-book
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─commic
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─foreign-book
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─hard-cover
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─magazine
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─paper-back
    │  │  │      index.tsx
    │  │  │      
    │  │  ├─picture-book
    │  │  │      index.tsx
    │  │  │      
    │  │  └─pocket-edition-book
    │  │          index.tsx
    │  │          
    │  ├─food-beverage-liqour
    │  │  │  index.tsx
    │  │  │  
    │  │  ├─food-beverage
    │  │  │      index.tsx
    │  │  │      
    │  │  └─liqour
    │  │      │  index.tsx
    │  │      │  
    │  │      ├─beer
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─chuhai-and-cocktail
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─low-malt-beer
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─non-alcoholic
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─sake
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─shouchu
    │  │      │      index.tsx
    │  │      │      
    │  │      ├─western-liqours-and-liquers
    │  │      │      index.tsx
    │  │      │      
    │  │      └─wine
    │  │              index.tsx
    │  │              
    │  ├─home
    │  │      index.tsx
    │  │      
    │  ├─layout
    │  │      content.tsx
    │  │      footer.tsx
    │  │      header.tsx
    │  │      helper-sidebar.tsx
    │  │      index.tsx
    │  │      sidebar.tsx
    │  │      
    │  ├─login
    │  │      index.tsx
    │  │      
    │  └─movie
    │      │  index.tsx
    │      │  
    │      ├─action
    │      │      index.tsx
    │      │      
    │      ├─dorama
    │      │      index.tsx
    │      │      
    │      └─sience-fiction
    │              index.tsx
    │              
    ├─routes
    │      route-book.ts
    │      route-food-beverage-liquor.ts
    │      route-movie.ts
    │      route.ts
    │      router.tsx
    │      
    └─stores
        │  store.ts
        │  
        ├─actions
        │      bookAction.ts
        │      commonAction.ts
        │      
        ├─constants
        │  └─actions
        │          index.ts
        │          
        └─reducers
                bookReducer.ts

Viteを使っているので、.envファイルに定義する変数名の先頭には「Vite_」を付けます。

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\.env

# TMDb API
VITE_REACT_APP_TMDB_API_KEY="[TMDB APIを利用するためのAPIキーの値]"    

「無限スクロール」用のソースコード。参考させていただいたサイト様のものをほぼそのまま使わせていただきました。

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\hooks\util\infinity-scroll\useIntersectionObserver.ts

import React from 'react'

export default function useIntersectionObserver({
  root,
  target,
  onIntersect,
  threshold = 1.0,
  enabled = true,
}: any) {
  React.useEffect(() => {
    if (!enabled) {
      return
    }

    const observer = new IntersectionObserver(
      entries =>
        entries.forEach(entry => entry.isIntersecting && onIntersect()),
      {
        root: root && root.current,
        threshold,
      }
    )

    const el = target && target.current

    if (!el) {
      return
    }

    observer.observe(el)

    return () => {
      observer.unobserve(el)
    }
  }, [target.current, enabled])
}

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\hooks\util\infinity-scroll\searchElements.ts

import { useEffect, useState } from 'react'
import axios from 'axios'

export default function SearchMovies(query: any, pageNumber: any) {
  const [loading, setLoading] = useState<any>(true) // ローディングの判定
  const [error, setError] = useState<any>(false) // エラーを検知したらセットする
  const [movies, setMovies] = useState<any>([]) // 検索結果をセットする
  const [hasMore, setHasMore] = useState<any>(false) // 検索結果が残っているか判定する

  useEffect(() => {
    setMovies([])
  }, [query])

  // api呼び出し
  useEffect(() => {
    setLoading(true)
    setError(false)
    let cancel: any
    
    // .envファイルの情報を取得
    const env = import.meta.env
    const apikey = env.VITE_REACT_APP_TMDB_API_KEY

    // 外部システム(TMDb)にリクエスト
    axios({
      method: 'GET',
      url: 'https://api.themoviedb.org/3/search/movie',
      params: { api_key: apikey, query: query, page: pageNumber },
      cancelToken: new axios.CancelToken(c => cancel = c)
    }).then(res => {
      console.log("■【response】https://api.themoviedb.org/3/search/movie")
      console.log(res)
      setMovies((prevMovies: any) => {
          return [...new Set([
            ...prevMovies, ...res.data.results.map((b: any) => b)
        ])]
      })
        setHasMore(res.data.results.length > 0)
        setLoading(false)
        console.log('then 成功です')
    }).catch(e => {
      if (axios.isCancel(e)) return
        setError(true)
        console.log('catch errorです')
    })
    return () => cancel()
  }, [query, pageNumber])
  return { loading, error, movies, hasMore }
}    

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\hooks\util\infinity-scroll\searchResult.tsx

import React, { useState, useRef, useCallback } from 'react'
import SearchMovies from "./searchElements"


export default function SearchResult() {
  const [query, setQuery] = useState('') // 検索ワードをセットする
  const [pageNumber, setPageNumber] = useState(1) // ページ番号をセットする

  const {
    movies,
    hasMore,
    loading,
    error
  } = SearchMovies(query, pageNumber)

  // ref対象を監視して表示終わったら、ページ番号を増やす
  const observer: any = useRef()
    const lastMovieElementRef = useCallback((node: any) => {
    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])

    // 入力値をセットする
  function handleSearch(e: any) {
    setQuery(e.target.value)
    setPageNumber(1)
  }

  const imgWidth = 300
  const imgHeight = 450
  return (
    <>
        <input type="text" value={query} onChange={handleSearch}></input>
        {movies.map((movie: any, index: any) => {
          console.log("■searchResult.tsx")
          console.log(movie)
          if (movies.length === index + 1) {
            return <div ref={lastMovieElementRef} key={movie.id}>
                <p>【ID】{movie.id}</p>
                <p>【Title】{movie.title}</p>
                <p>【Title(Original)】{movie.original_title}</p>
                {movie.poster_path &&
                  <img src={`https://image.tmdb.org/t/p/w${imgWidth}_and_h${imgHeight}_bestv2${movie.poster_path}`} />
                }
                </div>
          } else {
            return <div key={movie.id}>
                <p>【ID】{movie.id}</p>
                <p>【Title】{movie.title}</p>
                <p>【Title(Original)】{movie.original_title}</p>
                {movie.poster_path &&
                  <img src={`https://image.tmdb.org/t/p/w${imgWidth}_and_h${imgHeight}_bestv2${movie.poster_path}`} />
                }            
                </div>
        }
      })}
      <div>{loading && 'Loading...'}</div>
      <div>{error && 'Error'}</div>
    </>
  )
}

上記のコンポーネントを表示する用のページを追加。ジャンル別のページは使ってないけど、一応追加。

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\page\movie\index.tsx

// src/page/book/index.tsx
import React from 'react'
import { Outlet, useLocation } from 'react-router-dom';
import InfinityScroll from "../../hooks/util/infinity-scroll/searchResult";

const Movie: React.FC = () => {
    const location = useLocation();
    return (
        <div>
            {location.pathname === "/movie" && <h1>Movie</h1>}
            <Outlet />
            <InfinityScroll />
        </div>
    );
}

export default Movie    

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\page\movie\action\index.tsx

// src/page/movie/action/index.tsx
import React from 'react'

const Action: React.FC = () => {
    return (
        <div>
            <h1>Action</h1>
        </div>
    );
}

export default Action    

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\page\movie\dorama\index.tsx

// src/page/movie/dorama/index.tsx
import React from 'react'

const Dorama: React.FC = () => {
    return (
        <div>
            <h1>Dorama</h1>
        </div>
    );
}

export default Dorama    

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\page\movie\sience-fiction\index.tsx

// src/page/movie/sience-fiction/index.tsx
import React from 'react'

const SienceFiction: React.FC = () => {
    return (
        <div>
            <h1>SienceFiction</h1>
        </div>
    );
}

export default SienceFiction    

routerの定義を追加。

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\routes\route-movie.ts

// src/routes/route-movie.ts
import React, { ReactElement } from "react";
import Movie from "../page/movie";
import Action from "../page/movie/action";
import Dorama from "../page/movie/dorama";
import SienceFiction from "../page/movie/sience-fiction";


// react-router-domのRouteObject[]型はkeyが無いので独自型を定義
export type RouteType = { 
    index?: boolean;
    key?: string; 
    path: string; 
    element: ReactElement; 
    children?: RouteType[];
};
  
const bookRoutes: RouteType[] = [
  {
    key: "movie" 
    ,path: "movie" // 映画
    ,element: React.createElement(Movie)
    ,children: [
      {
        key: "action"
        ,path: "action" // アクション
        ,element: React.createElement(Action)
      }       
      ,{
         key: "dorama"
         ,path: "dorama" // ドラマ
         ,element: React.createElement(Dorama)
      }
      ,{
         key: "sience-fiction"
         ,path: "sience-fiction" // SF
         ,element: React.createElement(SienceFiction)
      }     
    ] 
  }
];
      
export default bookRoutes;

■C:\Users\Toshinobu\Desktop\soft_work\react_work\my-react-spa-app\src\routes\route.ts

// src/page/routes/route.ts
import React, { ReactElement } from "react";
import { BrowserRouter, Routes, Route, RouteObject } from "react-router-dom";
import bookRoutes from "../routes/route-book";
import foodBeverageLiquorRoutes from "../routes/route-food-beverage-liquor";
import movieRoutes from "../routes/route-movie";
import Page404 from "../page/404";
import Home from "../page/layout"

//const routes = [bookRoutes, foodBeverageLiquor];

// react-router-domのRouteObject[]型はkeyが無いので独自型を定義
export type RouteType = {
    index?: boolean;
    key?: string;
    path: string;
    element: ReactElement;
    children?: RouteType[];
};

// routesの要素をRouteTypeに合わせて定義
const routes: RouteType[] = [
  {
    key: "home"
    ,path: "/"
    ,element: React.createElement(Home)
    ,children: [
      bookRoutes[0]
      ,foodBeverageLiquorRoutes[0]
      ,movieRoutes[0]
    ]
  }
  ,{
    key: "404"
    ,path: "*"
    ,element: React.createElement(Page404)      
  }
];
export default routes;

で、開発用サーバーを起動。

ブラウザで、inputに文字入力すると、TMDB APIが実施されて、検索結果が表示されることが確認できました。

ブラウザでスクロールをしていくと、Intersection Obsever APIが検知して、TMDB APIが実施されて続きの検索結果を返してくれました。

ちなみに、TMDBのサイト自体も「無限スクロール」使ってるっぽい。

「無限スクロール」を実装する際の注意点として、

www.quora.com

blog.ojisan.io

例えばコンテンツが 10000 個あったとして、それを全部表示させることが無限スクロールだと可能になっている。 これの何が問題かと言うと、その描画や状態がメモリに全部載ることだ。 そのためたとえばスマホのような RAM が少ない端末であればとてもスクロールが重たくなる。 これは普通のページネーションであれば 1URL につき 週十件程度の表示で済んでいるので無限スクロール特有の問題と言える。

無限スクロールは考慮することが多い | blog.ojisan.io

これの解決方法はいわゆる "バーチャルスクロール" の導入だ。 React には画面の表示領域に映るものしか実際にレンダリングをしないというライブラリがあり、代表的なものは react-window や react-virtualized などが有名だ。 ただこれらは古くからあるライブラリなのでもしかすると今は新しいのがあるかもしれない。

無限スクロールは考慮することが多い | blog.ojisan.io

⇧ メモリーの逼迫があるようです。

う~む、結局、「無限スクロール」を実用的なレベルにするには、ライブラリを使わざるを得ないってことになりそうですかね...

毎度モヤモヤ感が半端ない...

今回はこのへんで。