今回達成すること
前回作成したnuxtServerInit
から、Contentful APIへリクエストを行います。
返されたContentfulコンテンツは、state
に保存し、最終的に
Vuex Postストアを作成する
前回post/getEntries
を用意しましょう。
store/index.ts(前回のコード)
async getContentfulApi ({ dispatch }) {
// これの呼び出し元を追加する
await dispatch('post/getEntries')
}
storeディレクトリ内に
% mkdir store/post && touch $_/index.ts
mutations
は使用しないので宣言しません。
store/post/index.ts
import { getterTree, actionTree } from 'typed-vuex'
import { BlogCategory, BlogPost, BlogTag } from '../types'
export const state = () => ({})
export const getters = getterTree(state, {})
export const actions = actionTree({ state, getters }, {})
Contentful APIで使用する固定値を用意する
state
にContentful APIで使用する値を用意します。
store/post/index.ts
export const state = () => ({
content: {
type: {
category: 'blogCategory' as string,
post: 'blogPost' as string,
tag: 'blogTag' as string
},
limit: 200 as number,
// 公開日 降順
order: '-fields.publishDate' as string
} as const
})
Vuex Stateの値を読み取り専用としたい時
state
の値を読み取り専用としたい場合はas const
を使用します。
as const
を宣言するとreadonly
が付与され、変更不可の読み取り専用の値となります。
今回は、content
オブジェクトにas const
を宣言し、Contentful APIで使用する全ての値を読み取り専用としています。
APIリクエストを行うActionメソッドを作成する
actions
にAPIリクエストメソッドを追加します。
store/post/index.ts
import { getterTree, actionTree } from 'typed-vuex'
import { BlogCategory, BlogPost, BlogTag } from '../types'
// 追加
import client from '~/plugins/contentful'
type RelatedEntries = (BlogCategory | BlogTag)[]
interface Entry {
includes: {
Entry: RelatedEntries
}
items: BlogPost[]
}
actions
にContentful APIリクエスト処理を記述します。
これはasyncData
と同じ処理を行なっています。
store/post/index.ts
export const actions = actionTree({ state, getters }, {
async getEntries ({ state, getters, commit }) {
await client.getEntries({
content_type: state.content.type.post,
limit: state.content.limit,
order: state.content.order
})
.then((entry: Entry) => {
const reletedEntry = getters.separateEntries(entry.includes.Entry)
const categories = getters.sortCategories(reletedEntry.categories)
const tags = getters.sortTags(reletedEntry.tags)
commit('setCategories', categories, { root: true })
commit('setTags', tags, { root: true })
commit('setPosts', entry.items, { root: true })
})
.catch((error: object) =>
console.log(error)
)
}
})
commit(, , { root: true })
...index.ts のmutations
を呼び出す場合は、{ root: true }
を第三引数に追加する。{ root: true }
がない場合はローカルのmutations
が呼ばれる。
データ整形するGetter関数を作成する
getters
にレスポンスデータの整形処理を追加します。
separateEntries
は、blogPostモデルに関連づいたblogCategoryとblogTagモデルを、それぞれの配列に振り分けます。
store/post/index.ts
export const getters = getterTree(state, {
// blogPostモデルに関連づくコンテンツを配列に振り分ける
separateEntries: (state: PostState) => (entries: RelatedEntries):
{ categories: BlogCategory[], tags: BlogTag[] } => {
// push時点ではTypeエラーとなるのでany型で宣言し、Getterの戻り値で型指定を行う
const categories: any[] = []
const tags: any[] = []
for (const entry of entries) {
const currentContentType: string = entry.sys.contentType.sys.id
if (currentContentType === state.content.type.category) {
categories.push(entry)
} else if (currentContentType === state.content.type.tag) {
tags.push(entry)
}
}
return { categories, tags }
}
})
Getter関数の戻り値を明示的に指定する理由
変数の宣言時点でBlogCategory[]
の型を宣言するとpush(entry)
ができません。
// NG
const categories: BlogCategory[] = []
for (const entry of entries) {
// TypeError
categories.push(entry)
}
これはfor
文のentry
が、BlogCategory | BlogTag
型であるためです。
そこで変数はany
型で宣言し、Getter関数の戻り値に対して型を指定しています。
separateEntries: (state: PostState) => (entries: RelatedEntries):
{ categories: BlogCategory[], tags: BlogTag[] } => {
// OK
const categories: any[] = []
for (const entry of entries) {
categories.push(entry)
}
return { categories, tags }
}
これにより、separateEntries
の型を保証しています。
Sort関数をGetterに作成する
getters
に並び替えを行う関数を2つ追加します。
sortCategories
... blogCategoryモデルをsort
昇順に並び替える関数sortTags
... blogTagモデルをcreatedAt
(作成日) 降順に並び替える関数
store/post/index.ts
export const getters = getterTree(state, {
...
// カテゴリーの並び替え(sort: 昇順)
sortCategories: () => (categories: BlogCategory[]) =>
categories.sort((a: BlogCategory, b: BlogCategory) =>
a.fields.sort - b.fields.sort
),
// タグの並び替え(作成日: 降順)
sortTags: () => (tags: BlogTag[]) =>
tags.sort((a: BlogTag, b: BlogTag) => {
switch (true) {
case a.sys.createdAt < b.sys.createdAt: return 1
case a.sys.createdAt > b.sys.createdAt: return -1
default: return 0
}
})
})
state
に保存する前に並び替えを行うことで、Vueファイル上でデータが扱いやすくなります。
またこの処理は、% yarn generate
コマンド時に実行されるため、クライアントの処理が減り、サイトの表示スピードが上がります。
このように、FullStaticモードのNuxtでは、決まりきった処理をできるだけサーバーサイドで行うことをおすすめします。
最終的なpost/index.ts
最終的なコードは以下の通りです。
store/post/index.ts
import { getterTree, actionTree } from 'typed-vuex'
import { BlogCategory, BlogPost, BlogTag } from '../types'
import client from '~/plugins/contentful'
type RelatedEntries = (BlogCategory | BlogTag)[]
interface Entry {
includes: {
Entry: RelatedEntries
}
items: BlogPost[]
}
export const state = () => ({
content: {
type: {
category: 'blogCategory' as string,
post: 'blogPost' as string,
tag: 'blogTag' as string
},
limit: 200 as number,
// 公開日 降順
order: '-fields.publishDate' as string
} as const
})
type PostState = ReturnType<typeof state>
export const getters = getterTree(state, {
// blogPostモデルに関連づくコンテンツを配列に振り分ける
separateEntries: (state: PostState) => (entries: RelatedEntries):
{ categories: BlogCategory[], tags: BlogTag[] } => {
// push時点ではTypeエラーとなるのでany型で宣言し、Getterの戻り値で型指定を行う
const categories: any[] = []
const tags: any[] = []
for (const entry of entries) {
const currentContentType: string = entry.sys.contentType.sys.id
if (currentContentType === state.content.type.category) {
categories.push(entry)
} else if (currentContentType === state.content.type.tag) {
tags.push(entry)
}
}
return { categories, tags }
},
// カテゴリーの並び替え(sort: 昇順)
sortCategories: () => (categories: BlogCategory[]) =>
categories.sort((a: BlogCategory, b: BlogCategory) =>
a.fields.sort - b.fields.sort
),
// タグの並び替え(作成日: 降順)
sortTags: () => (tags: BlogTag[]) =>
tags.sort((a: BlogTag, b: BlogTag) => {
switch (true) {
case a.sys.createdAt < b.sys.createdAt: return 1
case a.sys.createdAt > b.sys.createdAt: return -1
default: return 0
}
})
})
export const actions = actionTree({ state, getters }, {
async getEntries ({ state, getters, commit }) {
await client.getEntries({
content_type: state.content.type.post,
limit: state.content.limit,
order: state.content.order
})
.then((entry: Entry) => {
const reletedEntry = getters.separateEntries(entry.includes.Entry)
const categories = getters.sortCategories(reletedEntry.categories)
const tags = getters.sortTags(reletedEntry.tags)
commit('setCategories', categories, { root: true })
commit('setTags', tags, { root: true })
commit('setPosts', entry.items, { root: true })
})
.catch((error: object) =>
console.log(error)
)
}
})
これでVuexからサーバーサイドでContentful APIをリクエストできるようになりました。
Postストア型を$accessorに登録する
作成したPostストアの型を、$accessor
に登録します。
store/index.ts
import { BlogCategory, BlogPost, BlogTag } from './types'
// 追加
// Import submodules
import * as post from '~/store/post'
export const accessorType = getAccessorType({
...
modules: {
// 追加
post
}
})
これでPostストアの型を登録できたので、$accessor.post
を使用してもTypeエラーにはなりません。
VuexストアをVueファイルから呼び出そう
Vuexのコンテンツを
今あるコードを全て削除して、<script>
タグをまるっと書き換えてください。
Vuexに保存したデータをasyncData()
で取得するだけOKです。
pages/index.ts
<script lang="ts">
import { Context } from '@nuxt/types'
import { Component, Vue } from 'nuxt-property-decorator'
import { BlogCategory, BlogPost, BlogTag } from '~/store/types'
interface AsyncData {
categories: BlogCategory[]
posts: BlogPost[]
tags: BlogTag[]
}
@Component
export default class IndexPage extends Vue {
asyncData ({ app: { $accessor } }: Context): AsyncData {
return {
categories: $accessor.categories,
posts: $accessor.posts,
tags: $accessor.tags
}
}
}
</script>
HTMLにカテゴリー、タグ、ブログ記事を表示します。
カテゴリーは、Vuetifyのv-card
コンポーネントを使用してカード形式で表示します。
pages/index.vue(カテゴリー)
<template>
<div>
<v-container>
<v-card-title
class="font-weight-bold"
>
Category
</v-card-title>
<v-card-title
class="font-weight-bold"
>
Category
</v-card-title>
<v-row>
<!-- sm, md => Window幅がsm、md時にカラムを何分割するか(sm: 4/12, md: 3/12) -->
<v-col
v-for="(category, i) in categories"
:key="`category-${i}`"
cols="12"
sm="4"
md="3"
>
<!-- to => v-card全体をリンクに設定する -->
<v-card
max-width="300"
class="mx-auto"
:to="`/categories/${category.fields.slug}`"
>
<!-- v-if => imageプロパティが存在する場合に表示 -->
<v-img
v-if="category.fields.image"
:src="`https://${category.fields.image.fields.file.url}`"
/>
<v-card-title>
{{ category.fields.sort }}.
{{ category.fields.title }}
</v-card-title>
<v-card-subtitle>
{{ category.fields.description }}<br>
slug: {{ category.fields.slug }}<br>
isMain: {{ category.fields.isMain }}
</v-card-subtitle>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
タグはv-chip
を使って表示します。
pages/index.vue(タグ)
<v-card-title
class="font-weight-bold"
>
Tag
</v-card-title>
<v-card>
<v-card-text>
<v-chip
v-for="(tag, i) in tags"
:key="`tag-${i}`"
outlined
:to="`/tags/${tag.fields.slug}`"
>
<v-img
v-if="tag.fields.image"
:src="`https://${tag.fields.image.fields.file.url}`"
width="18"
height="18"
class="mr-2"
/>
{{ tag.fields.title }}
</v-chip>
</v-card-text>
</v-card>
ブログ記事はv-list
を使って表示します。
pages/index.vue(ブログ記事)
<v-card-title
class="font-weight-bold"
>
Post
</v-card-title>
<v-list>
<!-- $router.push() => クライアントでページ遷移を行う(<nuxt-link>と同じ挙動) -->
<v-list-item
v-for="(post, i) in posts"
:key="`post-${i}`"
@click="$router.push(`/posts/${post.fields.category.fields.slug}/${post.fields.slug}`)"
>
<v-list-item-avatar
tile
width="160"
height="90"
>
<v-img
:src="`https://${post.fields.image.fields.file.url}`"
/>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-subtitle>
<nuxt-link
:to="`/categories/${post.fields.category.fields.slug}`"
>
{{ post.fields.category.fields.title }}
</nuxt-link>
</v-list-item-subtitle>
<!-- tag => DOMのタグを<a>タグに変更(default: 'dev') -->
<v-list-item-title
tag="a"
>
{{ post.fields.title }}
</v-list-item-title>
<v-list-item-subtitle>
公開日: {{ post.fields.publishDate }}
</v-list-item-subtitle>
<!-- v-if => tagsは必須ではないので、存在する場合にのみループする -->
<v-list-item-action-text
v-if="post.fields.tags"
>
<!-- v-for => v-forとv-ifは同じタグで宣言しない。エラーの元となる -->
<nuxt-link
v-for="(tag, tagI) in post.fields.tags"
:key="`tag-${tagI}`"
:to="`/tags/${tag.fields.slug}`"
class="mr-2"
>
#{{ tag.fields.title }}
</nuxt-link>
</v-list-item-action-text>
</v-list-item-content>
</v-list-item>
</v-list>
完成したindex.vue
カテゴリー、
タグ、ブログ記事、それぞれが表示できました。
ここまでの変更をNetlifyにデプロイする
最後に本番環境にデプロイして終わりましょう。
% yarn netlify:deploy
エラーが出るのは、ルート先のVueファイルを作成していないからです。
今は無視してOKです。
Error generating route "/demo-blog-03": This page could not be found
デプロイが成功すればここまでの変更をGitHubにPushします。
% git add -A
% git commit -m "Add vuex post/index.ts get contentful api"
% git checkout master
% git merge 20220109_setup_nuxt_typed_vuex
% git push
このチャプターまとめ
このチャプターでは、下記4つの作業を行いました。
- TypeScript環境のVuexモジュールの検討
- nuxt-typed-vuexのインストール ~ セットアップ
- Vuexへ型定義ファイルの追加
- Contentful APIレスポンスをVuexストアに保存(今ここ)
次回は?
現状、コンテンツを表示するページが作成されていないため、リンクをクリックしてもエラーになります。
次回は、このエラーに対応し、コンテンツを表示するページを作成します。