Help us understand the problem. What is going on with this article?

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

mde
refcome
採用を仲間集めに。
https://about.refcome.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away