Vue.js
Netlify
nuxt.js
contentful
Tech DoDay 13

ブログ構築 Nuxt.js + Contentful + Netlifyを、npm実行すら初めての私がやってみた

技術的なやり方というよりは、エンジニア体験としてどうだったかを語ります。


背景


  • 副業(報酬なし)で企業向けホームページ制作をやっている

  • かなり昔からある片田舎の会社なので、採用技術が古い(もう25年位?)


    • さくらレンタルサーバー、ロリポップレンタルサーバー

    • WordPress

    • jQuery

    • Bootstrap, Materializeをつまみ食い

    • 独自・自作フレームワーク(作ったの僕だけど)



  • 多くのサイトを手がける上で、立ち上げ〜メンテナンスのコストが1次関数的に増加

結果、採用技術を刷新して、より高速な開発、豊かなサイト表現をできるようにしたい。


採用技術の刷新

とりあえず刷新したいと思って、とりあえず頭の中で考えたこと。


サーバ

さくらレンタルサーバー、ロリポップレンタルサーバーで、サイト制作するお客さん別にサーバを借りている。契約費が高い。セッティングがめんどくさい。

クラウド、もしくはサーバレスに変更する。最初のうちは手軽に始められるNetlifyが良さそう。


コンテンツ管理

レガシーな書き方のHTML + CSS + JSのファイル作成。ブログ記事についてはWordPressで管理している。グローバルナビの変更とか、フッターの変更一個とっても、十数ファイル書き換えが必要で変更がめんどくさい。

WordPressのカスタムフィールドに手を出し始めると実装がめんどくさい。変更のコストが大きい。

独自フィールドを簡単に定義可能なcontentfulに変更する。


サイト表現

Materializeでなんとなくマテリアルデザインっぽい。Bootstrapでなんとなくレスポンシブっぽい。ただ、スマホの大画面化、タブレットの普及などついていけてない部分があり、デザイン変更も独自の書き方ばかりで大変。デザインフレームワークをきちんと使いたい。サイトの変更を便利にしたい。

vue.js + Vuetifyを採用する。contentfulとの連携のために、Nuxt.jsも採用する。

最初はNuxt.jsの静的ジェネレートで良いが、そのうちAWSとかでNode.js使ってリアルタイム処理が必要になりそう。ブログ記事数が増えてくるとそうなりそう。


更にやりたいこと

PWAやりたい(願望)。ユーザにプッシュ通知送りたい。もちろんブログにはコメントも設けたい。etc...。PWAはNuxt.jsのPWAプラグインが良さそう。プッシュ通知はOneSignalが良さそう。コメントはDisqusが良さそう。

SEO対策のためにSSRが必須。


作業管理(おまけ)


業務

メールベースなのでSlackに変更する。


コード管理

コード管理はPCに存在するファイルのみなので、GitHubでリポジトリ管理する。


まとめ

作業は下記になった。


  1. GitHubセットアップ

  2. contentfulセットアップ


    1. 会社のアカウント作成

    2. 組織作成

    3. メンバ招待

    4. コンテンツ定義(タイトルとか本文とか)作成

    5. お試し用の記事作成



  3. Netlifyセットアップ


    1. 会社のアカウント作成

    2. サイト作成

    3. GitHubのリポジトリを登録

    4. カスタムドメインを登録

    5. Nuxt.jsを想定したビルドコマンドを登録



  4. Nuxt.jsのセットアップ


    1. ここで初めてnpmコマンド使ったり

    2. Nuxt.jsインストール

    3. contentfulのために、専用ライブラリ及び、マークダウンレンダリング用のライブラリインストール

    4. vuetifyライブラリインストール

    5. その他諸々インストール



  5. Nuxt.jsを使って実装


    1. ルーター設定

    2. pages, layouts, components設定

    3. vuetifyを使って装飾

    4. サービスワーカーが立ち上がってPWAにもなった



  6. トップページ、記事一覧、記事内容を備えたデモサイトを構築できた


    1. GitHubにプッシュすればnpm run generateがNetlifyで実行されてデプロイできる

    2. 手元でnpm run devすればlocalhostで動作確認できる

    3. ブログ1,000記事くらいまでだったらジェネレート時間も耐えられるかなぁ



