[Vue.js] ページネーション機能の作り方とコンポーネント作成入門

[PHP] ページング機能の仕組みとか作り方とか が今年で10周年!

今はPHPを書くことはWordpressを魔改造するときくらいで、もっぱらJavasScriptを使う事が多いです。
2018年はずっとVue.jsと戯れていたので、Vueでpaginationコンポーネントはどうやって作れば良いの?という視点でまとめてみました。

環境構築は面倒くさいのでCodeSandbox使います!
なのでローカルにインストールからやってみたい方はVueの公式ドキュメント片手にトライしてどうぞ。


※以下ソースすべてES5以上の構文多用してます。
ドキュメントへのリンクを貼ってますので詳細はそちらを確認してください。

CodeSandboxにGithubアカウント(なかったら作る)でログインしたら、
ダッシュボードでCreate Sandboxを選択して、フォーク元になるVue.jsのサンドボックスを開いたら右上のForkボタンを押す。
すると自分のサンドボックスにVue.jsのサンドボックスが複製されます。コピーし終わったらスタートです。

ダミーデータ作成

準備できたら早速App.vueを開いてデフォルトのHelloWorldを消します。

<template>
  <div id="app">
  </div>
</template>

<script>
export default {
  name: "App",
  components: {},
};
</script>

<style>
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Gistでソースを見る

componentsの下にページング機能に必要なデータを設定します。

Vueはコンポーネント内で参照できるローカルなデータを定義できますが、
dataは関数じゃないとダメというルールがあるのでそのようにします。

  data() {
    return {
      items: [], //表示するデータがここに入る
      page: 1, //現在のページ番号
      perPage: 10, //1ページ毎の表示件数
      totalPage: 1, //総ページ数
      count: 0, //データの総数
    };
  },

※カレントのページ番号は1スタートとします。

ダミーデータは適当な文字列で200個くらい欲しいですね。
実際なにか作るときはAPIとかからデータ取得するわけですが、それをコンポーネントでやるとすると、
data()created()mounted()のいずれかになります。
createdとmountedはどちらもコンポーネントインスタンスが作成されたときに1度実行されます。
詳しいタイミングはライフサイクルダイアグラムが分かりやすいです。
createdとmountedはthis.$elが使えるかどうかが最大の違いと思います。
this.$elを参照する必要がなければdataかcreatedでおkです。
今回は参考にdataで直接作っておきます。

data() {
  const items = new Array(200).fill(null).map((e, i) => `Item ${i + 1}`);
  
  return {
    items, //表示するデータがここに入る
    page: 1, //現在のページ番号
    perPage: 10, //1ページ毎の表示件数
    totalPage: 1, //総ページ数
    count: 0 //itemsの総数
  };
},

はいこれで200個のダミーができました。
ざっくり説明すると、new Array(200)でlengthが200のArrayが出来るので、それをfill(null)で全部の値をnullにしてから、mapでnullを文字列に置き換えた配列を作る。という処理です。
for文でArray.pushするより楽ちん~。

変数itemsを直接オブジェクトにぶちこむのはObject Property Shorthandです。
こうするとtotalPageやcountなどもこの変数itemsを利用して設定できます。

  data() {
    const items = new Array(200).fill(null).map((e, i) => `Item ${i + 1}`);
    const perPage = 10;

    return {
      items, //表示するデータがここに入る
      page: 1, //現在のページ番号
      perPage, //1ページ毎の表示件数
      totalPage: Math.ceil(items.length / perPage), //総ページ数
      count: items.length //itemsの総数
    };
  },

ダミーデータが用意できたので表示できるようにします。
こういう配列の何かをテンプレートで処理するならVueのfor文を使えばおkです。

  <div id="app">
    <div class="content">
      <ol>
        <li v-for="(item, i) in items" :key="i">{{item}}</li>
      </ol>
    </div>
  </div>

v-forを使うときは:keyの指定が必須なので、ここでは配列のindexを利用してます。

以下画像のように、ダミーで作ったテキストがずらっと表示されていればここまでは完成です。

Gistでソースを見る

ページ番号によるフィルタ処理

1ページあたりに10件表示するとしたら、200件あるデータをページ番号に合わせて切り取りしないといけません。
Vueのコンポーネント内でそれをやるならcomputed(算出プロパティ)が妥当です。
data()の下に追加します。

Array.slice

  computed: {
    filterItems() {
      return this.items.slice((this.page - 1) * this.perPage, this.page * this.perPage);
    }
  },

Array.filter

  computed: {
    filterItems() {
      return this.items.filter(
        (item, i) =>
          i >= (this.page - 1) * this.perPage &&
          i < this.page * this.perPage
      );
    }
  },

※配列のindexは0から始まるのでstartのページ番号は1マイナスします。

Array.sliceArray.filter 両方書いてみました。
ページ番号に応じて切り取って返すという目的ならsliceの方が分かりやすいですが、
うっかり名前をfilterItemsにしたのでここではfilterの方使います。

