最近Reactでの開発タスクが増大中のWebデザイナーです。
フロントエンドエンジニアではありません。 Webデザイナーです。
つい先日、とあるページを React + Redux で書き直しました。
今回は、その際に ページ送り(pagination)もReactでレンダリングするようにしたよ という話です。
なぜReactでレンダリングしたの??
Railsアプリケーションでgemのkaminariを導入していて、ページネーションにはkaminariを使用していました。
普通に使う分にはkaminariのviewにスタイルつけて = paginate @hogehoge
みたいにviewで呼び出せばいいのですが、今回は以下の事情でReactで描画するようにしました。
- タグやエリア、期間のような条件での絞り込みが発生するため、ページ全体をReact + Reduxでステート管理できるようにしたかった
- そのため、ページ送りも含めなければいけなかった
- 都度、ページ全体を読み込むのではなく、jsだけの読み込みにして速度改善したかった
実装してみると、特に3の効果が絶大で、絞り込みや条件の追加、ページ送りなどをした時の読み込みが超速になりました。何秒速くなったのかは計測していないですが、実装前を知っている人からは感嘆の声が漏れてきました。
Reactで書いてみよう
kaminariから以下の値をReactコンポーネントに渡してあげる必要があります。
- current_page: 現在のページ
- total_pages: 総ページ数
また、今回はやりませんが、limit_value(ページあたりの表示件数)とtotal_count(合計件数)も渡してやれば、何件中何件を表示していますを出すことも可能です。
ReactではこれらをPropsとして受け取り、以下のコンポーネントを作成していきます。
- Pagination
- FirstPaginationItem: 最初のページ
- LastPaginationItem: 最後のページ
- PrevPaginationItem: 1つ戻る
- NextPaginationItem: 1つ進む
- Gap:
...
で表示される - PaginationItem: 該当ページへのリンク
※ 最後に全体のコードを載せています
PaginationItemを作成しよう
ページネーションのHTML構成はリストタグにしたいので、これから作るPaginationItem
は<li></li>
で出力されるようにします。
const FirstPaginationItem = ({ onClick }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(1)}>
«
</button>
</li>
)
最初のページはこのように書けます。基本的にPaginationItem
はonClick
に渡す値と、ボタンのテキストが変わるだけでほぼ同じ構成になります。
- FirstPaginationItem: 最初のページなので常に
1
を渡す - LastPaginationItem: 最後のページは総ページ数と同じなので
total_pages
を渡す - PrevPaginationItem: 1つ戻さないといけないので、
current_page - 1
する - NextPaginationItem: 1つ進まないといけないので、
current_page + 1
する
のようになりますね。
ついでに Gap
もやってしまいましょう。
const Gap = () => (
<li className="pagination-item">
<span className="gap">…</span>
</li>
)
…
は ...
と表示されます。Gapは、ページ数が多い場合に、間を省略するために使用するコンポーネントです。
Paginationを作成しよう
必要なコンポーネントができたので、PaginationItem
をラップするPagination
コンポーネントを作成します。
<ul className="pagination">
{firstPage}
{previousPage}
{leftGap}
{pages}
{rightGap}
{nextPage}
{lastPage}
</ul>
完成時にはこのような形式で出力されて欲しいので、{}
された部分を作っていきます。
とはいっても前項でPaginationItem
を用意しているので、それ以外の ...[2][3][4][5][6]...
のような箇所を作ってみます。
// 数の配列を作るために使用します
import { range } from 'lodash'
// 現在のページを起点に左右に表示するアイテムの数
const PADDING = 3
const Pagination = ({ pagination, onPaginationClick }) => {
// 現在のページ
const currentPage = pagination.current_page
// 総ページ数
const totalPages = pagination.total_pages
...省略
const pages = [
...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
].map(page => {
// 現在のページかどうかを判定
const isCurrent = page === currentPage
// 現在のページはspanで出力する
const paginationItem = isCurrent
? <span>{page}</span>
: <button type="button" onClick={() => onPaginationClick(page)}>{page}</button>
return <li className="pagination-item" key={page}>{paginationItem}</li>
})
...省略
}
最後に全体コードがあるので一部省略していますが、ポイントは以下ですね。
lodashのrangeメソッドを使って、出力したいページ番号の配列を作っています。
const pages = [
// 現在のページが4なら[1][2][3]
...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
// 現在のページが4で総ページ数が6なら[4][5][6]
...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
]
後はこのページ番号が格納されたpages
配列を使って、現在のページとそれ以外で出力するhtmlを出し分けるだけですね。
全体のコードを見てみよう
長い道のりを適度に端折ってきましたが、最後に全体のコードを載せて締めたいと思います。
import PropTypes from 'prop-types'
import React from 'react'
import { range } from 'lodash'
const PADDING = 3
const FirstPaginationItem = ({ onClick }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(1)}>
«
</button>
</li>
)
const LastPaginationItem = ({ onClick, totalPages }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(totalPages)}>
»
</button>
</li>
)
const PrevPaginationItem = ({ onClick, prevPage }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(prevPage)}>
<
</button>
</li>
)
const NextPaginationItem = ({ onClick, nextPage }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(nextPage)}>
>
</button>
</li>
)
const Gap = () => (
<li className="pagination-item">
<span className="gap">…</span>
</li>
)
const Pagination = ({ pagination, onPaginationClick }) => {
const currentPage = pagination.current_page
const totalPage = pagination.total_pages
// 最初のページ
const firstPage = currentPage > 1 ? <FirstPaginationItem onClick={onPaginationClick} /> : null
// 最後のページ
const lastPage =
currentPage !== totalPages ? <LastPaginationItem onClick={onPaginationClick} totalPages={totalPages} /> : null
// 1つ戻る
const previousPage =
currentPage >= 2 ? <PrevPaginationItem onClick={onPaginationClick} prevPage={currentPage - 1} /> : null
// 1つ進む
const nextPage =
currentPage + 1 <= totalPages ? <NextPaginationItem onClick={onPaginationClick} nextPage={currentPage + 1} /> : null
// 現在のページと現在のページの左右にPADDINGで指定した数のアイテムを作成します
const pages = [
...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
].map(page => {
// 現在のページかどうかを判定
const isCurrent = page === currentPage
// 現在のページはspanで出力する
const paginationItem = isCurrent ? (
<span>{page}</span>
) : (
<button type="button" onClick={() => onPaginationClick(page)}>
{page}
</button>
)
return (
<li className="pagination-item" key={page}>
{paginationItem}
</li>
)
})
// 左右に `...` を表示する条件を追加
const leftGap = currentPage > PADDING + 1 ? <Gap /> : null
const rightGap = currentPage + PADDING < totalPages ? <Gap /> : null
return (
<ul className="pagination">
{firstPage}
{previousPage}
{leftGap}
{pages}
{rightGap}
{nextPage}
{lastPage}
</ul>
)
}
FirstPaginationItem.propTypes = {
onClick: PropTypes.func.isRequired
}
LastPaginationItem.propTypes = {
onClick: PropTypes.func.isRequired,
totalPages: PropTypes.number.isRequired
}
PrevPaginationItem.propTypes = {
onClick: PropTypes.func.isRequired,
prevPage: PropTypes.number.isRequired
}
NextPaginationItem.propTypes = {
onClick: PropTypes.func.isRequired,
nextPage: PropTypes.number.isRequired
}
Pagination.propTypes = {
onPaginationClick: PropTypes.func.isRequired,
pagination: PropTypes.object.isRequired
}
export default Pagination
まとめ
kaminariのviewをjsに移植したコードをあれこれ探して見ていたのですが、いまいちピンとくるものがなく、「それなら自分で書いてみよう。」と思ったのが今回の発端です。
で、実装完了した時に**「これやりたい人結構多いと思いますよ 」**と、同じ開発チームの優秀エンジニアが言ってくれたので書いた次第です。
実際、このコンポーネントがあれば必要な箇所でpropsだけ渡してあげれば使い所も多いのではないかと思っています。