2020年2月14日に投稿

【自作ブログ】記事の詳細画面を作ろう

前回までに記事の一覧画面の作成が完了したので、今回は各記事の詳細画面を作っていきます。
まずはNuxt.jsのルーティングについて確認していきましょう。

Nuxt.jsのルーティングを知る

Nuxt.jsではルーティングの設定ファイルはありません。つまりRuby on Railで言うところのroutes.rbのようなファイルが無いのです。
Nuxt.jsはpagesディレクトリ内のVueファイルの木構造に沿って、自動的にvue-routerの設定を生成してくれます。

公式サイトに記載されているとおり、
pages配下が以下の構造のとき、

pages/
--| posts/
-----| index.vue
-----| _slug.vue
--| index.vue

以下のルーティングが自動的に生成されます。

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'posts',
      path: '/posts',
      component: 'pages/posts/index.vue'
    },
    {
      name: 'posts-slug',
      path: '/posts/:slug?',
      component: 'pages/posts/_slug.vue'
    }
  ]
}

上記のパターンの一番下の_slugとはなんでしょうか?
Nuxt.jsではファイル名またはディレクトリ名にアンダースコアのプレフィックスを付けることによって、パラメータを受け取れる動的なルーティングを定義することができます。つまり/posts/1 /posts/2のようなパスでアクセスできるようになります。
記事の詳細ページはまさにこのようなパスでアクセスできるようにしたいので、pages/posts/_slug.vueを追加していきましょう。

記事詳細ページを作成する

では詳細ページコンポーネントを作っていきます。

$ mkdir pages/posts
$ touch pages/posts/_slug.vue

slugを使っているのは、第3回目でContentfulの記事モデルにslugという"identifier"を追加してあるからです。

現時点でpages/posts/_slug.vueで実装すべき機能は以下のとおりです。

  • Contentfulに登録された記事のデータをすべて取得する
  • Contentfulの記事の中からパスに含まれたパラメータslugに一致するpost.fields.slugを持つ記事を探す
  • 記事の内容を画面に表示する

以上のことを考慮して設計すると以下のようなコードになると思います。
キーポイントはcomputedasyncDataメソッドになります。

pages/posts/_slug.vue

<template>
  <div>
    <h1 class="title">
      {{ post.fields.title }}
    </h1>
    <div class="has-text-right">
      <p><small>{{ getFormattedDate(post.fields.publishedAt) }}</small></p>
    </div>
    <hr>
    <div>
      {{ post.fields.body }}
    </div>
  </div>
</template>

<script>
import sdkClient from '@/plugins/contentful.js'

export default {
  computed: {
    post () {
      return this.posts.find(
        post => post.fields.slug === this.$route.params.slug
      )
    }
  },
  async asyncData ({ env }) {
    let posts = []
    await sdkClient.getEntries({
      content_type: 'blogPost',
      order: '-fields.publishedAt'
    }).then((res) => {
      posts = res.items
    }).catch(console.error)
    return { posts }
  },
  methods: {
    getFormattedDate (date) {
      const originDate = new Date(date)
      const year = originDate.getFullYear()
      const month = originDate.getMonth() + 1
      const day = originDate.getDate()
      return `${year}${month}${day}日`
    }
  }
}
</script>

<style>

</style>

実はこのコードは複数の問題を抱えています。
が、画面遷移とデータ取得の確認が目的なので今はこのままにしておきます。

記事一覧から詳細ページへリンクさせる

次に記事一覧から各記事をクリックした際に詳細ページに移動できるようにしていきます。
変更点は以下の2点です。

  • カード全体を<nuxt-link></nuxt-link>で囲む
  • 詳細ページ用のURIを生成するメソッドを追加

components/Card.vue

<template>
  <div class="box is-radiusless">
    <nuxt-link :to="linkTo(post)">  <!-- 追加 -->
      <article class="media">
        <!-- 中略 -->
      </article>
    </nuxt-link>  <!-- 追加 -->
  </div>
