Help us understand the problem. What is going on with this article?

Vue + Wordpress + Heroku + S3でポートフォリオを構築する

1. はじめに

こんにちは。ツダと申します。私はカメラが趣味で、自分の写真のポートフォリオサイトを作成したいと思い、Vue.jsとWordpressを使って作成しました。

Tsuda Work

この記事では、私がポートフォリオを作るうえで行ったことについて紹介させていただければと思っています。

2. 技術スタック

  • Vue.js : @vue/cli 4.5.6
  • Wordpress : 5.5.1
  • PHP : 7.3.5
  • heroku : heroku/7.44.0 win32-x64 node-v12.16.2
  • S3

簡単なシーケンス図(トップ画面表示時)

Tsudawork.png

3. Wordpress環境を整える

3.1 概要

今回のポートフォリオでは、WordpressをAPIとして使用します。 ですので、フロントエンド側はVueを、バックエンド側はWordpressというような役割分担をするイメージです。

参考:WP REST API

3.2 Localbyflywheelをダウンロード

まずはWordpress環境を構築します。環境構築にはLocalbyFlywheelを用います
。環境構築方法については以下のサイトを参考にしていただければと思います。

3.3 DB設定

後述しますが、デプロイするサーバは「heroku」を使います。herokuで使用するDBとローカル環境で使用するDBが異なりますので、wp-config.phpに条件分岐を書きます。wp-config.phpは作成したWordpressプロジェクトのルートフォルダに配置してあります(Windowsの場合、C:\Users\<ユーザ名>\Local Sites\<作成したアプリ名>\app\public配下)

wp-config.php
...(省略)...

// ** MySQL settings - You can get this info from your web host ** //
if (
    @$_SERVER["SERVER_NAME"] === '<作成したWordpressのローカル環境のドメイン名>'
) {
    // ローカル環境の設定
    define( 'DB_NAME', 'local' );
    define( 'DB_USER', 'root' );
    define( 'DB_PASSWORD', 'root' );
    define( 'DB_HOST', 'localhost' );
} else {
    // heroku環境の設定
    $url = parse_url(getenv('CLEARDB_DATABASE_URL'));
    define('DB_NAME', trim($url['path'], '/'));
    define('DB_USER', $url['user']);
    define('DB_PASSWORD', $url['pass']);
    define('DB_HOST', $url['host']);
}
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );

...(省略)...

参考:PaaS入門 〜Heroku + wordpress〜

3.4 セキュリティ面の設定

WordPressはあくまでAPIとしての役割で使用するため、WordPressサイトにはアクセスされたくありません。そこで、直接アクセスされるのを防ぐための各種設定を行います。

ログイン画面のパスを変更する

WordPressはログイン画面へのパスがデフォルトで "/wp-admin" として設定されています。しかし、これではログイン画面へのアクセスが容易にできてしまうので、これを変更するためのプラグイン「Login Rebuilder」が用意されています。

参考: Login Rebuilderの使い方|WordPressのログインURLの変更方法

サイトへのアクセスを制御する

次にサイトへのアクセスを制御します。何も設定していない場合、サイトへアクセスした際に記事が表示されてしまいますので、表示されないように設定します。

設定するためには、設定しているテーマのfunctions.phpに下記のコードを追加します。

functions.php
function access_restriction() {
    // 許可するアクセス
    // 管理画面
     if (is_admin()) {
         return;
    }
    // APIのアクセス
    if (strpos($_SERVER['REQUEST_URI'],'wp-json')) {
       return;
    }
    // ログイン画面
    if (strpos($_SERVER['REQUEST_URI'],'<Login Rebuilderで設定したログイン画面へのパス>')) {
       return;
    }
     wp_die('アクセスできません', 'アクセス拒否', array('responce' => 403));
 }
 add_action('init', 'access_restriction');

管理画面、APIへのアクセス、およびログイン画面へのアクセスは必要なので許可し、それ以外を拒否するように設定しています。実際にアクセスしてみると下のような拒否画面が現れます。