1〜4までが半日で終わり、最後の5で2日くらいかかった。そもそもvue.jsの書き方がわからない。Nuxt.jsの設定ファイルの意味がわからない。storeとは・・・?どこの値をどうやってとったらいい?環境変数的なのを切り分けるには?

\( 'ω')/ウオオオオオアアアーーーーッ!


Nuxt.jsのインストールまわりの当時実行したコマンド

npmの使い方がまだまだ怪しい・・・?

npm install -g vue-cli

vue init nuxt/starter src
cd src
npm install --save babel-runtime
npm install --save contentful
npm install --save @nuxtjs/markdownit
npm install --save vue-markdown
npm install --save @nuxtjs/vuetify
npm install --save axios
npm install --save @nuxtjs/pwa
npm install --save @nuxtjs/onesignal
npm install


所感

意外と外部サービス使っての環境構築は簡単だった。Nuxt.jsが難しかった・・・。とりあえず現在は、.config.jsonにサイト名や認証情報などを切り離し、nuxt.config.jsにサイト生成ルールをまとめ、pagesに実装していけばとりあえずサイトを作れる流れができあがってきた。

普段サーバサイドの実装が主体で、フロントエンドは全くなので、Reactとかvue.jsとか聞いていて概念はわかっても実装方法がわからない。とりあえずサンプルを探しても、体型だった説明があまりなかった。探し方が悪いのかもしれない。公式のサンプルや説明を見るだけだと、実際業務でやりたい細かいことにつながるヒントがあまりなく、基本的な使い方で収まっているので試行錯誤が必要。

その点でやっぱりWordPressは親切だった。業務でPHPを使っているので技術的な苦難が無いのもそうだけど、ブラウザからアクセスする管理画面でわかりやすく設定変更が提供されているので細かく設定ファイルを変える必要もない。

静的ジェネレートは悪くはないけど、記事数が増えてきたり、ユーザ参加型のサイトを作ろうとすると限界が来るはず。そのためにNetlifyで作成したけど、すぐさまNode.jsが動くクラウドサーバの検討が必要。AWSは知識はあまりない。サーバサイドは経験強めなのでなんとかなるはずだが、このあたりの不安が大きい。

採用技術を刷新したことで、今まで他の人(1人しかいないけど)でもできていたことが、僕しかできなくなった。ドキュメント化などの共有、サポートが必要。GitHubからCloneして設定変えてけばすぐサイト作れるくらいにしておけば、良いだろう。汎用的に使えるものはpublicなリポジトリにしておいて、参考として使えるものに昇華させたい。

最終的には、お客様からサイト制作の契約がとれたら、コマンド実行ですぐさまデモサイトが立ち上がり、そこからデザイン詰めていく流れを作りたい。採用技術はどうあれ、業務を高速で回し続けるための仕組みが必要で、そのために上記に上げたサービス、ツールは非常に大きな役割を担っており、効果も高そう。


おまけ コード


nuxt.config.js

const config = require('./.config.json')

import axios from 'axios'

