技術・Web access_time update

Firestoreのデータからブログを表示する

 WordPressブログをNuxt+Firebaseに移行するシリーズ。

Image

WordPressの記事データをFirestoreに同期する

 WordPressブログをNuxt+Firebaseに移行するシリーズ。概要は↓[nlink id=6056]  今回は最初のステッ... 続きを読む

access_time2019-11-08

 WordPressに存在する記事データの必要な情報は全てFirestoreに同期させたので、次はいよいよ表示。

 現在のブログ表示で行っていることを、Firestoreからデータを取得するクエリに置き換えていきます。


 前回も書きましたが、そもそもブログごとに必要・不必要な部分がたくさんあるので難しいんですよね。

 ルーティング自体は書きますが、JSONデータをどう扱うかは完全に個々のブログのデザインの話になるので。


初期設定

 Firebaseの初期設定やNuxtとの連携の話はもういろいろなところにあると思うのでざっくり。

  • yarn add firebase する
  • ダッシュボードから「アプリを追加」でWEBアプリを追加
  • 表示されるJS情報をplugin/firebase.ts ファイルに入れてnuxtで読み込む
import firebase from 'firebase/app'
import 'firebase/firestore'

if (!firebase.apps.length) {
  const config = {
    apiKey: process.env.firebaseApiKey,
    authDomain: process.env.firebaseAuthDomain,
    databaseURL: process.env.firebaseDatabaseURL,
    projectId: process.env.firebaseProjectId,
    storageBucket: process.env.firebaseStorageBucket,
    messagingSenderId: process.env.firebaseMessagingSenderId
  }
  firebase.initializeApp(config)
}

export default firebase

 ここでは@nuxt/dotenvを使って環境変数化していますが、面倒な方は表示されるIDなどを直接書いてもそんなに問題はないと思います。(そもそもCDNでも使える設計なので公開情報のはず)

 加えて、今後GitHubからFirebase Hostingへの自動デプロイを行うためには.env をGitHubに上げる必要も出てくるので、あまり意味がなくなる可能性もあります。気休めみたいなものです。


 ストアから情報を呼び出す際は、このプラグインで初期化されたfirebaseを呼び出すので、

import firebase from '~/plugins/firebase'
const db = firebase.firestore()

 みたいになります。


 このDBに対しての具体的な操作は、Firebaseのドキュメントを見ればわかります。

Cloud Firestore を使ってみる | Firebase

 これ以降はdbの定義などは省いて書いていきます。


コンテンツ

 「WordPressサイトからURLを変えることなく移行」するためには、各アーカイブページを表示する必要があります。


ルーティング

 WordPressに存在する主要なページは以下の通り。

ページ名URL
トップページ(記事一覧)/
記事/archives/{post_id}
カテゴリーアーカイブ/category/{category_slug}
タグアーカイブ/tag/{tag_slug}
年別・月別・日別アーカイブ/archives/date/{year}/{month}/{day}
固定ページ/{page_slug}

 実はページの種類としてはこのくらいしかありません。

 NuxtのPagesのルーティングを使うとこうなります。

image-20191110001933705

 年別/月別/日別だけ面倒ですが、WordPressのルーティングを再現するのは意外と簡単なのです。

 というわけで作るページはこんな感じです。意外と少ないですね。


トップページ

 まずメインの記事部分はこんな感じで取得してきます。

import firebase from '~/plugins/firebase'
const db = firebase.firestore()

export const state = () => ({
  posts: []
})

export const mutations = {
  SET_POSTS: (state, posts) => (state.posts = posts)
}

export const actions = {
  async getPosts({ commit }, { lastPost = null, name = null }) {
    const query = db.collection('posts').orderBy('date', 'desc')
    const posts = await query
      .limit(10)
      .get()
      .then(querySnapshot => {
        return querySnapshot.docs.map(doc => doc.data())
      })
    commit('SET_POSTS', posts)
    return posts
  }
}

export const getters = {
  posts: state => state.posts
}

 querySnapshot.docs.map(doc => doc.data())の部分で、collectionの中のdataを1つずつ取っています。ここにPost Objectが入ります。

 コンポーネント側(index.vue)ではmountedでgetPostsを呼び出しつつ、mapGettersでpostsを読み込むことで記事を表示できるようになります。


 もちろん、実際に記事表示する際にはFirestoreのTimestampをDate型に変換したりいろいろやる必要がありますが、それはもう各ブログごとに変わる領域なので一切コードでは説明しません

 Firestoreから取得してきたデータを変換する際にTypeScriptとかclassとかをゴリゴリに使っていますし、

 何なら本当はストアでDBを読み込まずにAPIを別ディレクトリに分けてApiClientをDIしていますが、この記事の主題ではないので説明はしません。(というか会社でやっている方法をそのまま実践しているだけなので解説できない)

Nuxt.jsのinjectを使ってDIする – CYDAS Developer's Blog

 興味のある方はこの記事とかを参照して自力で頑張ってください。


 この記事ではFirestoreのクエリと、NuxtでWordPressブログの構造を再現する場合に使える細かいテクニックだけを書きます。


ページ送り

 ページ送りにはvue-infinite-loadingを使います。