WS000021.JPG

参考:WordPress へのアクセス制限を functions.php で設定する

3.5 S3を使うための設定をする

次の記事に沿ってWordpressでS3を使用するための準備を行います。

Heroku + WordpressでS3を使用する

3.6 Herokuへデプロイする

まずは下記の記事を参考に、Herokuが使える状態にします。

参考:Heroku初心者がHello, Herokuをしてみる

そして下記の記事を参考に、作成したWordpressプロジェクトをHerokuへデプロイします。

参考:PaaS入門 〜Heroku + wordpress〜

主に次のことをやる必要があります。

  • Wordpressのルートディレクトリで「git init」+「git commit -m "commit"」
  • Wordpress用のプロジェクトアプリ作成
  • composer.jsonおよびcomposer.lockの作成
  • DB作成
  • デプロイ

3.7 パーマリンクの設定

WP REST APIにリクエストを投げると、デフォルトの状態では404エラーが返ってくるそうです。そのためにパーマリンクの設定をしてあげます。

※私の環境でこの設定をしなかったところ、 管理画面からの記事の編集・投稿ができませんでしたので設定することがおすすめです。

設定方法

設定方法はいたって簡単です。Wordpressの管理画面左にあるタブから「設定→パーマリンク設定」と進み、共通設定の「投稿名」にチェックをいれるだけとなっております。

WS000030.jpg

これで404エラーの原因となるパーマリンク設定が解消されるはずです。

参考:WP REST APIで404が返ってくる。これはパーマリンク設定のせいだ!

4. Vue.jsでプロジェクトを作成していく

4.1 環境構築

まずVueの環境構築を行います。Vueを使用する方法は主に二つあります

  • CDN
  • Vue CLI

ここではVue CLIを用いた方法を選択します。「参考」に記載させていただいた記事をもとに、「Welcome to Your Vue.js App」が表示されればOKです。

参考:Vue.js を vue-cli を使ってシンプルにはじめてみる

4.2 必要なモジュールをインストール

  • axios
$ npm install axios
  • bootstrap-vue
$ npm install vue bootstrap-vue bootstrap
  • vue-router
$ npm install vue-router

参考:
axios - npm
Getting Started | BootstrapVue
Installation | Vue Router

4.3 アプリの全体像

アプリの全体像は次のようになっています。

main.js
import Vue from 'vue'
import Articles from './Articles.vue'
import Article from './Article.vue'
import Router from 'vue-router'
import Top from './Top.vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

// Router
Vue.use(Router)

// Install BootstrapVue
Vue.use(BootstrapVue)

// Optionally install the BootstrapVue icon components plugin
Vue.use(IconsPlugin)


const router = new Router({
  mode:'history',
  routes: [
    {
      path: '/',
      component: Articles
    },    
    {
      path: '/category/:value',
      name: 'category',
      component: Articles,
    },
    {
      path: '/post/:value',
      name: 'post',
      component: Article
    }
  ]
})

Vue.config.productionTip = false

new Vue({
  render: h => h(Top),
  router
}).$mount('#app')
Top.vue
<template>
  <div id="app">
    <b-navbar class="navbar bg-white" fixed="top" toggleable="lg">
      <b-navbar-brand href="/">Tsuda Work</b-navbar-brand>
      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item-dropdown :text="category.name" class="nav-item" v-for="category in categories" :key="category.id">
              <b-dropdown-item><router-link class="nav-link" :to="{name:'category', params:{value:category.id}}">All</router-link></b-dropdown-item>
              <b-dropdown-item :text="subCategory.name" class="nav-item" v-for="subCategory in category.subCategories" :key="subCategory.id">
                <router-link class="nav-link" :to="{name:'category', params:{value:subCategory.id}}">{{subCategory.name}}</router-link>
              </b-dropdown-item>
          </b-nav-item-dropdown>
        </b-navbar-nav>
        <b-navbar-nav class="ml-auto">
          <b-nav-item class="nav-item" href="https://twitter.com/tsuda215">
            <div>Twitter</div>
          </b-nav-item>   
        </b-navbar-nav>
      </b-collapse>
    </b-navbar> 
    <router-view class="article-router" :key="$route.path"></router-view>
    <footer class="page-footer font-small blue pt-4">
      <div class="footer-copyright text-center py-3">© 2019-2020 Tsuda Work.</div>
    </footer>
  </div>
