Posted at

kaminariのviewをReactで書いてみた

More than 1 year has passed since last update.

最近Reactでの開発タスクが増大中のWebデザイナーです。

フロントエンドエンジニアではありません。 Webデザイナーです。

つい先日、とあるページを React + Redux で書き直しました。

今回は、その際に ページ送り(pagination)もReactでレンダリングするようにしたよ という話です。

pagination.png



こういうやつです。


なぜReactでレンダリングしたの??

Railsアプリケーションでgemのkaminariを導入していて、ページネーションにはkaminariを使用していました。

普通に使う分にはkaminariのviewにスタイルつけて = paginate @hogehoge みたいにviewで呼び出せばいいのですが、今回は以下の事情でReactで描画するようにしました。


  1. タグやエリア、期間のような条件での絞り込みが発生するため、ページ全体をReact + Reduxでステート管理できるようにしたかった

  2. そのため、ページ送りも含めなければいけなかった

  3. 都度、ページ全体を読み込むのではなく、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)}>
&laquo;
</button>
</li>
)

最初のページはこのように書けます。基本的にPaginationItemonClickに渡す値と、ボタンのテキストが変わるだけでほぼ同じ構成になります。


  • 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">&hellip;</span>
</li>
)

&hellip;... と表示されます。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)}>
&laquo;
</button>
</li>
)

const LastPaginationItem = ({ onClick, totalPages }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(totalPages)}>
&raquo;
</button>
</li>
)

const PrevPaginationItem = ({ onClick, prevPage }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(prevPage)}>
&lt;
</button>
</li>
)

const NextPaginationItem = ({ onClick, nextPage }) => (
<li className="pagination-item">
<button type="button" onClick={() => onClick(nextPage)}>
&gt;
</button>
</li>
)

const Gap = () => (
<li className="pagination-item">
<span className="gap">&hellip;</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に移植したコードをあれこれ探して見ていたのですが、いまいちピンとくるものがなく、「それなら自分で書いてみよう。」と思ったのが今回の発端です。

で、実装完了した時に「これやりたい人結構多いと思いますよ :bulb:と、同じ開発チームの優秀エンジニアが言ってくれたので書いた次第です。

実際、このコンポーネントがあれば必要な箇所でpropsだけ渡してあげれば使い所も多いのではないかと思っています。