JavaScript
kaminari
JSX
React

kaminariのviewをReactで書いてみた

最近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だけ渡してあげれば使い所も多いのではないかと思っています。