</template>

<script>
import axios from 'axios';
import states from "./assets/property.json";
export default {
  name: 'Top',
  data() {
      return {
        categories: [],
      }
  },
  mounted() {
    const url = states.hostname + states.categoriesUrl;
    (async () => {
      try {
        // カテゴリー取得
        const res = await axios.get(url);
        var categoriesTmp = [].concat(res.data);
        for (var categoryTmp of categoriesTmp) {
          // サブカテゴリ―の場合は処理をスキップ
          if (categoryTmp.parent > 0) {
            continue;
          }

          // カテゴリー作成
          var category = {};
          category['id'] = categoryTmp.id;
          category['name'] = categoryTmp.name;
          category['subCategories'] = [];

          // サブカテゴリー取得
          const resForSubCategories = await axios.get(url + "?parent=" + categoryTmp.id);
          var subCategoriesTmp = [].concat(resForSubCategories.data);

          for (var subCategoryTmp of subCategoriesTmp) {
            // サブカテゴリー作成
            var subCategory = {};
            subCategory['id'] = subCategoryTmp.id;
            subCategory['name'] = subCategoryTmp.name;
            // サブカテゴリ―配列に追加
            category['subCategories'].push(subCategory);
          }

          // カテゴリー配列に追加
          this.categories.push(category);
        }
      } catch (error) {
        console.log(error);
      }
    })();
  },
}
</script>

<style>
#app {
  display: flex;
  flex-direction: column;
  min-height: 82vh;
  font-family: "Yu Gothic","Yu Gothic UI","Meiryo UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "MS PGothic", sans-serif; 
}
.navbar {
  background-color:rgba(0, 0, 0, 0);
}
.page-footer {
  margin-bottom:0;  
}
@media screen and (orientation:portrait) {
  .article-router {
    margin-top:10vh;
  }
}
@media screen and (orientation:landscape) {
  .article-router {
    margin-top:7vw;
  }
  @media screen and (max-width: 800px){
    .article-router{
      margin-top:9vw;
    }
  }
}
</style>
Articles.vue
<template>
  <div id="app">
    <transition name="fade">
      <!-- フェードイン実装のためv-ifは必要 -->
      <div v-if="ok" class="album py-5">
        <div class="container">
          <div class="row">
            <div class="col-md-4 mb-4" v-for="post in posts" :key="post.title.rendered">
              <div class="card h-100 shadow-sm">
                <router-link class="nav-link" :to="{name:'post', params:{value:post.id}}">
                    <img class="card-img-top" :src="post._embedded['wp:featuredmedia'][0].source_url" alt="">
                </router-link>
              </div>
            </div>
          </div>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
import axios from 'axios';
import states from "./assets/property.json";
export default {
  name: 'Articles',
  data() {
      return {
        posts: [],
        // フェードイン実装のために必要
        ok: false
      }
  },
  mounted() {
    var categoryId = this.$route.params.value;
    var url = states.hostname + states.postsUrl;
    if (categoryId != undefined) {
      url = states.hostname + states.categoryUrl + categoryId + '&_embed';
    }
    (async () => {  
      try {
        const res = await axios.get(url);
        this.posts = this.posts.concat(res.data);
        // マウント時にok=trueを実施
        this.ok = true;
      } catch (error) {
        console.log(error);
      }
    })();
  },
}
</script>

<style>
.card {
  display: flex;
  justify-content: center; /*左右中央揃え*/
  align-items: center; /*上下中央揃え*/
}

.fade-enter-active, .fade-leave-active {
  transition: opacity 1s;
}