これで、template側のv-forをitemsからfilterItemsに変更すれば10件だけの表示に変わります。
他の変数も利用できるので、ページ番号と総ページ数も表示させてみます。

  <div id="app">
    <div class="content">
      <ol>
        <li v-for="(item, i) in filterItems" :key="i">{{item}}</li>
      </ol>
    </div>
    <div class="pagination">
      <div class="total">ページ {{page}}/{{totalPage}}</div>
    </div>
  </div>

前後リンクだけのページングコンポーネント

PHP版と同じく最初は一番シンプルなやつを作りましょう。

componentsフォルダ内にpaginationフォルダを作成して、そこにprev-next.vueを作成。
HelloWorld.vueは使わないので消しておきます。


※親子判断しやすくするため子を小文字にしてます。

ファイル内でtemplateタイプしてtab、scriptタイプしてtab押せば画像のようにタグが展開されると思います。
templateにApp.vueで書いてた.paginationをカット&ペーストで移植します。

それが終わったらApp.vueに戻りまして、コンポーネントを利用できるようにします。
まずimportでファイルを読み込みます。

import PrevNext from "@/components/pagination/prev-next";

※@はルートディレクトリのエイリアスです。Webpackの機能です。

componentsに登録して、

components: { PrevNext },

templateに追加すれば、

  <div id="app">
    <div class="content">
      <ol>
        <li v-for="(item, i) in filterItems" :key="i">{{item}}</li>
      </ol>
    </div>
    <prev-next/>
  </div>

番号以外は元通り表示されます。

ページ番号の受け渡し

これでprev-next.vueはApp.vueの子コンポーネントになりました。
親コンポーネントから子コンポーネントへデータを受け渡すのにはprops(プロパティ)を使用します。

prev-next.vueで使いたいのはpageとtotalPageなので、
templateのprev-next要素にディレクティブ属性で追加します。

<prev-next :page="page" :totalPage="totalPage"/>

※v-bindは省略して書けます

prev-next.vue側で渡された値を利用できるようにプロパティを登録します。

export default {
  props: {
    page: {
      type: Number,
      required: true
    },
    totalPage: {
      type: Number,
      required: true
    }
  }
};

props: ['page', 'totalPage']とも書けるんですが、推奨されてないのでこのようにいちいち書きましょう。

前へ、次へボタンの追加

テンプレートにボタンのHTMLを追加します。

<template>
  <div class="pagination">
    <a class="prev">&lt; 前へ</a>
    <div class="total">ページ {{page}}/{{totalPage}}</div>
    <a class="next">次へ &gt;</a>
  </div>
</template>

コンポーネントのCSSについて

単一ファイルコンポーネントのvueファイル内でCSSを書いた場合、

<template>
  <div class="pagination">
    <a class="prev">&lt; 前へ</a>
    <div class="total">ページ {{page}}/{{totalPage}}</div>
    <a class="next">次へ &gt;</a>
  </div>
</template>

<script>
// 省略
</script>

<style>
.pagination {
  text-align: center;
}
.pagination * {
  display: inline;
}
a {
  border: 0;
  background: none;
  font-size: initial;
  margin: 0 1rem;
}
</style>

これは他のコンポーネントにも影響を与えるグローバルなスタイルになります。
このコンポーネント内だけのつもりで書いたスタイルでも、実行時はstyleタグでheadに埋め込まれるので、カスケーディングによって上書きすることもあれば上書きされることもあるわけです。

Vueには他のコンポーネントに影響させないためのCSSの書き方が2つあります。

Scoped CSS

styleタグにscoped属性を使うとスコープ付きCSSとなり、ランダム文字列による属性がコンポーネントのHTML要素に追加され、そのコンポーネント内だけで有効なスタイルになります。

<style scoped>
.pagination {
  text-align: center;
}
.pagination * {
  display: inline;
}
a {
  border: 0;
  background: none;
  font-size: initial;
  margin: 0 1rem;
}
</style>

CSSとHTMLへの属性追加は自動で行われるのでscoped属性をつけること以外特にやることはなく、もっともお手軽な方法です。

CSS Modules

styleタグにmodule属性を使うとCSSモジュールモードになります。

<style module>
.pagination {
  text-align: center;
}
.pagination * {
  display: inline;
}
a {
  border: 0;
  background: none;
  font-size: initial;
  margin: 0 1rem;
}
</style>

CSSモジュールモードだとコンポーネント内に書いたスタイルはcss-loaderによってオブジェクト化され$styleプロパティで注入されるので、テンプレートでの書き方が変わります。

<template>
  <div :class="$style.pagination">
    <a class="prev">&lt; 前へ</a>
    <div class="total">ページ {{page}}/{{totalPage}}</div>
    <a class="next">次へ &gt;</a>
  </div>
</template>

具体的な仕様はCSS Modulesのドキュメントを見てください。

クリックイベントとイベントハンドラの追加

そろそろ前へ・次へを押したときにページ番号を変更したくなってきましたね?
ということでそのようなイベントハンドラを追加します。

<a href="#" class="prev" @click="onPrev">&lt; 前へ</a>
<a href="#" class="next" @click="onNext">次へ &gt;</a>