</template>

<script>
export default {
  // ~中略~
  methods: {
    getFormattedDate (date) {
      const originDate = new Date(date)
      const year = originDate.getFullYear()
      const month = originDate.getMonth() + 1
      const day = originDate.getDate()
      return `${year}${month}${day}日`
    },
    // 追加~ここから~
    linkTo (post) {
      return { name: 'posts-slug', params: { slug: post.fields.slug } }
    }
    // 追加~ここまで~
  }
}
</script>

~後略~


ここまでできたら画面で動作確認してみましょう。
記事の詳細画面が表示されるようになっているはずです。

キャプチャ

現状の課題を見つける

前述のとおり、今回実装した機能にはいくつか問題、というより課題が残っています。
components/Card.vuepages/posts/_slug.vuepages/index、をじっくり見渡してみましょう。以下のことが気になりませんか?

  1. pages/index.vuepages/posts/_slug.vueどちらのページでも毎回Contentfulにリクエストを送っている
  2. components/Card.vuepages/posts/_slug.vuegetFormattedDateという全く同じ日付処理用メソッドを定義している

前者は通信によるパフォーマンス劣化とトラフィック混雑に、
後者はメンテナンス性の低下にそれぞれ繋がります。
ではstore、middleware、pluginと呼ばれる機能を使ってこれらを解決していきましょう。

Vuexのstoreを使ってデータを保存する

1つ目の課題であるContentfulへのリクエスト問題を解決するために、Vuexのstoreにデータを保存する方式に変更していきましょう。
Vuexについては公式サイトを見ていただきたいのですが、アプリケーション全体を通してstateという状態値を保持する仕組みを提供してくれます。
Vuexのstoreを使う上で重要になる単語を簡単に説明しておきます。

オブジェクト名 説明
state 信頼できる唯一の情報源。値を保持する本体。
getters ストア内の算出プロパティのようなふるまいをする。
mutations stateの値を変更する関数を定義する
actions 各所からdispatchされて呼出される。処理の本体。

原則として、コンポーネントなどからactionsの関数がdispatchされて、actions内からstateを変更するためにmutationsを呼出す。という流れになります。

acitionsmutationsstate

この流れを意識して実装していきましょう。

storeの定義

Nuxt.jsでVuexのstoreを使うのは簡単です。
プロジェクトのルートディレクトリにstoreディレクトリがあるので、その直下にindex.jsファイルを配置するだけで自動的にstoreとして扱ってくれます。
それではファイルを追加してstateの定義から始めましょう。

$ touch store/index.js

公式にも書いてありますが、クラシックモードは廃止予定なのでモジュールモードで記述していきます。

store/index.js

import sdkClient from '@/plugins/contentful.js'

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

export const getters = {

}

export const mutations = {
  setPosts (state, payload) {
    state.posts = payload
  }
}

export const actions = {
  async getPosts ({ commit }) {
    await sdkClient.getEntries({
      content_type: 'blogPost',
      order: '-fields.publishedAt'
    })
      .then((res) => {
        commit('setPosts', res.items)
      })
      .catch(console.error)
  }
}

stateには全ての記事を表すpostsを定義しました。
なおstateだけは関数の形で定義することに気を付けてください。

モードに関わらず、サーバーサイドで不要な共有状態を避けるため、state の値は常に function でなければなりません。

引用元:公式より


mutationsには引数として受け取ったpayloadを使ってpostsを更新するだけの関数setPostsを定義しました。

actionsには今まで記事の取得に使っていた関数を持ってきていて、取得結果をmutationsの関数にコミットするようになっています。

これで記事取得→storeに保存までの仕組みは出来上がりましたが、肝心のactionsにdispatchする大元の処理がありません。

middlewareを作る

理想としては、ユーザーがサイトに訪れたタイミングで一度だけContentfulにリクエストを送って記事の情報を取得することです。

しかしユーザーは必ずトップページからアクセスしてくれるでしょうか?

………つまりすべてのページに対して、最初に表示されたときにstoreにデータが無かったらContentfulにリクエストを送る。ということが必要になります。

そんな仕組みを作ることができるのでしょうか?

それが簡単にできるんです。そう、middlewareならね。

というわけで、middlewareを作ってすべてのルートに対して有効になるように設定していきます。
Nuxt.jsにはmiddleware用のディレクトリが用意されているので、そこに新しいファイルを作り、処理を実装していきましょう。

$ touch middleware/getPosts.js

middleware/getPosts.js

export default async ({ store }) => {
  if (!store.state.posts.length) { await store.dispatch('getPosts') }
}

非常に短いですが、今必要な処理はこれだけです。
ページがレンダリングされる直前にstore.state.postsを確認して、空だった場合はactionsgetPostsをdispatchしてContentfulにリクエストを送るようにしています。

続いてこのmiddlewareをnuxt.config.jsrouterに設定します。


nuxt.config.js

// ~前略~
const config = {
  
  // ~中略~
  
  router: {
    middleware: [
      'getPosts'
    ]
  },
  
  // ~中略~
}

これですべてのページにおいてレンダリング直前にmiddlewareが走るようになりました。

既存の処理をstoreを使った処理に置き換える

では本来の目的だったpages/index.vuepages/posts/_slug.vueでリクエストを送っている処理を修正していきましょう。

pages/index.vue

<template>
  <!-- ~中略~ -->
</template>

<script>
import Card from '@/components/Card.vue'
// import sdkClient from '@/plugins/contentful.js' ← 削除
import { mapState } from 'vuex' // ← 追加

export default {
  components: {
    Card
  },
  // 追加~ここから~
  computed: {
    ...mapState(['posts'])
  }
  // 追加~ここまで~

  // 削除~ここから~
  // async asyncData ({ env }) {
  //   let posts = []
  //   await sdkClient.getEntries({
  //     content_type: 'blogPost',
  //     order: '-fields.publishedAt'
  //   }).then((res) => {
  //     posts = res.items
  //   }).catch(console.error)
  //   return { posts }
  // }
  // 削除~ここまで~
}
</script>

同様にpages/posts/_slug.vueも修正していきます。


pages/posts/_slug.vue

<template>
  <!-- ~中略~ -->
</template>

<script>
// import sdkClient from '@/plugins/contentful.js' ← 削除
import { mapState } from 'vuex' // ← 追加

export default {
  computed: {
    // 追加
    ...mapState(['posts']),
    post () {
      return this.posts.find(
        post => post.fields.slug === this.$route.params.slug
      )
    }
  },
  // 削除~ここから~
  // async asyncData ({ env }) {
  //   let posts = []
  //   await sdkClient.getEntries({
  //     content_type: 'blogPost',
  //     order: '-fields.publishedAt'
  //   }).then((res) => {
  //     posts = res.items
  //   }).catch(console.error)
  //   return { posts }
  // },
  // 削除~ここまで~
  methods: {
    getFormattedDate (date) {
      // ・・・
    }
  }
}
</script>

mapStateとスプレッド演算子...を使ってstatepostscomputedに展開しています。
私はVuexを初めて使ったとき、上記の処理が何をやっているのか理解できませんでした。
同じような人がいればこちらの投稿が大変参考になるので是非ご覧ください。

リファクタリングが終わったらブラウザを更新して確認してみましょう。
修正前と同じ動作になっていればリファクタリング成功です。

pluginに共通関数をまとめる

それでは2つ目の課題、共通関数getFormattedDateをどうにかしていきましょう。
共通関数をまとめる方法はいくつかあるのですが(mixinを使うなど)、今回はpluginを使って実装したいと思います。
※この方法はすべてのコンポーネントに対して影響を与えます。ご利用は計画的に。

まずはplugins/utils.jsを作って関数を定義します。

$ touch plugins/utils.js

plugins/utils.js

const getFormattedDate = (date) => {
  const originDate = new Date(date)
  const year = originDate.getFullYear()
  const month = originDate.getMonth() + 1
  const day = originDate.getDate()
  return `${year}${month}${day}日`
}

export default ({ app }, inject) => {
  inject('getFormattedDate', getFormattedDate)
}

コンポーネントからそのまま関数の定義を持ってきています。
そしてexport defaultの箇所でVueインスタンス(クライアントサイド)、コンテキスト(サーバサイド)両方にpluginを注入しています。
なお、inject関数を使って注入した場合、自動的に関数の頭に$が付与されます。

context 内や Vue インスタンスだけでなく Vuex ストア内でも関数が必要な場合 inject 関数を使用することができます。この関数は、プラグインとして公開する関数の第 2 引数です。

Vue インスタンスへのコンテンツの注入は、通常の Vue アプリケーションと同様に動作します。>関数の先頭へ自動的に $ が追加されます。

引用元:公式ガイドより


続いてpluginをnuxt.config.jsに登録します。
pluginsに追記するだけです。

nuxt.config.js

require('dotenv').config()

export default {
  // ~中略~
  // 追加~ここから~
  plugins: [
    '@/plugins/utils.js'
  ],
  // 追加~ここまで~
  // ~中略~
}

これですべてのコンポーネント(およびVuexのstore内でも)から$getFormattedDateの形で関数を呼出すことができるようになりましたので、コンポーネント側を修正しましょう。


pages/posts/_slug.vue

<template>
  <div>
    <h1 class="title">
      {{ post.fields.title }}
    </h1>
    <div class="has-text-right">
      <p><small>{{ $getFormattedDate(post.fields.publishedAt) }}</small></p>
    </div>
    <hr>
    <div>
      {{ post.fields.body }}
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['posts']),
    post () {
      return this.posts.find(
        post => post.fields.slug === this.$route.params.slug
      )
    }
  }
}
</script>

<style>

</style>


components/Card.vue

<template>
  <div class="box is-radiusless">
    <nuxt-link :to="linkTo(post)">
      <article class="media">
        <!-- ~中略~ -->
        <figure class="media-content">
          <div class="content">
            <div class="is-size-4">
              {{ post.fields.title }}
            </div>
            <div class="has-text-right">
              <small>{{ $getFormattedDate(post.fields.publishedAt) }}</small>
            </div>
          </div>
        </figure>
      </article>
    </nuxt-link>
  </div>
</template>

<script>

export default {
  // ~中略~
  methods: {
    // 削除
ここから    // getFormattedDate (date) {
    //   const originDate = new Date(date)
    //   const year = originDate.getFullYear()
    //   const month = originDate.getMonth() + 1
    //   const day = originDate.getDate()
    //   return `${year}年${month}月${day}日`
    // },
    li// 削除~ここまで~
    nkTo (post) {
      return { name: 'posts-slug', params: { slug: post.fields.slug } }
    }
  }
  // ~中略~
}
</script>

<style scoped>
// ・・・
</style>

これで同一関数を一つにまとめることができました。
最後にブラウザを更新して日付が正しく表示されることを確認してみてください。

一難去ってまた一難

長くなりましたが、これでようやく記事の詳細画面を実装することができました!
と、言いたいところですが、まだ一つだけ問題が残っています。
ここまでの成果をGitHubにpushしてNetlifyへのデプロイが終わったら実際にサイトを確認してみていただきたいのですが、なんとせっかく作った記事の詳細ページが表示されないのです。
これについては次回解決していきますので、少しお待ちください…。

終わりに

次回に課題を残したままで何とも後味の悪い感じになってしまいましたが、ちょっと今回のボリュームがすごいことになってしまったので分割することにしました。申し訳ありません。
上手く手短に説明ができるように精進します…。
それではまた次回。