Tellerrand

Pagination mit Nuxt-Content

Website Monitoring Magazin

Im voran gegangen Artikel, habe ich euch gezeigt wie man das Magazin um eine Kategorisierung erweitert. Damit sind die Weichen gestellt, für eine Erweiterung des Content-Angebots. Damit ergibt sich aber ein Problem, das der Ladezeit. Man will ja nicht warten bis hunderte Artikel gesammelt, gefiltert und dann ausgegeben werden, kleinere Häppchen sind besser für die Ladezeit.

Anzahl der Artikel

Damit die Pagination arbeiten kann, wird die Gesamtzahl aller Artikel benötigt.

Gesamtzahl Artikel / Artikel pro Seite = Anzahl der paginierten Seiten

Das ist so kein großes Problem, da man nach dem Query, die Anzahl der gelieferten Artikel nur noch abzählen muss. Wenn man aber die Anzahl limitiert (um das resultierende JSON klein zu halten), weil man wie im unteren Fall nur neun Artikel anzeigen will, besteht die gesamt Zahl nur aus neun oder weniger Artikeln.

const articles = await this.$content(this.path, { deep: true })
  .only(['title', 'description', 'slug', 'type', 'image', 'dir', 'path', 'author'])
  .limit(9)
  .fetch()

Man könnte das Limit ganz entfernen, das wäre aber nicht sinnvoll. Also was bleibt? Man schickt einen zusätzlichen Request los, der alle Artikel des betreffenden Filters unabhängig von einem Limit sammelt. Da mittels only([]) alle andere Informationen ausschließt, erhält man ein Array mit vielen leeren Arrays, von dem man dann die Gesamtanzahl nimmt.

const articleCount = (await this.$content(this.path, { deep: true })
  .only([])
  .fetch())
  .length

Problem gelöst? Fast...

Reduktion der Requests

Im oberen Fall schickt man jetzt zwei asynchrone Calls los, die einmal alle Artikel abrufen (ohne weiteren Details) und dann noch mal die letzten 9 (die mit mehr Details). Der Sinn hinter dem Query ist ja das resultierende JSON so klein wie möglich zu halten. Das macht bei 20 oder 30 Artikeln nicht so viel aus, aber bei 100 oder mehr, arbeitet das ganze mehr und mehr gegen die Performance.

Das ganze summiert sich aber noch, wenn man die Artikel-Previews noch über Kategorien, Autoren oder Tags filtert. Es besteht dann nicht nur eine Anzahl aller Artikel, sondern auch eine Anzahl der Artikel die unter die Kategorie Tellerrand fallen.

Also muss eine Art Cache her, der die Artikelanzahl der jeweils gewählten Filters zwischen speichert. So wird es beim ersten Aufruf einer Kategorieübersicht immer noch zwei Queries losgeschickt werden (einmal für den Count und dann für die Artikel), aber beim zweiten Aufruf entfällt der Count-Query.

Vuex Store

Um die Anzahl der Artikel zu speichern, wird mit Vuex ein Store erstellt.

export const state = () => ({
  polling: true,
  counts: {},
  paginatedCount: 12
})

export const mutations = {
  SET_POLLING (state, status) {
    state.polling = status
  },
  SET_COUNT (state, { type, count }) {
    state.counts[type] = count
  }
}

export const actions = {
  setCount ({ commit, state }, { type, count }) {
    commit('SET_COUNT', { type, count })
  },
  setPolling ({ commit }, status) {
    commit('SET_POLLING', status)
  }
}

export const getters = {
  getPolling: state => state.polling,
  getCount: state => state.counts,
  getPaginatedCount: state => state.paginatedCount
}

Der ist relativ simpel gehalten. Die Artikelanzahl wird in einem Object counts gespeichert und dann über getCount wieder zurückgeliefert. Durch Assoziation mit dem jeweiligen Filter, kann man Komponentenseitig gezielt abfragen ob der entsprechende Count vorhanden ist.

counts: {
    agenturleben: 8,
    tellerrand: 3,
    nils: 7
}

Was man vielleicht bemerkt, das ich nicht noch mal nach Autor und Kategorie aufteile, ich bin da guter Dinge das wir Kategorien nicht so benennen wie die Autoren.

Die Anzahl der Artikel pro Seite habe ich ebenfalls im Store gespeichert, das wird später für die ArticleCollection und die Pagination benötigt.

Dazu kommt noch das Polling, das innerhalb der Paginations Komponenten die Computed Properties triggern wird..

Cachefunktion und Artikelauswahl

Die ArticleCollection, wird ja zum einsammeln der Artikel-Previews genutzt. Die Komponente wird auf der Startseite, der Kategorieseite und der Autorenseite genutzt, damit ist gewährleistet das sie bei jedem der möglichen Filter in Aktion tritt. Und ist damit prädestiniert, den Cache zu initialisieren.

import { mapGetters } from 'vuex'
import ArticlePreview from '@/components/notice/ArticlePreview/ArticlePreview'

