Spring + Vue.jsでページングする

Spring Dataを使うと、Pagingをいい感じにやってくれる。こいつとVue.jsを使って、いい感じにページングをしてみる。

API

SpringのRestControllerPageableが全てよしなに取り計らってくれる。

@RequestMapping("/api")
@RestController
public class APIController {
    
    @Autowired
    private ItemRepository repo;

    @GetMapping("/items")
    public Page<Item> allItems(Pageable pageable) {
        return repo.findAll(pageable);
    }
}

これで、以下のようにページングしやすい形でResponseを返してくれる。

{
  content: [ ... (省略) ...],
    pageable: {
      sort: {
        sorted: false,
          unsorted: true
      },
        offset: 200,
        pageSize: 20,
        pageNumber: 10,
        unpaged: false,
        paged: true
    },
    totalPages: 21,
    totalElements: 410,
    last: false,
    size: 20,
    numberOfElements: 20,
    first: false,
    sort: {
      sorted: false,
        unsorted: true
    },
    number: 10
}

英語だけど以下の記事がわかりやすい。HATEOAS 対応しようとしたけど、面倒くさそうなので後回し。

www.baeldung.com

画面

上記のAPIで返ってきた結果を表示する。Vue.js始めたてなので、もっとスマートなやり方はあるかも。

まずはページングするためのComponent。単純に全ページ分を表示する。20ページを超えたあたりからちょっと見づらくなるかも。

個人的にポイントだと思ったのは以下。

  • RestControllerが返すJSONのうち、content以外をとりあえずpagerというpropsにして渡す。その後の制御は大体これで事足りる。
  • JSONに含まれるページ番号は0始まりのインデクスだが、v-for="page in page.totalPages"でループするpageは1始まり。
  • 各ページがアクティブ(=現在ページ)かどうかを判定するために、インラインで{'active': page - 1 === pager.number}としている。
  • ページ遷移をするときは、propsで渡されたpager自身を更新する必要があるので、親Componentとやり取りする必要がある。調べた限りだと以下の方法がありそう。
    • 親Componentをpropsで子Componentにわたす
    • $emitでイベントを発行し、そいつを親Componentで捕まえる (ここの解説)
Vue.component('pager-block', {
    props: ['pager'],
    data: {},
    computed: {},
    methods: {
        changepage: function(page) {
            this.$emit('changepage', page);
        }
    },
    template: `
<nav aria-label="Page navigation example">
  <ul class="pagination">
    <li class="page-item" :class="{disabled: pager.first}">
        <a class="page-link" href="#">Previous</a>
    </li>
    <template v-for="page in pager.totalPages">
        <li class='page-item' :class="{'active': page - 1 === pager.number}">
            <a class="page-link" href="#" :class="page" @click="changepage(page - 1)">{{page}}</a>
        </li>
    </template>
    <li class="page-item" :class="{disabled: pager.last}">
        <a class="page-link" href="#">Next</a>
    </li>
  </ul>
</nav>
    `,
});

親Componentはこんな感じ。

  • methodsでページ読み込みの処理を作っておいて、mountedから呼び出す
  • GETリクエストのパラメーターはURLSearchParamsを使う。普通に文字列として追加してもよい。
  • 上の方ので発行したイベントを、@changepageでキャッチする。
  • propsで渡すpagerJSONの中身をぶち込むときに、普通にインデックスを使うとVue.jsが認識できない。this.$setを使う必要がある (ここ参照)
       <pager-block :pager="pager" @changepage="changepage"></pager-block>
var app = new Vue({
      el: '#app',
      data: function() {
          return {
            itemList: [],
            pager: {},
          };
      },
      methods: {
        loadPage: function(page) {
            var urlParams = new URLSearchParams();
            urlParams.set("page", page);
            fetch("/api/items?" + urlParams.toString())
                .then(response => {return response.json();})
                .then(data => {
                    console.log(data);
                    for (var k in data) {
                        if (k === 'content') {
                            continue;
                        }
                        this.$set(this.pager, k, data[k]);
                    }
                    for (var i = 0; i < data.content.length; i++) {
                        this.itemList.push(data.content[i]);
                    }
                });
        },
        changepage: function(page) {
            this.itemList.splice(0);
            this.loadPage(page);
        }
      },
      mounted: function() {
          this.loadPage(0);
      },
});