JavaScript
vue.js
GraphQL
apollo

vue.js, apolloで無限スクロール(Infinite Scroll)を実装する

vue.jsとapolloを使って以下のような無限スクロールを実装します。
(正確にはスクロール位置検知での自動追加読み込み)
demo.gif

フロントの説明のみになります。バックエンドのgraphQL APIサーバーはGraphCMS使っています。

デモページはこちら。
https://vue-sample-blog.firebaseapp.com/infiniteScrollDemo
(スマホで動かなかったので修正します)

無限スクロール[Infinite Scroll]とは?

よくある画面下部までスクロールした際に、自動で次のコンテンツが読み込まれるやつです。
スマホアプリとかのUIで採用される場合が多いですね。
利点は、ページングがないので離脱を防ぎやすいなど。
欠点は、プラウザの戻るとの相性が悪いなど。

vue.js apolloでの実装

今回の元データはこちらで作成したブログを使っています。
headLessCMSのデータも同じです。

graphQLでページング用のクエリを作成

graphCMSを使った場合のページング用のクエリは以下のとおりです。

モデル名(first: 最初から何件取得するか?(int), skip: 何件とばすか?(int), orderBy: 並び順(プロパティ名_desc,asc)) {
 プロパティ1
 プロパティ2
}

これをvueで使いやすいようにjsの定数として引数付きで定義します。
今回はpostsという要素を使っています。

post-grapql.js
import gql from 'graphql-tag'

export const FEACH_POST_BY_PAGE = gql`
    query fetchPostByPage($displayUnit: Int, $page: Int) {
        posts(first: $displayUnit, skip: $page, orderBy: createdAt_DESC where: {status: PUBLISHED}) {
            id
            title
            content
            description
            createdAt
            thumbnail {
                url
            }
            __typename 
        }
    }
`

vueテンプレートの実装

テンプレートはUIコンポーネントライブラリのvuetifyを使っています。
vuetifyは今回の主ではないので、説明は省きます。

infiniteScrollDemo
<template>
  <div>
    <v-container grid-list-xl>
      <!--投稿一覧-->
      <v-layout row wrap>
        <PostCard
            v-for="post in posts"
            v-bind:key="post.id"
            :post=post
        ></PostCard>
      </v-layout>
      <!-- ローディングアイコン-->
      <div class="text-xs-center" v-if="loading">
        <v-progress-circular
            :size="50"
            color="primary"
            indeterminate
        ></v-progress-circular>
      </div>
    </v-container>
  </div>
</template>

<PostCard>がカード型のUIを形成しているコンポーネントです。
それをPostsのv-forで回しています。
ローディングアイコンは後に定義するloadingという変数を元にv-if表示を制御しています。

スクリプト部分の実装

今回の主である無限スクロールのロジック部分の実装です。
説明ようにコメントを多めにしています。

<script>
  import {FEACH_POST_BY_PAGE} from "../constants/post-graphql";
  import PostCard from '../components/PostCardSingleLine'

  const DISPLAY_UNIT = 3 // 一度に読み込む記事の単位

  export default {
    name: "InfinityScrollDemo",
    components: {
      PostCard,
    },
    data: () => ({
      posts: [], // postの配列
      page: 0, // ページ位置(skipの値)
      loading: false, // ローディングアイコン表示可否のフラグ
      loadEnable: true, // 次のローディングが可能化どうかのフラグ
    }),
    apollo: { // 以下は最初のページロード時に実行される
      posts: {
        query: FEACH_POST_BY_PAGE,
        variables: {
          displayUnit: DISPLAY_UNIT,
          page: 0
        }
      }
    },
    methods: {
      showMore() {
        this.page++ //呼ばれるごとにpageの値をインクリメント
        let self = this // 関数内でthisが参照できないので、リファレンス用のselfに移し替える
        this.$apollo.queries.posts.fetchMore({
          variables: {
            displayUnit: DISPLAY_UNIT,
            page: self.page * DISPLAY_UNIT
          },
          updateQuery: (previousResult, {fetchMoreResult}) => {
            const prePosts = previousResult.posts // 前取得済みのpostsの値
            const newPosts = fetchMoreResult.posts // 追加取得のpostsの値

            // 残された投稿が、表示単位より少ない場合はローディング可能のフラグをfalseに
            if (newPosts.length < DISPLAY_UNIT) {
              self.loadEnable = false
            }

            // loadingを終了
            self.loading = false
            return {
              posts: [...prePosts, ...newPosts] // 配列の結合。prePosts.concat(newPosts)と同意
            }
          }
        })
      },
      watchScroll() {
        let self = this
        window.onscroll = () => {
          let scrollingPosition = document.documentElement.scrollTop + window.innerHeight
          let bottomPosition = document.documentElement.offsetHeight - 10 // なぜか最下部まで行っても5px程たりなかったので。cssの問題?

          // 次の投稿があり、ロード中の状態ではなく、最下部までスクロールされた場合に実行
          if (self.loadEnable && !self.loading && scrollingPosition >= bottomPosition ) {
            // ローディングフラグをON
            self.loading = true
            // あまりにロードが早いと、表示的に違和感あるので、1秒まって実行
            setTimeout(this.showMore, 1000)

          }
        }
      }
    },
    mounted() {
      // watchScrollの実行登録
      this.watchScroll()
    }
  }
</script>

解説

ページ単位での取得

showMoreというmethodがページ単位の取得部分です。
apolloの場合、

this.$apollo.queries.クエリ名.fetchMore

とすることで、簡単に追加読み込みが定義できます。
ここでのクエリ名は、apollo: 以下で定義しているものを設定してください。
その中で重要な部分は、updateQueryです。

updateQuery: (previousResult, {fetchMoreResult}) => 

引数のpreviousResult, fetchMoreResultにそれぞれ更新前のクエリ取得結果、更新後のクエリ取得結果が入ります。
移行の処理で、

return {
  設定したいプロパティ名: [...previousResult.モデル名, ...fetchMoreResult.モデル名] 
}

とすることで前と後の結合結果がプロパティに入ります。
「...」はスプレッド構文というらしく、ES6で新たに追加されたものです。
配列やオブジェクトをその使い所に合わせた展開をしてくれるらしいです。(あまりわかってない。。)
[...配列A, ...配列B]という形で配列A、Bのマージを行えます。

スクロールイベントの検知

watchScrollメソッドでは、

window.onscroll = () => {
...
}

とすることでwindowのスクロールイベントを検知して、内部の処理を実行しています。
見慣れない書き方の「= () => 」はES6で入ったアロー関数式です。
こちらの記事がわかりやすかったです。

最下部までスクロールされたかどうか?は、通常のjsで判定しています。
documentElement.scrollTop、documentElement.offsetHeightプロパティと
ウィンドウのinnerHeightプロパティを使用して、スクロールが下部にあるかどうかを判定しています。

let scrollingPosition = document.documentElement.scrollTop + window.innerHeight
let bottomPosition = document.documentElement.offsetHeight

if(scrollingPosition >= bottomPosition) {
...実行したい処理
}

あとは、watchScrollメソッドをvueのライフサイクルのmount後に実行するように、
mouted: 以下に設定すれば完了です。

mounted() {
  this.watchScroll()
}

以上!

今回のコード全文はこちらにあります。
https://github.com/kawamataryo/vue-sample-blog/blob/master/src/views/InfiniteScrollDemo.vue

参考

Implementing an Infinite Scroll with Vue.js
vue-apolloドキュメント
【JavaScript】アロー関数式を学ぶついでにthisも復習する話