hashをURLに出したくないならprevent修飾子をつけて、
@click.preventとすればe.preventDefault()する手間が省けます。

methods: {
  onPrev() {

  },
  onNext() {
    
  }
}

イベントハンドラ内の処理は単純で、1より大きく、totalPageより少ないかというチェックなので、次のように書けます。

methods: {
  onPrev() {
    this.page= Math.max(this.page- 1, 1);
  },
  onNext() {
    this.page= Math.min(this.page+ 1, this.totalPage);
  }
}

が……、子コンポーネントでプロパティ値を直接操作することはできないので、
操作するためのデータを作って、それを扱います。

data() {
  return {
    currentPage: this.page
  };
},
methods: {
  onPrev() {
    this.currentPage = Math.max(this.currentPage - 1, 1);
  },
  onNext() {
    this.currentPage = Math.min(this.currentPage + 1, this.totalPage);
  }
}

templateの方も変更します。

<div class="total">ページ {{currentPage}}/{{totalPage}}</div>

前へ・次へを押してページ番号が変われば成功です!

href属性も変更させる

href属性もページ番号に合わせて変更したいなと思いましたか?
私は思ったので実装します。

算出プロパティにonPrevとonNextの処理を移植して、prevPageとnextPageを作成してから、
もとのmethodsの方では作ったprevPageとnextPageへの参照に書き換えます。

computed: {
  prevPage() {
    return Math.max(this.currentPage - 1, 1);
  },
  nextPage() {
    return Math.min(this.currentPage + 1, this.totalPage);
  }
},
methods: {
  onPrev() {
    this.currentPage = this.prevPage;
  },
  onNext() {
    this.currentPage = this.nextPage;
  }
}

テンプレートの方でhrefをディレクティブ属性に変更してから、pageクエリで番号をもたせるようにします。

<a :href="`?page=${prevPage}`" class="prev" @click.prevent="onPrev">&lt; 前へ</a>
<div class="total">ページ {{currentPage}}/{{totalPage}}</div>
<a :href="`?page=${nextPage}`" class="next" @click.prevent="onNext">次へ &gt;</a>

これでURLは変更ないままHTMLのhref属性は書き換わるようになります。

前へ・次への表示制御

最初と最後のページでは前へ・次へボタンは不要ですね。
表示の制御はv-if属性で出来るので、テンプレート側だけで実装しちゃいます。

<a :href="`?page=${prevPage}`" class="prev" v-if="currentPage > 1" @click.prevent="onPrev">&lt; 前へ</a>
<a :href="`?page=${nextPage}`" class="next" v-if="currentPage < totalPage" @click.prevent="onNext">次へ &gt;</a>

これで前へ次へボタンだけのページングコンポーネントは一通りできました。

ページ番号の変更によるデータの処理

直接pageを変更しようとすると怒られたように、プロパティは単方向のデータフローなので、子コンポーネントでの変化を親が直接知ることはできません。
子コンポーネントで起きているページの変化を親コンポーネントが知るには、子コンポーネントが変化したことを自分で親に教えるしかないです。
教える方法はカスタムイベントになります。子で作成したカスタムなイベントを親がlistenするという格好です。
jQueryでいうtriggerのような、イベントを発火させる$emitがVueにもあるので、前へ・次へのイベントハンドラでそれを利用します。

methods: {
  onPrev() {
    this.currentPage = this.prevPage;
    this.$emit("change", this.currentPage);
  },
  onNext() {
    this.currentPage = this.nextPage;
    this.$emit("change", this.currentPage);
  }
}

App.vue側で作成したカスタムイベント’change’のリスナーonPageChangeを作ります。

<prev-next :page="page" :totalPage="totalPage" @change="onPageChange" />
methods: {
  onPageChange(page) {
    this.page = page;
  }
}

$emitでページ番号を渡しているので、引数としてそれを受け取ってpageに代入すれば、
前へ・次へを押すのに合わせて表示されるリストが変更されるようになります。

他に処理するものがないのであれば、
イベントハンドラを省略して書くこともできます。

<prev-next
 :page="page"
 :totalPage="totalPage"
 @change="page = $event"
/>

ページ番号つきURLの操作

せっかくAタグでhref属性にもページ番号を入れたので、URLも合わせて変更したいですね?
History APIの replaceStateまたはpushStateを使えばさも移動しているかのようにURLを偽装できます。

onPageChange(page) {
  this.page = page;
  window.history.replaceState(
    { page },
    `Page${page}`,
    `${window.location.origin}/?page=${page}`
  );
}

replaceStateは履歴に残さない、pushStateは履歴に残る、です。お好みで。

SPA作りたいなら公式のルーター Vue Router を使いましょう。

基本のページング機能はこれで満たされました…!
思ったより長くなったのでページ番号表示するタイプは別の投稿でやっつけることにします。

続き:[Vue] ページング機能の作り方(弐)コンポーネントのテスト作成入門

Nuxt3でComposableAPIと組み合わせるサンプルはこちら。

 

CodeSandboxはこちら:

ここまでのソース:
App.vue
prev-next.vue

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください