【自作ブログ】記事の詳細画面を作ろう
前回までに記事の一覧画面の作成が完了したので、今回は各記事の詳細画面を作っていきます。
まずは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
を持つ記事を探す - 記事の内容を画面に表示する
以上のことを考慮して設計すると以下のようなコードになると思います。
キーポイントはcomputed
とasyncData
メソッドになります。
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.vue
とpages/posts/_slug.vue
、pages/index
、をじっくり見渡してみましょう。以下のことが気になりませんか?
pages/index.vue
とpages/posts/_slug.vue
どちらのページでも毎回Contentfulにリクエストを送っているcomponents/Card.vue
とpages/posts/_slug.vue
でgetFormattedDate
という全く同じ日付処理用メソッドを定義している
前者は通信によるパフォーマンス劣化とトラフィック混雑に、
後者はメンテナンス性の低下にそれぞれ繋がります。
ではstore、middleware、pluginと呼ばれる機能を使ってこれらを解決していきましょう。
Vuexのstoreを使ってデータを保存する
1つ目の課題であるContentfulへのリクエスト問題を解決するために、Vuexのstoreにデータを保存する方式に変更していきましょう。
Vuexについては公式サイトを見ていただきたいのですが、アプリケーション全体を通してstateという状態値を保持する仕組みを提供してくれます。
Vuexのstoreを使う上で重要になる単語を簡単に説明しておきます。
オブジェクト名 | 説明 |
---|---|
state | 信頼できる唯一の情報源。値を保持する本体。 |
getters | ストア内の算出プロパティのようなふるまいをする。 |
mutations | stateの値を変更する関数を定義する |
actions | 各所からdispatchされて呼出される。処理の本体。 |
原則として、コンポーネントなどからactions
の関数がdispatchされて、actions
内からstate
を変更するためにmutations
を呼出す。という流れになります。
acitions
→mutations
→state
この流れを意識して実装していきましょう。
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
を確認して、空だった場合はactions
のgetPosts
をdispatchしてContentfulにリクエストを送るようにしています。
続いてこのmiddlewareをnuxt.config.js
のrouter
に設定します。
nuxt.config.js
// ~前略~
const config = {
// ~中略~
router: {
middleware: [
'getPosts'
]
},
// ~中略~
}
これですべてのページにおいてレンダリング直前にmiddlewareが走るようになりました。
既存の処理をstoreを使った処理に置き換える
では本来の目的だったpages/index.vue
とpages/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
とスプレッド演算子...
を使ってstate
のposts
をcomputed
に展開しています。
私は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へのデプロイが終わったら実際にサイトを確認してみていただきたいのですが、なんとせっかく作った記事の詳細ページが表示されないのです。
これについては次回解決していきますので、少しお待ちください…。
終わりに
次回に課題を残したままで何とも後味の悪い感じになってしまいましたが、ちょっと今回のボリュームがすごいことになってしまったので分割することにしました。申し訳ありません。
上手く手短に説明ができるように精進します…。
それではまた次回。