export default {
  name: 'ArticleCollection',
  components: { ArticlePreview },
  props: {...},
  async fetch () {
    const { query } = this.$route
    const pageNumber = query.p ? query.p : 1

    this.error = false

    const baseQuery = () => ({ type: { $ne: 'category' } })
    const publishQuery = () => process.env.NODE_ENV === 'production' ? ({ published: true }) : null
    const authorQuery = () => this.authorArticles ? ({ author: { $eq: this.$route.params.author } }) : null

    const limiter = this.asident
      ? 1
      : this.paginated
        ? this.paginatedCount
        : this.limit

    const skipper = this.paginated
      ? this.paginatedCount * (pageNumber - 1)
      : null

    try {
      if (this.paginated) {
        this.$store.dispatch('magazine/setPolling', true)
        const path = this.authorArticles ? this.$route.params.author : this.path.split('/')[2]
        if (!this.getCount[path]) {
          const articleCount = (await this.$content(this.path, { deep: true })
            .only([])
            .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
            .fetch())
            .length
          this.$store.dispatch('magazine/setCount', { type: path, count: articleCount })
        }
        this.$store.dispatch('magazine/setPolling', false)
      }

      const articles = await this.$content(this.path, { deep: true })
        .only(['title', 'description', 'slug', 'type', 'image', 'dir', 'path', 'author'])
        .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
        .sortBy('createdAt', this.sortDirection)
        .skip(skipper)
        .limit(limiter)
        .fetch()

      this.articles = articles
        .filter(val => this.activeArticle.length ? val.title !== this.activeArticle : val)
    } catch (error) {
      console.error(error)
      this.error = true
    }
  },

  data () {
    return {
      articles: null,
      error: false
    }
  },

  computed: {
    ...mapGetters({
      getCount: 'magazine/getCount',
      paginatedCount: 'magazine/getPaginatedCount'
    })
  },

  watch: {
    '$route.query': '$fetch'
  }
}

Wer sich die letzte Version angesehen hat, wird die Erweiterung bemerkt haben, ich gehe jetzt einmal auf die Details ein. Da man die Seitenzahl per Query Parameter einstellt, ist es natürlich Sinnvoll den Query schon mal zu speichern, ggf. Auf einen Defaultwert von 1 zu setzen.

async fetch() {
    const { query } = this.$route
    const pageNumber = query.p ? query.p : 1
...
}

Ob die Einträge paginiert werden, ist abhängig ob das Prop paginated gesetzt wurde. Ist das der Fall wird das Limit verwendet das man im Store paginatedCount gespeichert hat.

const limiter = this.asident
  ? 1
  : this.paginated
    ? this.paginatedCount
    : this.limit

Wirklich interessant wird es aber jetzt, denn anhand des Wertes für skip, legen wir fest wie viele Artikel im Query übersprungen werden. Auch hier gilt, es ist nur gesetzt wenn die Paginierung aktiviert wurde. Als Beispiel, sind 12 Artikel pro Seite festgelegt worden und wir befinden uns auf Seite 3, werden 24 Artikel übersprungen.

const skipper = this.paginated
  ? this.paginatedCount * (pageNumber - 1)
  : null

Jetzt kommen wir zum Triggern des Caches, der im try-catch Block steckt. Viel Spannendes passiert dort nicht, es geht ja lediglich darum das wird die Anzahl der Artikel abspeichern können. Wenn wir die Anzahl schon haben, passiert hier auch nicht viel mehr.

if (this.paginated) {
  const path = this.authorArticles 
    ? this.$route.params.author 
    : this.path.split('/')[2]

  if (!this.getCount[path]) {
    this.$store.dispatch('magazine/setPolling', true)
    const articleCount = (await this.$content(this.path, { deep: true })
      .only([])
      .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
      .fetch())
      .length

    this.$store.dispatch('magazine/setCount', { type: path, count: articleCount })
    this.$store.dispatch('magazine/setPolling', false)
  }
}

Zu guter Letzt kommt neben den obligatorischen mapGetters noch ein Watcher dazu, der die Route überwacht, falls sich an den Queries etwas ändern sollte, wird der ein neuen fetch() auslösen und damit die View neu rendern.

watch: {
  '$route.query': '$fetch'
}

Paginierung

Über den Store werden die Daten bezogen die notwendig um die Paginierung aufzubauen. Im Falle von Koality gibt es nur eine Paginierung wenn eine Kategorie verwendet wird, oder man die Artikel eines Autors darstellen möchte.

Die Computed Property pages() wird immer dann aktualisiert, wenn sich etwas am Pfad ändern sollte. Damit erspart man sich mehrere watcher. Ansonsten ist die Komponente, obwohl sie das Herzstück darstellt, ist sie relativ simpel gehalten.

<template>
  <div v-if="pages > 1">
    <nav class="flex items-center justify-center -mx-1">
      <div
        v-for="page in pages"
        :key="page"
        class="px-1"
      >
        <a
          class="h-8 w-8 border border-gray-200 block flex-center cursor-pointer "
          :class="[page === parseInt(current) ? 'bg-gray-600 text-white' : 'text-gray-600']"
          @click.prevent="$router.push({ query: { p: page }})"
        >
          {{ page }}
        </a>
      </div>
    </nav>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'MagazinPagination',
  props: {
    path: {
      type: String,
      default: '/articles'
    },
    authorArticles: {
      type: Boolean,
      default: false
    }
  },

  computed: {
    ...mapGetters({
      getCount: 'magazine/getCount',
      paginatedCount: 'magazine/getPaginatedCount',
      polling: 'magazine/getPolling'
    }),
    current () {
      const { query } = this.$route
      return query.p ? query.p : 1
    },
    pages () {
      if (this.polling) { return }
      const path = this.authorArticles
        ? this.$route.params.author
        : this.path.split('/')[2]
      const pages = Math.round(this.getCount[path] / this.paginatedCount)
      return pages > 0 ? pages : 1
    }
  }
}
</script>