Nuxt.jsとvue-infinite-loadingを使って無限スクロールを実装する – Qiita

 Firestoreにはoffsetという概念がないので、現在表示中の最後の記事の日付を渡して指定します。

  async getPosts({ commit }, { lastDate = null, limit = 10, name = null }) {
    let query = db.collection('posts').orderBy('date', 'desc')
    if (lastDate) {
      query = query.startAfter(lastDate)
    }
    const posts = await query
      .limit(limit)
      .get()
      .then(querySnapshot => {
        return querySnapshot.docs.map(doc => new Post(doc.data()))
      })
    if (lastDate) {
      commit('ADD_POSTS', posts)
    } else {
      commit('SET_POSTS', posts)
    }
    return posts
  }

 vue-infinite-loadingではonInfiniteを使って

    onInfinite($state) {
      setTimeout(() => {
        this.getPosts({ lastDate: this.posts[this.posts.length - 1].date })
          .then(res => {
            res.length >= 10 ? $state.loaded() : $state.complete()
          })
          .catch(err => {
            $state.complete()
            return err.response
          })
      }, 300)
    },

 getPostsで取得できた記事が10件未満なら最後まで行ったと判断して$state.complete()を呼びます。

 これ以降、記事単体ページを除く全てのクエリでページングにはlastDateを使います。

 ということは、ページングのURLを変えて2ページ目にアクセス、みたいなことはFirestoreでは絶対に不可能です。


キーワード検索

 Algoriaを使うのが定石ですが、準備するのが面倒なので一旦WordPressに頼ります。

    if (name) {
      const apiUrl = process.env.apiUrl
      const data = await axios.get(
        `https://oswdiary.net/wp-json/wp/v2/posts/?per_page=${limit}&search=${name}&page=${page}`
      )
      const posts = data.data.map(doc => new Post(doc))
      return posts
    }

 nameがある時だけWP REST APIに送ります。動的ルーティングではないのが救い。

 WP REST APIとFirestoreから得られるデータをどこかで一致させる必要がありますし、prev_postやthumbnail_imagesなどWP REST APIにないものを


カテゴリー/タグアーカイブ

 今のFirestoreは配列に対する検索をサポートしているので、category_slugsという配列をFirestoreに持たせておいて、array-containsで絞り込みます。

 WordPressから同期するデータを

'category_slugs' => array_map('convert_tax_to_slug', $categories)

 とすればslugの配列を

  async getCategoryPosts({ commit }, { slug, limit = 10, lastDate = null }) {
    let query = db
      .collection('posts')
      .where('category_slugs', 'array-contains', slug)
      .orderBy('date', 'desc')
    if (lastDate) {
      query = query.startAfter(lastDate)
    }
    const posts = await query
      .limit(limit)
      .get()
      .then(querySnapshot => {
        return querySnapshot.docs.map(doc => new Post(doc.data()))
      })
    commit(lastDate ? 'ADD_POSTS' : 'SET_POSTS', posts)
    return posts
  }

 ちなみに、whereとorderByを組み合わせるためにはカスタムインデックスの作成が必要で、ブラウザのコンソールに下のようなメッセージが出ます。

Uncaught (in promise) FirebaseError: The query requires an index. That index is currently building and cannot be used yet. See its status here:

 ここに表示されるURLをクリックするだけで勝手に作ってくれるので特に気にする必要はありません。

 カスタムタクソノミーを


年別・月別アーカイブ

 年別・月別・日別については、条件分岐だけでやると複雑になったのでストア時点で3つに分けました。

 momentより軽いと噂のdayjsを使って、1年後・1ヶ月後・1日後などの日付を取得しています。

  async getDatePosts(
    { commit },
    { year, month, day, limit = 10, lastDate = null, startDate, endDate }
  ) {
    const api = this.$deps.apiClient
    const dayjs = require('dayjs')
    if (year && !month && !day) {
      startDate = new Date(`${year}/1/1`)
      endDate = dayjs(startDate).add(1, 'year').toDate()
    } else if (year && month && !day) {
      startDate = new Date(`${year}/${month}/1`)
      endDate = dayjs(startDate).add(1, 'month').toDate()
    } else if (year && month && day) {
      startDate = new Date(`${year}/${month}/${day}`)
      endDate = dayjs(startDate).add(1, 'day').toDate()
    }
    const posts = await api.periodPosts(limit, startDate, endDate, lastDate)
    commit(lastDate ? 'ADD_POSTS' : 'SET_POSTS', posts)
    return posts
  }

 APIの方はTypeScriptです。

  async periodPosts(
    limit: number,
    startDate: Date,
    endDate: Date,
    lastDate: Date | null
  ) {
    let query = db.collection('posts').orderBy('date', 'desc')
    query = lastDate
      ? query.endBefore(firebase.firestore.Timestamp.fromDate(lastDate))
      : query.endAt(firebase.firestore.Timestamp.fromDate(startDate))
    const data = await query
      .startAfter(firebase.firestore.Timestamp.fromDate(endDate))
      .limit(limit)
      .get()
      .then(querySnapshot => {
        return querySnapshot.docs.map(doc => new Post(doc.data()))
      })
    return data
  }

 例えばyearPostsで2019年を指定したら、2019/1/1以降(startAt)→その1年後を含まない時点まで(endBefore)。

 ページングはそれ以降なのでstartAfterで。

 monthだったら

    const startDate = new Date(`${year}/${month}/1`)
    const endDate = new Date(
      dayjs(startDate).add(1, 'months').format()
    )

 dayは省略。


記事ページ・固定ページ

 記事ページはコレクションではなくドキュメント指定での取得になります。

    const data = await db
      .collection('posts')
      .doc(id)
      .get()
      .then(doc => doc.data())
    return data

 これだけです。簡単。記事本文はストア通す必要もないのでasyncDataでも良いですね。

 固定ページならcollectionをpagesにすれば良いですね。


サイドバー・コメントフォーム

 こちらは長くなるので別記事にします。

 要するに前回の記事で用意したデータを取っていくという話なので、今回と違ってクエリでの工夫はあまりないですが……。