module.exports = {
// mode: "spa",

env: {
// contentful
CTF_SPACE_ID: config.CTF_SPACE_ID,
CTF_CDA_ACCESS_TOKEN: config.CTF_CDA_ACCESS_TOKEN,
CTF_PERSON_ID: config.CTF_PERSON_ID,
CTF_BLOG_POST_TYPE_ID: config.CTF_BLOG_POST_TYPE_ID,
CTF_NEWS_POST_TYPE_ID: config.CTF_NEWS_POST_TYPE_ID,
CTF_POST_TYPE_LIST: config.CTF_POST_TYPE_LIST,
// list, paging
TOP_PAGE_LIST_LIMIT: config.TOP_PAGE_LIST_LIMIT,
LIST_PAGE_LIMIT: config.LIST_PAGE_LIMIT,
LIST_DEFAULT_ORDER: config.LIST_DEFAULT_ORDER,
LIST_MAX_PAGING_NUMBER: config.LIST_MAX_PAGING_NUMBER,
},

router: {
routes: [
{
name: 'category',
path: '/:category',
component: 'pages/_category/_list.vue',
children: [
{
name: 'list',
path: '/:category/:list',
component: 'pages/_category/_list.vue'
},
{
name: 'page',
path: '/:category/page/:id',
component: '/_category/page/_id.vue'
}
]
},
]
},

generate: {
routes () {
// 各記事のページを作成
let pages = axios.get('https://cdn.contentful.com/spaces/' + config.CTF_SPACE_ID + '/environments/master/entries'
, {params: {access_token: config.CTF_CDA_ACCESS_TOKEN}}).then((res) => {
const posts = res.data
return posts.items.map((item) => {
return '/' + item.sys.contentType.sys.id + '/page/' + item.sys.id
})
})

let cateogry = [];
config.CTF_POST_TYPE_LIST.forEach(element => {
// /_categoryを作成
cateogry.push('/' + element);
// 対象ページ数分のリストページを作成
for(var i = 1; i <= config.LIST_MAX_PAGING_NUMBER; i++){
cateogry.push('/' + element + '/' + i);
}
});

return Promise.all([pages, cateogry]).then(values => {
return [...values[0], ...values[1]]
})
}
},

modules: [
'@nuxtjs/markdownit',
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/onesignal'
],

markdownit: {
injected: true
},

vuetify: {
theme: {
primary: '#3f51b5',
secondary: '#b0bec5',
accent: '#8c9eff',
error: '#b71c1c'
}
},

// PWA用
workbox: {
dev: true, //開発環境でもPWAできるように
},
manifest: {
name: config.SITE_NAME,
lang: 'ja'
},

// プッシュ通知
oneSignal: {
init: {
appId: config.ONE_PUSH_APP_ID,
allowLocalhostAsSecureOrigin: true,
welcomeNotification: {
disable: true
}
}
},

/*
** Headers of the page
*/

head: {
title: 'site_name',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: 'SiteName' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
},
/*
** Customize the progress bar color
*/

loading: {
color: 'red',
height: '3px'
},
/*
** Build configuration
*/

build: {
/*
** Run ESLint on save
*/

extend (config, { isDev, isClient }) {
if (isDev && isClient) {
config.module.rules.push({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /(node_modules)/
})
}
}
}

}



pages/index.vue

<template>

<div>
このあたりにスライドショー<br>
このあたりにプロモーションをグリッド表示で

<ul>
<li v-for="post in posts" v-bind:post="post" v-bind:key="post.sys.id">
<nuxt-link v-bind:to="'/' + post.sys.contentType.sys.id + '/page/' + post.sys.id">{{ post.fields.title }}</nuxt-link>
</li>
</ul>

SNSとか配置<br>
スポンサースペース
</div>
</template>

<script>
import {createClient} from '~/plugins/contentful.js'
const client = createClient()
export default {
asyncData ({env}) {
return Promise.all([
client.getEntries({
limit: env.TOP_PAGE_LIST_LIMIT,
order: env.LIST_DEFAULT_ORDER
})
]).then(([entries]) => {
return {
posts: entries.items
}
}).catch(console.error)
}
}
</script>



pages/_category/_list.vue

<template>

<div>
<ul>

<v-list two-line subheader>
<v-list-tile v-for="post in posts" v-bind:post="post" v-bind:key="post.sys.id">
<v-list-tile-content @click="dialog = openModal(post.fields.title, post.fields.subbody, post.sys.id, post.sys.contentType.sys.id)">
<a :href="'/' + post.sys.contentType.sys.id + '/page/' + post.sys.id" onclick="return false;">
<v-list-tile-title>{{ post.fields.title }}</v-list-tile-title>
<v-list-tile-sub-title>{{ post.sys.updatedAt }}</v-list-tile-sub-title>
</a>
</v-list-tile-content>
</v-list-tile>
</v-list>

</ul>

<div>
<nuxt-link :to="'/' + category + '/' + prevList">← prev</nuxt-link>
<nuxt-link :to="'/' + category + '/' + nextList">next →</nuxt-link>
</div>

<!-- モーダル 全画面 -->
<v-dialog v-model="dialog">
<v-card tile>
<v-toolbar card dark color="red darken-1">
<v-btn icon dark @click="dialog = false">
<v-icon>close</v-icon>
</v-btn>
<v-toolbar-title id="modal_title">title</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>

<v-card-text>
<div id="modal_body"></div>
</v-card-text>
<div style="flex: 1 1 auto;"></div>
</v-card>
</v-dialog>

<!-- モーダル ポップアップ
<v-dialog v-model="dialog" width="90%">
<v-card>
<v-card-title>
<span id="modal_title"></span>
</v-card-title>
<v-card-text id="modal_body"></v-card-text>
</v-card>
</v-dialog>
-->
</div>
</template>