.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>
Article.vue
<template>
  <transition name="fade">
    <div v-if="ok" class="container">
      <div class="row">
        <div class="articleContainer">
          <div class="article">
            <h3 class="title">{{post.title.rendered}}</h3> 
            <div class="content" v-html="post.content.rendered">
            </div>
          </div>
        </div>
      </div>
    </div>
  </transition>
</template>

<script>
import axios from 'axios';
import states from "./assets/property.json";
export default {
  name: 'Article',
  data() {
      return {
        post: null,
        // フェードイン実装のために必要
        ok: false
      }
  },
  mounted() {
    var postId = this.$route.params.value;
    const url = states.hostname + states.postUrl + postId + '?_embed';
    (async () => {
      try {
        const res = await axios.get(url);
        this.post = res.data;
        // マウント時にok=trueを実施
        this.ok = true;
      } catch (error) {
        console.log(error);
      }
    })();
  },
}
</script>

<style>
.articleContainer {
  display: flex;
  justify-content: center; /*左右中央揃え*/
  align-items: center; /*上下中央揃え*/
  width: 100%;
}

.article > .title {
  text-align: center;
}

.article img {
  width: 100%;
}

@media screen and (min-width: 800px){
    .article{
        width:60%;
    }
}

@media screen and (max-width: 799px){
    .article{
        width:85%;
    }
}

/* フェードインの設定 */
.fade-enter-active
/* , .fade-leave-active */
{
  transition: opacity 2s;
}
.fade-enter
/* , .fade-leave-to */
{
  opacity: 0;
}
.fade-leave-active {
  display:none;
}

</style>
property.json
{
    "hostname":"https://<Wordpressプロジェクトのホスト名>/",
    "postUrl":"wp-json/wp/v2/posts/",
    "postsUrl":"wp-json/wp/v2/posts?_embed",
    "categoryUrl":"wp-json/wp/v2/posts?categories=",
    "categoriesUrl":"wp-json/wp/v2/categories"
}

※注意

  • ホスト名を間違えると"No 'Access-Control-Allow-Origin' header is present on the requested resource."という旨のエラーが出ることを確認しました。
  • httpsをhttpにして設定すると、Heroku上のVueから画像が取得できないことを確認しました。

4.4 アプリの解説

Navbar

Navbarを使用する前に、vueでbootstrapを使用できるようにする必要があります。

参考:Getting Started | BootstrapVue

インストールコマンドは次の通りです。

# With npm
npm install vue bootstrap-vue bootstrap

# With yarn
yarn add vue bootstrap-vue bootstrap

インストールした後はmain.jsにてbootstrapを使えるように設定しています。

main.js
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

...(省略)...

// Install BootstrapVue
Vue.use(BootstrapVue)

// Optionally install the BootstrapVue icon components plugin
Vue.use(IconsPlugin)

次にメニュー部分の実装に入ります。
参考:Navbar

Top.vue
<template>
  <div id="app">
    <b-navbar class="navbar bg-white" fixed="top" toggleable="lg">
      <b-navbar-brand href="/">Tsuda Work</b-navbar-brand>
      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item-dropdown :text="category.name" class="nav-item" v-for="category in categories" :key="category.id">
              <b-dropdown-item><router-link class="nav-link" :to="{name:'category', params:{value:category.id}}">All</router-link></b-dropdown-item>
              <b-dropdown-item :text="subCategory.name" class="nav-item" v-for="subCategory in category.subCategories" :key="subCategory.id">
                <router-link class="nav-link" :to="{name:'category', params:{value:subCategory.id}}">{{subCategory.name}}</router-link>
              </b-dropdown-item>
          </b-nav-item-dropdown>
        </b-navbar-nav>
        <b-navbar-nav class="ml-auto">
          <b-nav-item class="nav-item" href="https://twitter.com/tsuda215">
            <div>Twitter</div>
          </b-nav-item>   
        </b-navbar-nav>
      </b-collapse>
    </b-navbar> 
    <router-view class="article-router" :key="$route.path"></router-view>
    <footer class="page-footer font-small blue pt-4">
      <div class="footer-copyright text-center py-3">© 2019-2020 Tsuda Work.</div>
    </footer>
  </div>
