この記事は、DMMグループAdvent Calendar 2021の14日目の記事です。
こんにちは。DMMのプラットフォーム(以下PFと略称)事業本部所属の佐々木勝春と申します。
私が所属するチームでは現在「DMMポイントクラブ」というDMMのポイントを獲得したりチャージしたり、履歴の確認などができるアプリ(iOS/AndroidとWeb)を運営しております。私はこの両システムのサーバーサイドの開発とWeb版のフロントの開発を担当しております。
DMMポイントクラブ|lp.pointclub.dmm.com
今回はこちらのWeb版ポイントクラブのポイント管理ページにあるポイント履歴一覧のページネーションを実装する際に工夫した点についてご紹介させていただきます。
Web版ポイントクラブのフロントエンドのシステム構成概要
前提として、まずWeb版ポイントクラブのシステム構成の概要について簡単に説明いたします。 フロントエンドの実装にUIライブラリであるReactを採用していて、 インフラ環境はAWSのCloudFrontとS3を活用してファイルを配信しています。
React – ユーザインターフェース構築のための JavaScript ライブラリ|ja.reactjs.org
本題
ポイント履歴一覧のページネーションのデザイン案
ユーザーのポイントの獲得・消費などのポイント使用履歴一覧や保有しているポイントの有効期限の一覧を、下記のポイント利用情報のセクションで表示します。ポイントの履歴を複数表示するためにページネーションを活用しています。
ポイント利用情報のデザイン原案
サービス間のシステム構成
ここで具体的なシステム構成について見ていきます。 上記ポイント履歴情報を取得するにあたって、ブラウザ側でポイントクラブチームで開発したバックエンドのAPIにリクエストして情報を取得し、その結果を表示に利用しています。 ポイントクラブAPI内部ではDMMのPFをはじめとする各マイクロサービスにリクエストを行なうことでデータのやり取りを行なっています。その一つであるDMMポイントに関する機能を提供するポイントAPIにリクエストを行いポイント情報履歴を取得して、それをポイントクラブAPIでクライアント側に適切な形式に加工してあげて返却する構成になっています。
システム構成概念図(システムのグルーピングや認証ゲートウェイなどは省略)
ページネーション実装にあたっての課題
上述の外部サービスのポイントAPIの利用にあたって一つの課題となったのがポイントの履歴の取得件数が最大で50件までという制限でした。 上述のWeb版のポイント履歴のデザインの原案としてはページネーションのページのあるページ数分(今回は4ページ分)ページを表示して、それ以上のページデータを保有している場合は三点リーダで省略表示した後最後のページの数を表示するようなデザイン案でした。
しかし、最大取得件数の制限より上記デザイン原案の最大ページ数の表示の実現が困難になりました。
対応策
最後のページ数は取得できないものの、下記のようにある指定の表示幅のページ数を表示して(今回の場合は4ページ分)、 それ以上のページ数のデータがある場合は三点リーダーで省略表示し、ページネーション押下毎にページ表示幅以降のデータを取得してきて表示する方法を考えました。この方法であればポイントAPIでの取得最大件数の制約下でも実装できることが分かりました。
ページネーションデザイン修正案
対応策の実装方法検討
上記の対応策を実装するにあたって考慮点がいくつかありました。 各ページインデックス毎のデータを管理しなければならず、また複数ページ表示のためにあらかじめデータを取得しておく必要がありました。 また、ページネーションのクリックイベント時にデータ取得でAPIリクエストする際に、それまでのポイントクラブの実装ではAPIリクエストによるデータ取得用ライブラリのSWR
をラップしてHook関数化したものを利用していました。しかし、Hook関数のルールではJavaScript関数内部からHook関数を直接呼ぶことができず、ページネーション実装の部分だけデータ取得のこの実装形式を統一できないなど、もやもやする部分がありました。
データ取得のための React Hooks ライブラリ – SWR|swr.vercel.app
const handlePaginate = (selectedItem: { selected: number }) => {
const { data } = usePointHistory() // JavaScript関数内部からはHook関数は呼べない
setCurrentPage(selectedItem.selected);
};
useSWRInfiniteを活用
ページネーション の実装に関して調査した結果、上述のデータ取得ライブラリSWR
の1機能としてページネーション実装用に useSWRInfinite
というモジュールが提供されていることが分かりました。 下記で詳細を見ていきますが、このモジュールを使うことでとても簡単にページネーション関連を実装できることが分かりました。
useSWRInfinite定義
import useSWRInfinite from 'swr/infinite'
// ...
const { data, error, isValidating, mutate, size, setSize } = useSWRInfinite(
getKey, fetcher?, options?
)
引数
getKey
ページのインデックス(ページ数)と全ページのデータを引数に取る関数でレスポンスとしてページのキーを返します。 fetcher
はuseSWR
と同様にデータをフェッチするためのPromise
を返す関数を指定します。 options
もuseSWR
と同様の設定に加えて、下記のページネーション特有のオプションがあります。
initialSize
= 1: 最初にロードするページ数revalidateAll
= false: 常にすべてのページに対して再検証を試みる設定revalidateFirstPage
= true: 常に最初のページに対して再検証を試みる設定persistSize
= false: 最初のページのキーが変更されたときに、ページサイズを 1 (またはセットされていれば initialSize)にリセットしない
レスポンス
上記レスポンスの data
, error
, isValidating
, mutate
に関してはuseSWR
と基本同じ内容のため割愛します。
それ以外のレスポンスsize
に関して、フェッチ後に返されうる想定ページ数の値で、 setSize
は初回以降のページのデータを取得していく際にAPIリクエストに指定するページ数をセットする関数です (ex: ページ数2のデータを取得したい場合→ setSize(2)
を実行します)
さらなるAPIの詳細については下記を参考ください。 下記以降でWeb版ポイントクラブでのポイント履歴のページネーションの実装例を具体的に見ていきます。
参考 https://swr.vercel.app/ja/docs/pagination#useswrinfinite
ポイント履歴一覧での実装例
下記はuseSWRInfinite
を利用したAPIリクエスト用のHook関数の定義です。 (import文や変数宣言部分など諸々省略して本質的な部分のみ簡略的に記載しています)
データ取得用Hook関数定義
import { useState, useEffect } from 'react';
import useSWRInfinite from 'swr/infinite'
const usePointHistory = (
fromUnixTime: number,
toUnixTime: number
): PointHistoryListResponse => {
const { data, size, setSize } = useSWRInfinite<FetchPointHistoryResponse>(
(pageIndex: number, previousPageData: FetchPointHistoryResponse | null) => {
if (previousPageData && !previousPageData.length) return null;
return `/v1/points/history?page=${pageIndex}&per=10&from=${fromUnixTime}&to=${toUnixTime}`;
},
pointClubAPIFetcher,
{
initialSize: 5, // プリフェッチで事前に5ページ分初回ページロード時にデータを取得する
}
);
const [res, setRes] = useState<PointHistoryListResponse>({
pointHistoryList: [],
page: 0,
});
useEffect(() => {
if (!data) return;
setRes({
pointHistoryList: data,
page: size,
setPage: setSize,
});
}, [data, size, setSize]);
return res;
};
export default usePointHistory;
下記部分で useSWRInfinite
を呼び出しています。 第一引数のgetKey
部分には現在のページインデックスに加えて、前ページのデータを引数にとる関数が指定されているため、インデックスとカーソルベースの両方のタイプのページネーションの実装に活用できます。 第二引数にデータ取得用Fetcher
関数を指定して、第三引数でoption
を指定しています。 option
で{initialSize: 5}
を指定することにより初回レンダー時にデフォルトの1ページ分ではなく5ページ分のデータをあらかじめAPIリクエストして習得してくれるようになります。
const { data, size, setSize } = useSWRInfinite<FetchPointHistoryResponse>(
(pageIndex: number, previousPageData: FetchPointHistoryResponse | null) => {
if (previousPageData && !previousPageData.length) return null;
return `/v1/points/history?page=${pageIndex}&per=10&from=${fromUnixTime}&to=${toUnixTime}`;
},
pointClubAPIFetcher,
{
initialSize: 5,
}
ブラウザでの初回ページ遷移時のリクエストログ
そしてレスポンスに関して、レスポンスデータを格納するdata
と、取得したページ数を返す size
,そして次ページ以降取得するページ数をセットするためのsetSize
関数を受け取ります。 注目の点としてdata
の構造が各ページ数をキーにそのレスポンスを配列で格納する二重配列の構造になっています。
// `data` はこのようになります
[
// 1ページ目のAPIリクエストのレスポンスを格納
[
{ id: 'xxxx', pointAmount: 500, ... },
{ id: 'xxxx', pointAmount: 200, ... },
{ id: 'xxxx', pointAmount: 400, ... },
...
],
// 2ページ目
[
{ id: 'xxxx', pointAmount: -300, ... },
{ id: 'xxxx', pointAmount: 600, ... },
{ id: 'xxxx', pointAmount: 50, ... },
...
],
...
]
ポイント履歴一覧(ページネーション)コンポーネント
上記のHook関数を利用してポイント履歴一覧を表示するコンポーネントが下記になります。
import React, { useState } from 'react';
import PointHistoryListItem from '../HistoryPointListItem';
import styles from './index.module.scss';
import Paginate from '../Paginate';
import usePointHistory from '../../hooks/usePointHistory';
const HistoryPointList: React.FC = () => {
const [currentPage, setCurrentPage] = useState(0);
const { pointHistoryList, page, setPage } = usePointHistory(fromUnixTime, toUnixTime);
let totalItems = 0; // 全ページでのポイント履歴の総件数
for (let i = 0; i < pointHistoryList.length; i += 1) {
totalItems += pointHistoryList[i].length;
}
const handlePaginate = (selectedItem: { selected: number }) => {
setCurrentPage(selectedItem.selected);
if (setPage) {
setPage(page + 1);
}
};
return (
<>
<div className={styles.area_list}>
<ul className={styles.box_list}>
<li>
{pointHistoryList[currentPage].map((item) => (
<PointHistoryListItem
key={item.id}
points={item.points}
totalPoint={item.totalPoint}
/>
))
}
</li>
</ul>
</div>
<div className={styles.paginate_wrapper}>
<Paginate
pageCount={totalItems / listItemCountPerPage}
handlePaginate={handlePaginate}
/>
</div>
</>
);
};
export default HistoryPointList;
下記部分でページネーションデータ取得用のHook関数を呼び出しています。
const { pointHistoryList, page, setPage } = usePointHistory(fromUnixTime, toUnixTime);
hook関数のレスポンスpointHistoryList
からページネーション全体でのポイント履歴の件数を計算しています。
let totalItems = 0; // 全ページでのポイント履歴の総件数
for (let i = 0; i < pointHistoryList.length; i += 1) {
totalItems += pointHistoryList[i].length;
}
そしてページネーションのイベントハンドラを下記で定義しています。 現在のページ数をステートで定義し(currentPage
),イベントハンドラでSetState
でページ数を更新しています。 さらに、指定されたページ数でポイント履歴一覧取得APIのリクエストを行うように setPage
でページ数を設定します。 ここで指定したページのデータを既に取得済みの場合はSWR
の方でよしなにキャッシュから値を返すようにしてくれます。
const handlePaginate = (selectedItem: { selected: number }) => {
setCurrentPage(selectedItem.selected);
if (setPage) {
setPage(page + 1);
}
};
まとめ
データ取得ライブラリSWR
の1モジュールである useSWRInfinite
を活用することでページネーションが楽に実装する点についてみていきました。 SWR
はキャッシュをうまく有効活用してくれたり、リクエストでエラーが発生した場合の再検証の実装が楽にできたりとAPIリクエスト周りの処理をとても簡潔に実装できます。
明日DMMグループAdvent Calendar 202115日目は oda-daisuke さんです。
宣伝
DMMポイントクラブ(スマホアプリ版)では現在下記のキャンペーンを実施中です。 DMMポイントを活用してDMMの多種多様なサービスを体験してください。