<script>
import {createClient} from '~/plugins/contentful.js'
const client = createClient()
var markdownIt = require('markdown-it');
var mi = new markdownIt();
var currentCategory;
var currentNumber;

export default {
validate ({env, params}) {
// 対象のコンテンツタイプのみ許可する
return env.CTF_POST_TYPE_LIST.indexOf(params.category) >= 0;
},
data () {
return {
dialog: false,
notifications: false,
sound: true,
widgets: false
}
},
asyncData ({env, params}) {
currentCategory = params.category;
let currentListNumber = params.list > 0 ? parseInt(params.list) : 1;
currentNumber = currentListNumber;
let nextListNumber = currentListNumber > 1 ? currentListNumber + 1 : 2;
let prevListNumber = currentListNumber > 1 ? currentListNumber - 1 : 1;
return Promise.all([
client.getEntries({
content_type: params.category,
skip: (currentListNumber - 1) * env.LIST_PAGE_LIMIT,
limit: env.LIST_PAGE_LIMIT,
order: env.LIST_DEFAULT_ORDER
})
]).then(([entries]) => {
return {
posts: entries.items,
category: params.category,
nextList: nextListNumber,
prevList: prevListNumber,
}
}).catch(console.error)
},
methods: {
openModal(title, body, postId, contentType) {
// モーダルを開く
document.getElementById("modal_title").innerHTML = title;
document.getElementById("modal_body").innerHTML = mi.render(body);
// URLを変更する
window.history.pushState(null, null, '/' + contentType + '/page/' + postId);
return true;
}
},
watch: {
dialog (val) {
if(!val){
window.history.pushState(null, null, '/' + currentCategory + '/' + (currentNumber > 1 ? currentNumber : ''));
}
}
}
}
</script>



pages/_cateogry/page/_id.vue

<template>

<div v-if="post">
<h1>{{ post.fields.title }}</h1>
<p>{{ post.fields.when }}</p>
<div v-html="$md.render(post.fields.subbody)"></div>
</div>
</template>

<script>
import {createClient} from '~/plugins/contentful.js'
const client = createClient()
export default {
asyncData ({env, params}) {
return Promise.all([
client.getEntry(params.id)
]).then(([entry]) => {
return {
post: entry
}
}).catch(console.error)
}
}
</script>



layouts/default.vue

<template>

<v-app>
<toolbar/>

<v-content>
<div id="main_content">
<v-slide-y-transition>
<nuxt/>
</v-slide-y-transition>
</div>
</v-content>

<footerbar/>
</v-app>
</template>

<script>
import Toolbar from '~/components/Toolbar.vue'
import Footerbar from '~/components/Footerbar.vue'
export default {
components: {
Toolbar,
Footerbar
}
}
</script>

<style scoped>
#main_content{
padding:20px 0 0 16px;
}
</style>



components/Toolbar.vue

<template>

<span>

<v-toolbar color="white" fixed app clipped-left style="z-index:4;">
<v-toolbar-side-icon @click.stop="drawer = !drawer"></v-toolbar-side-icon>
<nuxt-link to="/" style="display:table-cell"><img src="/images/logo.png" style="vertical-align:middle;" /></nuxt-link>
</v-toolbar>

<v-navigation-drawer
v-model="drawer"
fixed
app
clipped
absolute
class="grey lighten-3">

<v-list dense>
<v-list-tile
@click="$router.push('/')"
:ripple="{ class: 'red--text' }"
:class="{'router-link-active': $route.path === '/' }">
<v-list-tile-action>
<v-icon>home</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Home</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>

<v-list-tile
@click="$router.push('/news')"
:ripple="{ class: 'red--text' }"
:class="{'router-link-active': $route.path.indexOf('/news') !== -1 }">
<v-list-tile-action>
<v-icon>notifications</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>What's New</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>

<v-list-tile
@click="$router.push('/blog')"
:ripple="{ class: 'red--text' }"
:class="{'router-link-active': $route.path.indexOf('/blog') !== -1 }">
<v-list-tile-action>
<v-icon>music_video</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>ブログ</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>

</span>
</template>

<script>
export default {
data: () => ({
drawer: null
})
};
</script>

<style scoped>
.router-link-active *{
color:#EF5350;
}
</style>


感想が増えたらまた書き足します。