</template>

ポイントは次の部分です。

Top.vue
<b-nav-item-dropdown :text="category.name" class="nav-item" v-for="category in categories" :key="category.id">
    <b-dropdown-item><router-link class="nav-link" :to="{name:'category', params:{value:category.id}}">All</router-link></b-dropdown-item>
    <b-dropdown-item :text="subCategory.name" class="nav-item" v-for="subCategory in category.subCategories" :key="subCategory.id">
      <router-link class="nav-link" :to="{name:'category', params:{value:subCategory.id}}">{{subCategory.name}}</router-link>
    </b-dropdown-item>
</b-nav-item-dropdown>

親カテゴリはAllとして必ず作成するようにしています。そしてサブカテゴリの数だけループを回し、追加のドロップダウンアイテムを作成しています。カテゴリ作成ロジックは次のようにしています。

Top.vue
mounted() {
  const url = states.hostname + states.categoriesUrl;
  (async () => {
    try {
      // カテゴリー取得
      const res = await axios.get(url);
      var categoriesTmp = [].concat(res.data);
      for (var categoryTmp of categoriesTmp) {
        // サブカテゴリ―の場合は処理をスキップ
        if (categoryTmp.parent > 0) {
          continue;
        }

        // カテゴリー作成
        var category = {};
        category['id'] = categoryTmp.id;
        category['name'] = categoryTmp.name;
        category['subCategories'] = [];

        // サブカテゴリー取得
        const resForSubCategories = await axios.get(url + "?parent=" + categoryTmp.id);
        var subCategoriesTmp = [].concat(resForSubCategories.data);

        for (var subCategoryTmp of subCategoriesTmp) {
          // サブカテゴリー作成
          var subCategory = {};
          subCategory['id'] = subCategoryTmp.id;
          subCategory['name'] = subCategoryTmp.name;
          // サブカテゴリ―配列に追加
          category['subCategories'].push(subCategory);
        }

        // カテゴリー配列に追加
        this.categories.push(category);
      }
    } catch (error) {
      console.log(error);
    }
  })();
},

記事表示部分

記事の表示は次の部分です。

Articles.vue
<div class="container">
  <div class="row">
    <div class="col-md-4 mb-4" v-for="post in posts" :key="post.title.rendered">
      <div class="card h-100 shadow-sm">
        <router-link class="nav-link" :to="{name:'post', params:{value:post.id}}">
            <img class="card-img-top" :src="post._embedded['wp:featuredmedia'][0].source_url" alt="">
        </router-link>
      </div>
    </div>
  </div>
</div>

こちらもbootstrapの使用が必要になります。

フェードインの実装

記事の一覧を表示する際にフェードインを使用しています。
参考:Enter/Leave & List Transitions

フェードインの実装にはvueのtransitionという機能を使用しています。transitionを使用するためにはフェードさせたい要素をで囲む必要があります。

Articles.vue
<transition name="fade">
  <!-- フェードイン実装のためv-ifは必要 -->
  <div v-if="ok" class="album py-3">
...(省略)...
  </div>
</transition>

v-ifに指定の"ok"はデフォルトでfalseとなっています。

Articles.vue
data() {
    return {
      posts: [],
      // フェードイン実装のために必要
      ok: false
    }
},

そしてマウント時にtrueとすることで、要素を表示させます。

Articles.vue
mounted() {
...(省略)...
    (async () => {  
      try {
        const res = await axios.get(url);
        this.posts = this.posts.concat(res.data);
        // マウント時にok=trueを実施
        this.ok = true;
      } catch (error) {
        console.log(error);
      }
    })();
  },

表示の際は下記のcssを使用しているので、フェードインでの表示となります。

Articles.vue
<style>
...(省略)...
.fade-enter-active, .fade-leave-active {
  transition: opacity 1s;
}

.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>

Wordpress REST APIを用いて記事取得

記事を取得する際はWordpress REST APIにリクエストを投げますが、リクエストを投げる際はaxiosを使用しています。

Articles.vue
<script>
import axios from 'axios';
import states from "./assets/property.json";
export default {
  name: 'Articles',
  data() {
      return {
        posts: [],
        // フェードイン実装のために必要
        ok: false
      }
  },
  mounted() {
    var categoryId = this.$route.params.value;
    var url = states.hostname + states.postsUrl;
    if (categoryId != undefined) {
      url = states.hostname + states.categoryUrl + categoryId + '&_embed';
    }
    (async () => {  
      try {
        const res = await axios.get(url);
        this.posts = this.posts.concat(res.data);
        // マウント時にok=trueを実施
        this.ok = true;
      } catch (error) {
        console.log(error);
      }
    })();
  },
}
</script>

実際にリクエストを投げているのは次の部分です。

const res = await axios.get(url);

記事のIDを取得したときなど、単数を指定するものはObject、IDを指定せず記事を複数取得した場合はObjectの配列がres.dataには格納されています。後は取得したものを加工し、vueで描画すればOKです。

参考:《WordPress》2017年末にWP REST API で取得してVue.jsで描画するまでのまとめ。

4.5 アプリをHerokuにデプロイする。

次の記事に沿って、下記の3作業を行う必要があります。

Vue.jsで作ったアプリをHerokuにデプロイ

  • expressのインストール
  • server.jsの作成
  • デプロイ

これらの作業を完了しアプリを表示すると、Wordpressで作成したアプリに対してリクエストが飛んでいるはずです。無事記事の一覧が表示されていればOKです。

4.6 リロードの設定

Vueアプリはデフォルトでrouterのパスに「ハッシュ(#)」が含まれています。これはrouterを「historyモード」に変更することで削除することができますが、historyモードを使用することでページをリロードした際に、404エラーが返却されてしまいます(historyモードでない場合にはこの問題は発生しません)。この問題を防ぐために次の記事に沿って、vueアプリに対してリロードの設定を行います。

VueでURLからハッシュ("#")を削除し、リロードもできるようにする

5. その他

5.1 スリープ防止のため、スケジューラ作成

Herokuのサイトは一定時間が経過すると、スリープ状態に入ってしまうため、次にアクセスする際にサイトが表示されるまで時間がかかるという問題が発生してしまいます。次の記事に沿ってHerokuの設定を行うことでスリープを防止します。

Herokuでスリープ防止のためにスケジューラを作成する

5.2 Wordpressでアップロードできる画像の大きさを上げる

HerokuにWordpressをデプロイした直後は2MBまでの画像しかアップロードすることができません。この最大値を上げるための設定を行います。

.user.iniの追加

Herokuにはphp.iniの設定を書き換える方法として.user.iniの作成を挙げています。
参考:Customizing Web Server and Runtime Settings for PHP

具体的には、まず下記のようなファイルを作成します。

.user.ini
post_max_size = 100M
upload_max_filesize = 100M

そしてこのファイルをWordpressのルートディレクトリに配置します。後はアプリをデプロイするとHerokuが自動で設定値を認識し、画像のアップロードサイズが増加しているはずです。

6. まとめ

以上がVue + Wordpressを用いてポートフォリオを作成した手順となります。初めてVueを用いてWebサイトを作成しましたが、APIの併用もしたことで大変勉強になりました。簡単にはなってしまいますが、以上で記事を締めさせていただこうと思います。最後に、参考にさせていただいた記事の執筆者の方々、本当にありがとうございます。そして勝手に引用させていただき恐縮です。

備考

今回紹介させていただいたポートフォリオは無料で作成することが可能となっております。しかしSSLを常用化したり、独自ドメインを設定したい場合は有料となってしまいます。

参考させていただいたリンクまとめ

Wordpress

Vue.js

その他

kyabetsuda
わかりやすい記事執筆を心がけています。 Java Shell VBA Wordpress
https://linktr.ee/tsudatech
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away