作成日:2021/10/14 更新日:2023/06/05
#1 vue.js + WordPressでヘッドレスCMSを作る
まえおき
ヘッドレスCMSについて
WordPres(以下WP)は、それだけでWebサイトが作成できる素晴らしいCMSです。データを保存するバックエンド(サーバサイド)も、画面に表示するフロント(クライアントサイド)もPHP言語を使って作成できます。
WPで使うPHP言語は、記事やページを登録することに関しては申し分のない機能をもっている反面、 表示はブラウザのみに限られおり、通常スマホアプリなどでは表示できません。
また、ページ遷移もいちいちページ全体を再度読み込むためにデータ通信量が増え、速度も遅くなりがちです。 そのあたりの弱点を解消するための新しい技術として、ヘッドレスCMSという考え方ができました。 Headless CMS(Head=頭、less=すくない)とは、表示部分の機能が無いCMSのことをいいます。 バックエンド側(体)と、表示させるフロント部分(頭)を切り離そうとする考え方です。
これの何が良いのかといえば、WPのフロント部分を本来バックエンドが得意なPHPでは作らずに、 フロントがとても得意なvue.jsでフロント部分を作るのです。
そうすることで、前に言った弱点を解消できる上に、なによりWPで表示部分を作る関数を覚えてもWPでしか使えませんが、 vue.jsで表示できる技術があれば、ブラウザ以外の様々なデバイスへの表示やWP以外のCMSやバックエンドサービスに対応できるようになるので、 やれることの選択肢が増えるという利点があります。
また、セキュリティ的にも有利です。
WPは表示部分(頭)からデータベース(体)まで、一つのアプリケーションですべてを管理しているため、 表示部分から直接バックエンドにつながってしまいます。例えるなら戸建住宅のような感じです。 勝手口のドアが空いてたりトイレの窓ガラスを割るだけで、家の中の全てに侵入することができます。
ヘッドレスCMSは表示とデータが、異なるアプリケーションとして分離しているため、 表示部分を調べても、データへの穴が見つけにくい設計となります。
これを例えるならタワーマンションです。オートロックで入り口に管理室があって、 ロビーに防犯カメラもあって、エレベーター使うときには鍵が必要で、玄関まで来たとしても鍵がかかっていて簡単には中に入れない...みたいな。
今現在使用しているWPを新しいヘッドレスCMSに移行することも案としてありますが、 今回紹介するWP API + vue.jsは、WPという過去の資産を活かしつつ、フロント部分の速度アップとセキュリティ面の強化を実現できます。
わざわざヘッドありCMSであるWPの、表示部分 を使わず にその部分をvue.jsで表示させるということになります。
ヘッドレスCMSを調べていると出てくる言葉
ヘッドレスCMSを調べていくと、Nuxt.jsというものが出てくると思いますが、 それは中規模以上のサイトやアプリ制作に役に立つvue.jsのフレームワークです。 30〜40ページ程度までのコーポレートサイトなどはvue.jsでも十分です。SSGと呼ばれる静的なサイト制作にはVuePressというフレームワークのほうが適しているでしょう。 この章をマスターして余力があれば学習してみましょう。
また、JAMstack = JavaScript/APIs/Markup という言葉も見られますが、 この章で学ぶようなヘッドレスCMSやSSG、SSR などを総称した言葉である、、、程度の認識でOKです。
Netlifyというホスティングサーバーの会社が作った考え方だそうです。
実際にWordPressだけで作ったサイト と、 vue.js + WordPress で作ったサイトを見比べてみましょう。
どうでしょう?
WordPressでも普通のサイトとしては十分なのですが、ページを移動する際のページ遷移にもたつきを感じます。。。 しかしvue.js + WordPressの表示はストレスなく表示されたのではないでしょうか。
vue.js + WordPressでは、WordPressで登録したデータをWP APIとしてvue.jsが受信して画面表示しています。どちらも同一のデータベースを利用しています。
制作手順
大まかな手順
①ローカル環境にvue.jsのインストール②header, footer, navの部品コンポーネントを作りトップページを移植する(API取得確認)
③その他のページを移植する(ルーティング動作確認)
④アニメーションなど動きの設定(UX設定)
⑤オンラインで確認
ざっと、こんな感じの手順になります。これから何をするのかを頭の中でイメージしてみましょう。
詳細な手順
① vue.jsのインストール- ローカルで開発したWP APIにアクセスしてJSONを確認してみる
- コマンドラインからvue.jsをインストールする
- vue.jsのセッティング(main.js、vue-router, axios, sass-loaderなど)
② header, footer, navの部品コンポーネントを作りトップページを移植する
- header, footer, naviコンポーネント(vue)の作成とApp.vueへの記述
- WPのトップページを、vueのhomeコンポーネントへの移植(axiosでAPIを使ったJSON取得方法)
- router.jsのセッティング
- コマンドラインでサーバ起動し、ブラウザで表示確認
- アコーディオンメニューをjQuery→vue.jsに変更する
③ その他のページを移植する
- 固定ページ(WP)を、pageコンポーネント(vue)として移植
- ブラウザでURLの変化を確認(名前付きルーティング説明)
- カスタム投稿ページ(WP)を、chefコンポーネント(vue)として移植(パラメータルーティング説明)
- chefコンポーネント内に、一覧機能を子コンポーネントとして作成(chefList.vue)
- 404ページの作成、ページの再読み込みや進む戻るの挙動確認
④ アニメーションなど動きの設定
- ページ遷移時のアニメーションと、遷移後のトップへ移動する機能説明
- ローディングの実装
⑤ オンラインで確認
- ビルドしてwebサーバーにデプロイ
- 動作確認
この章では応用3で作ったWordpressを使ったレストランサイトを使いますが、 事前にWPのheader, footer部分を多少カスタムすることから始めます。
まず、WPレストランサイトの改修
wordpressで作ったレストランサイトを改修していきます。直接ヘッドレスCMSとは関係ないのですが、よりvue.jsの特徴がわかりやすいように、応用3の#4のheader, footerのデザインを変更します。 追加画像はダウンロードしてテーマフォルダ > asstes > img フォルダに入れておいてください。
新しいテーマ用のscreenshot.pngに差し替えておきましょう。
WPテンプレートのダウンロード 追加画像のダウンロード screenshot.png
前回の例の通りで行けば、8003-wordpress/wp-content/themes/内のテーマフォルダcbcrestaurantを複製してcbcrestaurant-vueを作ったとします。
では、cbcrestaurant-vueのファイルの中身を改修していきましょう。
WP_Queryで記事を取得して、スラッグ名を表示するようにしています。
$the_query->posts[$count]->post_name;で記事のスラッグ名が取得できます。
$countはループごとに+1づつ増やしていきましょう。
functions.phpのwp_enqueue_scriptで読み込ませてあります。応用3#4
レイアウトはこのようになります。
では、cbcrestaurant-vueのファイルの中身を改修していきましょう。
1) ナビゲーションを変更します。
<header class="header">・・・</header>のラッパーを削除して下記のナビゲーションに差し替えます。
//header.php
<nav class="gnav">
<div class="wrap">
<div class="gnav__logo">
<a href="/">
<img src="<?php echo get_template_directory_uri(); ?>/assets/img/cbc_logo.svg" alt="logo" />
</a>
</div>
<div class="gnav__nav">
<div class="toggle" onClick="toggleBtn()">
<div class="one"></div>
<div class="two"></div>
<div class="three"></div>
</div>
<ul class="menu" style="display:none;">
<?php
$args = array(
'post_type' => 'page' /* page: 固定ページを取得 */
);
$the_query = new WP_Query($args);
$count = 0;
?>
<?php if($the_query->have_posts()): while($the_query->have_posts()): $the_query->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"> <?php echo $the_query->posts[$count]->post_name; ?> </a>
</li>
<?php
$count++;
endwhile; endif;
wp_reset_postdata();
?>
<li>
<ul>
<?php
$args = array(
'post_type' => 'chef' /* post: カスタム投稿nameでページを取得 */
);
$the_query = new WP_Query($args);
$count = 0;
?>
<?php if($the_query->have_posts()): while($the_query->have_posts()): $the_query->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"> <?php echo $the_query->posts[$count]->post_name; ?> </a>
</li>
<?php
$count++;
endwhile; endif;
wp_reset_postdata();
?>
</ul>
</li>
</ul>
</div>
</div>
<div class="modal-bg" style="display:none;" onClick="toggleBtn()"></div>
</nav>
/cbcrestaurant-vue/header.phpをこのように書き換えます。
いったん丸々コピペして、後ほどどのような動きをしているのか見てみましょう。WP_Queryで記事を取得して、スラッグ名を表示するようにしています。
$the_query->posts[$count]->post_name;で記事のスラッグ名が取得できます。
$countはループごとに+1づつ増やしていきましょう。
2) cssの変数を集めた、_variables.scssに、変数を2つ追加しておきます。
/* _variables.scss */
$gnav-color:rgba(26, 26, 26, 0.95);
$point-color: #fa8b23;
3) ハンバーガーメニューを作るのでちょっと長いですが、style.scssに以下を追加しましょう。
/*
* cbcrestaurant-vue .header{}の次に以下を追加
*/
.display-block{
display:block!important;
}
.modal-bg{
height: 100vh;
width: 100%;
position: fixed;
top:0;
left:0;
background: rgba(0, 0, 0, .5);
cursor: pointer;
z-index: 997;
}
.gnav {
z-index: 999;
width: 100%;
position:fixed;
top:0;
background-color:v.$gnav-color;
transition: all .1s ease 0s;
.wrap{
height:70px;
margin:0 auto;
max-width: 1920px;
padding:0 18px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
&__nav{
width:80px;
margin-right:10px;
display:flex;
justify-content: center;
align-items: center;
position:relative;
&-menu{
@include v.mq('max','md'){
display:none;
}
}
}
&__logo{
display: flex;
justify-content: center;
align-items: center;
width:100px;
@include v.mq('max','md'){
align-items: center;
}
img{
height:50px;
transition: all .5s ease 0s;
@include v.mq('max','md'){
width:40px;
}
}
}
}
.nav-small{
.wrap{
height:50px;
}
.gnav {
&__content{
height:50px;
}
&__logo{
align-items: center;
img{
height:40px;
}
}
}
}
/*ハンバーガーメニュー*/
.toggle {
width: 30px;
height: 30px;
cursor: pointer;
z-index: 999;
div {
height: 3px;
background: #fff;
margin: 6px auto;
transition: all 0.5s;
backface-visibility: hidden;
}
&.on .one {transform: rotate(45deg) translate(5px, 5px);}
&.on .two {opacity: 0;}
&.on .three {transform: rotate(-45deg) translate(7px, -8px);}
}
/*メニュー*/
.menu {
width:200px;
margin: 0.5em 0 !important;
position: absolute;
top:25px;
right:25px;
font: 300 13px Arial, Helvetica;
z-index: 998;
> li {
padding:1em;
background-color: #111;
a{
color:#fff;
}
&:hover{
background-color: #373737;
> a {
color:v.$point-color;
}
}
> ul {
width:100%;
position: absolute;
right: 0;
z-index: 1;
visibility:visible;
background-color: #373737;
> li {
font-size:12px;
box-shadow: 0 1px 0 #1e1e1e, 0 2px 0 #515151;
a {
padding: 1em;
&:hover {
color:v.$point-color;
background-color: #666;
}
}
}
}
}
a.active{
color:v.$point-color;
}
}
/* cbcrestaurant-vueここまで*/
ナビゲーション部分のスタイルになります。4)cbcrestaurant-vue/asset/にjsフォルダを作ってmain.jsを作ります。
/* ハンバーガーメニューや、モーダル背景をクリックしたときの動き */
const toggle = document.querySelector('.toggle');
const modalBg = document.querySelector('.modal-bg');
const menu = document.querySelector('.menu');
function toggleBtn(){ /* onClick="toggleBtn()"をクリックしたときに発火する命令 */
toggle.classList.toggle('on');
modalBg.classList.toggle('display-block');
menu.classList.toggle('display-block');
}
/* 80px以上スクロールしたらheaderを小さくするためのスタイルをつける */
const gnav = document.querySelector('.gnav');
window.addEventListener('scroll', function(){
if(window.scrollY > 80){
gnav.classList.add('nav-small');
} else {
gnav.classList.remove('nav-small');
}
});
vueで同じものを使うため、jQueryではなく純粋なjavaScriptで記述しました。functions.phpのwp_enqueue_scriptで読み込ませてあります。応用3#4
5)footerのデザインを変更します。
<footer class="footer">・・・省略</footer>を削除して以下のものに差し替えます
//footer.php
<footer class="footer">
<div class="footer__profile">
<div>
<h2><img src="<?php echo get_template_directory_uri(); ?>/assets/img/cbc_logo_white.svg" alt="logo" width="65" /></h2>
</div>
</div>
<div class="footer__contact">
<div>
<?php
$args = array(
'post_type' => 'page' /* page: 固定ページを取得 */
);
$the_query = new WP_Query($args);
$count = 0;
?>
<?php if($the_query->have_posts()): while($the_query->have_posts()): $the_query->the_post(); ?>
<h2>
<a href="<?php the_permalink(); ?>"> <?php echo $the_query->posts[$count]->post_name; ?> </a>
</h2>
<?php
$count++;
endwhile; endif;
wp_reset_postdata();
?>
</div>
</div>
</footer>
<div class="copyright">
<p>© copyright</p>
</div>
header.phpのnavi部分に記述したものをそのまま流用しました。
.footer{
display:flex;
align-items: center;
background: #39383C;
color:#fff;
a:link,
a:visited {
color: #eee;
text-decoration: none;
}
&__profile {
display:flex;
justify-content: center;
flex:5;
text-align:center;
height:400px;
padding:20px;
background:url("../img/footer_bg.jpg")no-repeat center center/cover;
> div{
display:flex;
justify-content: center;
flex-direction:column;
}
}
&__contact{
flex:3;
text-align:center;
padding:20px;
}
h2{
font-size:1.2em;
font-weight:300;
line-height:3;
}
.sns-wrap{
margin-top:30px;
}
li {
display: inline-block;
margin: 5px;
}
i{
font-size:3em;
margin-right:40px;
}
}
.copyright {
padding:10px;
height:40px;
color:#fff;
font-size: 0.8em;
text-align:center;
background-color: rgba(0, 0, 0, 0.8);
}
style.scssの.footer{ }内をすべて上記のものに差し替えます。6)wordpressの固定ページに記事を登録する
レイアウトはこのようになります。
1. ローカル環境にvue.jsをインストール
ターミナルを使ってVUE CLIで環境を作っていきます。実践1 #1 vueテンプレート作成 の通りに作成します。
ここの例では、job/stg/8080-vue/11-restaurant/で作成していきます。
▼制作環境
Mac OS 10.15.7
node.js v16.8.0
Vue CLI v4.5.13
vue.js v2.6.14
下準備
$ nodebrew ls-remote //インストール可能なバージョンの表示
$ nodebrew install-binary v16.8.0 //Node.jsをバージョン指定してインストール
$ nodebrew ls //インストールしているバージョン一覧
$ nodebrew use v16.8.0 //使用するNode.jsバージョン選択
$ node -v //Node.jsのバージョン確認
Node.jsをインストールした時期によってはバージョンの見直しも必要です。今回はv16.8.0で進めます。
インストール
$ cd job/stg/8080-vue/
$ vue create 11-restaurant
vue.jsのバージョンは3でも良いですが、ここでは2で構築していきます。
$ cd 11-restaurant
$ npm i vue-router ress axios
vue-routerとressとaxiosをインストール
$ npm i -D sass-loader@10 sass
sass-loaderとsass。必要なライブラリをインストールします。
vue.jsの設定
初期設定としてファイル編集と新規作成を行います。設定概要
vueの設定ファイルと、ページを表示させるためのコンポーネントを作っていきます。
├── .env.development.local
├── .env.production
├── dist
│ └── vue
│ └── xxx
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── css
│ │ ├── images
│ │ ├── js
│ │ └── scss
│ ├── components
│ │ ├── ChefList.vue
│ │ ├── FooterComponent.vue
│ │ ├── HeaderComponent.vue
│ │ └── Navi.vue
│ ├── main.js
│ ├── router.js
│ └── views
│ ├── PageNotFound.vue
│ ├── Chef.vue
│ ├── Home.vue
│ ├── Insta.vue
│ └── Pages.vue
└── vue.config.js
最終的にこのようなファイル構成になります。部品パーツをcomponentsフォルダに、ページレイアウトファイルをviewsフォルダに入れています。
vueの設定ファイル
以下のファイルを編集します。vue.config.js、router.js、.envは新規で作成します。- vue.config.js
- router.js
- main.js
- .env.xxxxx
/* vue.config.js */
module.exports = {
publicPath: process.env.NODE_ENV === 'production' ? '/vue/' : '/',
outputDir: 'dist/vue', /* ビルドファイルの出力先 */
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true,
devServer: {
port: 8089,
https: false,
},
css: {
loaderOptions: {
scss: {
additionalData: `@import "@/assets/scss/_variables.scss";`
}
}
}
}
特に、前回と同じ設定です。公開用のwebサーバがサブディレクトリになるので、ビルドしたファイル置き場を設定しました。
/* router.js */
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import Home from '@/views/Home'
import Pages from '@/views/Pages'
/**
* ルーティングルールの設定
* path:〜 のURLにアクセスすると component:〜 が表示される
* name:〜 はルートの`名前`。名前付きビューによるルート設定
*/
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/concept', name: 'concept', component: Pages },
{ path: '/contact', name: 'contact', component: Pages },
{ path: '/menu', name: 'menu', component: Pages },
{ path: '/access', name: 'access', component: Pages },
{ path: '/company', name: 'company', component: Pages },
{ path: '/chef', name: 'chef', component: Pages },
{ path: '/chef/:chefName', name: 'chef_page', component: () => import( '@/views/Chef' ), props: true},
{ path: '*', name: 'pageNotFound', component: () => import( '@/views/PageNotFound' ) },
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
ルーティングは、名前付きルーティングを使っています。ナビゲーションのリンク部分で使います。path: '/chef/:chefName'
この : で設定したパラメーター(例では`chefName`)を使った動的ルーティングと呼ばれるものです。
ナビゲーションでの表示はシェフの名前をURLとして使いたいので、リンク先は /chef/hannahや、/chef/georges のように出力します。
パラメータ名は :chefName と設定していますが、chefNameという名前は別に何でも良いです。テンプレート内でこの名前のパラメーターを params: {chefName: c.slug} のような感じで指定します。
また、router.jsの最初の方に`Home`や`Pages`コンポーネントを読み込んでいますが、`Chef`も同じように読み込めばいいのですが、 全コンポーネントを最初に読み込む必要もなく、このシェフページのコンポーネントはシェフページを表示させる時にだけ読み込ませます。 component: () => import( '@/views/Chef' )この書き方は、実際に該当するページが表示されるときに初めて読み込ませる設定です。 通信量を減らす効果があります。
props: trueという引数もつけています。このページで作られたデータを他で使うことになるので必要になります。
/* main.js */
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import Axios from 'axios'
Axios.defaults.baseURL = process.env.VUE_APP_API_ENDPOINT /* .env.***ファイルから変数を取得 */
Vue.prototype.$axios = Axios /* $は、Vueがすべてのインスタンスで使用できるプロパティに使用する規則です。*/
import 'ress'
import '@/assets/scss/main.scss'
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App),
}).$mount('#app')
前回と同じ設定なのですが、
Axios.defaults.baseURL = process.env.VUE_APP_API_ENDPOINT
が増えています。これは、Axiosで使うAPIエンドポイント(APIを取得するURLのこと)を設定しています。 AxiosのbaseURLを何も設定しなければ、コンポーネント内での記述は
$axios.get( 'http://localhost:8003/wp-json/wp/v2/pages?_embed' )
と書けばいいのですが、APIエンドポイント(URL)が開発用のと公開用のとで違う場合、公開するときにはすべてのAPIエンドポイントを変更しなければならず、面倒です。ですので、この2つのAPIエンドポイントの、パラメーターまでのURLを別のファイルに記述し、 process.env.VUE_APP_XXXXX で開発か公開環境かを判断し、APIエンドポイントを自動的に切り分けています。 そうすることで、コンポーネント内では以下のようにAPIエンドポイント以降のパラメーターだけ記述することができるようになります。
$axios.get( '/pages?_embed' )
/* .env.development.local */
NODE_ENV='development'
VUE_APP_API_ENDPOINT='http://localhost:8003/wp-json/wp/v2'
ローカル開発用の設定ファイル
/* .env.production */
NODE_ENV='production'
VUE_APP_API_ENDPOINT='https://xxxxxxx.com/wp/wp-json/wp/v2'
公開用の設定ファイルドットで始まる.envファイルです。environment(環境)の略です。変数名はVUE_APP_XXXXXという命名規則で定義します。 いずれもルートフォルダに入れておきましょう。
2. コンポーネントの作成(部品)
componentsフォルダに入る、部品としてのコンポーネントファイルを作成します。ここで作る部品は、ヘッダー, フッター, ナビゲーションです。
/* HeaderComponent.vue */
<template>
<nav class="gnav">
<div class="wrap">
<div class="gnav__logo">
<router-link :to="{ name: 'home' }"> /* point1 */
<img src="@/assets/images/cbc_logo.svg" alt="logo" /> /* point2 */
</router-link>
</div>
<div class="gnav__nav">
/* point3 */
<div
class="toggle"
:class="{ on: isActive }"
v-on:click="toggleBtn()"
>
<div class="one"></div>
<div class="two"></div>
<div class="three"></div>
</div>
<Navi v-show="isActive"></Navi>
</div>
</div>
/* point3 */
<div class="modal-bg" v-show="isActive" v-on:click="toggleBtn()"></div>
</nav>
</template>
<script>
import Navi from '@/components/Navi.vue'
export default {
name: 'Header',
components: {
Navi,
},
data() {
return {
posts: [],
isActive: false, /* falseの時、v-showはdisplay:noneとなる */
}
},
methods: {
getPosts: function(){
this.$axios
.get( '/pages?_embed' ) /* .envファイルで指定しているので、ローカルも公開もこの記述だけでいけます */
.then(response => (this.posts = response.data))
.catch(error => console.log(error))
},
toggleBtn: function() {
this.isActive = !this.isActive
},
},
created: function(){ /* インスタンスが生成された後、処理が実行される */
this.getPosts()
},
mounted: function(){ /* DOM生成後に実行 */
const gnav = document.querySelector('.gnav'); /* gnavセレクタを取得してgnav変数に代入 */
window.addEventListener('scroll', function(){ /* スクロールしたときのイベント */
if(window.scrollY > 80){
gnav.classList.add('nav-small'); /* 上から80px移動したらgnavセレクタにnav-smallクラスを付与 */
} else {
gnav.classList.remove('nav-small'); /* 上から80px以内ならgnavセレクタからnav-smallクラスを削除 */
}
});
},
}
</script>
<style lang="scss" scoped>
.modal-bg{
height: 100vh;
width: 100%;
position: fixed;
top:0;
left:0;
background: rgba(0, 0, 0, .5);
cursor: pointer;
z-index: 997;
}
.gnav {
z-index: 999;
width: 100%;
position:fixed;
top:0;
background-color:$gnav-color;
transition: all .1s ease 0s;
.wrap{
height:70px;
margin:0 auto;
max-width: 1920px;
padding:0 18px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
&__nav{
width:80px;
margin-right:10px;
display:flex;
justify-content: center;
align-items: center;
position:relative;
&-menu{
@include mq('max','md'){
display:none;
}
}
}
&__logo{
display: flex;
justify-content: center;
align-items: center;
width:100px;
@include mq('max','md'){
align-items: center;
}
img{
height:50px;
transition: all .5s ease 0s;
@include mq('max','md'){
width:40px;
}
}
}
}
.nav-small{
.wrap{
height:50px;
}
.gnav {
&__content{
height:50px;
}
&__logo{
align-items: center;
img{
height:40px;
}
}
}
}
.toggle {
width: 30px;
height: 30px;
cursor: pointer;
z-index: 999;
div {
height: 3px;
background: #fff;
margin: 6px auto;
transition: all 0.5s;
backface-visibility: hidden;
}
&.on .one {transform: rotate(45deg) translate(5px, 5px);}
&.on .two {opacity: 0;}
&.on .three {transform: rotate(-45deg) translate(7px, -8px);}
}
</style>
header部分はHeaderComponent.vueという名前で作ります。Header.vueとしないのは ルール があるからです。 <header>というタグとダブるのを避けるためにWordpressHeader、HeaderRestaurantなどの複数単語で作るのが良いでしょう。
router-link
<router-link :to="{ name: 'home' }">
vue-routerの機能 で、
router-link :to="/xxxx"は、aタグを作ります。いろいろ便利なのでaタグよりこちらを使いましょう。
ルートの指定を名前でおこなっているので、router.jsに書いたroutes変数の{name: xxxx}、に設定した名前を入れます。@はsrcフォルダのこと
<img src="@/assets/images/cbc_logo.svg" alt="logo" />
@はsrcフォルダのことです。メニューの表示、非表示
<div class="gnav__nav">
<div
class="toggle"
:class="{ on: isActive }"
v-on:click="toggleBtn()"
>
<div class="one"></div>
<div class="two"></div>
<div class="three"></div>
</div>
<Navi v-show="isActive"></Navi>
</div>
・・・
<div class="modal-bg" v-show="isActive" v-on:click="toggleBtn()"></div>
class.one, .two, .threeでナビの三本線を作っていて.onクラスが付与されるとcssで、Xマークになるようにアニメーションしています。
v-on:click
ラッパーの.toggleをクリックするとv-on:click="xxxx"と指定しているので、`methods:`オブジェクト内に記述してる メソッドtoggleBtn:を呼び出して実行します。※引数がないのでv-on:click="toggleBtn"と()を書いていないこともありますが、ここでは入れておきます。
data() {
return {
isActive: false, /* 初期設定 */
}
},
methods: {
toggleBtn: function() {
this.isActive = !this.isActive /* クリックされたら逆の真偽値をisActiveに入れる */
},
},
まず`data`オプションに`isActive`という名前をつけたデータプロパティを用意し値にfalseを設定し、真と偽の状態を持つデータを作っておきます。
そうすることによりページが表示されたときに`isActive`が設定されている箇所は`false`になります。なので<Navi>コンポーネントや.modal-bgの初期状態は、 設定している`v-show`の`isActive`はfalseのため、display:noneとなり表示されません。
クリックすると`toggleBtn()`メソッドが起動します。 その時、`isActive`プロパティにtrueかfalseが入っているのですが、 !を入れることで否定の意味になり結果、今入っている値と逆の真偽値が`isActive`プロパティに代入されます。
クリックするたびにtrueとfalseが入れ替わります。これをトグル機能と呼びます。 toggleBtnメソッドにconsole.log(this.isActive);を入れると、クリックしたときの状態が見れます。
:class="{ on: isActive }"
三本線のハンバーガーメニューをcssアニメーションさせるために使います。class="xxxx"は固定でクラスが追加されますが、v-bind:class="xxxx"は条件によって追加したり消したりできます。
v-bind:class="{ クラス名 : trueかfalse}"
trueかfalseでクラスを付けたり消したりします。クリックしたらトグルする`isActive`変数を利用して真偽を指定します。 この例では、クリックされたときに`isActive`がtrueのときに`on`クラスが付与されアニメーションが始まります。falseの時に`on`クラスが消えます。
HeaderComponent.vue内で読み込んでいる、Naviコンポーネントを作成します。
Wordpressの公開済みの、固定ページ一覧をgetPages()メソッドで、カスタム投稿ページ`chef`の一覧をgetPages()メソッドで取得し、それぞれ変数に格納します。
active-class="active"をつけると、アクティブなURLの文字列に.activeクラスをつけることができます。
router-linkは前回も出てきました。aタグが出力されます。
配列データの`p`に入ってるp.slugはAPIで取得したJSONの、キー`slug`に入っている値が出力されます。 wordpressの記事のスラッグに入力した値のことです。
名前つきルートなので`name: p.slug `として名前を指定します。
htmlとして出力されると以下のようになります。
router-linkに新たな引数が追加されています。params: {chefName: c.slug}はパラメーターをもつURLの表示に必要です。
router.jsで設定した内容のとおりです。
htmlとして出力されると以下のようになります。
これを、好みの順番に入れ替えたいと思います。
slice()で配列から一部のデータを取り出しsort()でmenu_orderの番号の昇順(値が小さい順)に並べ替えるという処理です。 menu_orderの値はWordpressのプラグインIntuitive Custom Post Orderをインストールするとドラッグ&ドロップで順序を変更できて便利です。
<template>
<ul class="menu">
<li
v-for="p in pages"
:key="p.id"
>
<router-link :to="{ name: p.slug }" active-class="active">
{{ p.slug }}
</router-link>
</li>
<li>
<ul>
<li
v-for="c in chefs"
:key="c.id"
class="menu__sub"
>
<router-link :to="{ name: 'chef_page', params: {chefName: c.slug} }">
{{ c.slug }}
</router-link>
</li>
</ul>
</li>
</ul>
</template>
<script>
export default {
name: 'navi',
data() {
return {
pages: [],
chefs: []
}
},
methods: {
getPages: function(){
this.$axios
.get( '/pages?_embed' )
.then(response => (this.pages = response.data))
.catch(error => console.log(error))
},
getChefs: function(){
this.$axios
.get( '/chef?_embed' )
.then(response => (this.chefs = response.data))
.catch(error => console.log(error))
}
},
created: function(){ /* vueインスタンス作成後、DOM生成前に実行される */
this.getPages()
this.getChefs()
},
}
</script>
<style lang="scss" scoped>
.menu {
width:200px;
margin: 0.5em 0 !important;
position: absolute;
top:25px;
right:25px;
font: 300 13px Arial, Helvetica;
z-index: 998;
> li {
padding:1em;
background-color: #111;
&:last-child{
padding:0;
}
a{
color:#fff;
}
&:hover{
background-color: #373737;
> a {
color:$point-color;
}
}
> ul {
width:100%;
position: absolute;
right: 0;
z-index: 1;
visibility:visible;
background-color: #373737;
> li {
font-size:12px;
box-shadow: 0 1px 0 #1e1e1e, 0 2px 0 #515151;
a {
padding: 1em;
&:hover {
color:$point-color;
background-color: #666;
}
}
}
}
}
a.active, .router-link-active{
color:$point-color;
}
}
</style>
WP APIを取得してプロパティに保存
data() {
return {
pages: [],
chefs: []
}
},
ナビでは固定ページの一覧と、カスタム投稿一覧と2つ必要になるので、まず`data`オプションでそれぞれ、`pages``chefs`という名前のプロパティに`空の配列`を設定します。
methods: {
getPages: function(){
this.$axios
.get( '/pages?_embed' )
.then(response => (this.pages = response.data)) /* pagesプロパティにAPIで取得したJSONデータを入れる */
.catch(error => console.log(error))
},
getChefs: function(){
this.$axios
.get( '/chef?_embed' )
.then(response => (this.chefs = response.data)) /* chefsプロパティにAPIで取得したJSONデータを入れる */
.catch(error => console.log(error))
}
},
`methods`オプションに、WP APIにアクセスしてJSONを取得するメソッドを設定します。Wordpressの公開済みの、固定ページ一覧をgetPages()メソッドで、カスタム投稿ページ`chef`の一覧をgetPages()メソッドで取得し、それぞれ変数に格納します。
created: function(){
this.getPages()
this.getChefs()
},
`created`フックにメソッドを設定すると、DOM生成(html文章を作る作業)前に、すぐさま実行されます。JSONデータを取得できます。
データをテンプレート内で出力
<li
v-for="p in pages"
:key="p.id"
>
<router-link :to="{ name: p.slug }" active-class="active">
{{ p.slug }}
</router-link>
</li>
①固定ページのデータactive-class="active"をつけると、アクティブなURLの文字列に.activeクラスをつけることができます。
router-linkは前回も出てきました。aタグが出力されます。
配列データの`p`に入ってるp.slugはAPIで取得したJSONの、キー`slug`に入っている値が出力されます。 wordpressの記事のスラッグに入力した値のことです。
名前つきルートなので`name: p.slug `として名前を指定します。
htmlとして出力されると以下のようになります。
<h2><a class="" data-v-xxxxxxx="" href="/chef">chef</a></h2>
<h2><a class="" data-v-xxxxxxx="" href="/menu">menu</a></h2>
<li
v-for="c in chefs"
:key="c.id"
class="menu__sub"
>
<router-link :to="{ name: 'chef_page', params: {chefName: c.slug} }">
{{ c.slug }}
</router-link>
</li>
②カスタム投稿ページrouter-linkに新たな引数が追加されています。params: {chefName: c.slug}はパラメーターをもつURLの表示に必要です。
router.jsで設定した内容のとおりです。
htmlとして出力されると以下のようになります。
<li><a class="" data-v-xxxxxxx="" href="/chef/hannah">hannah</a></li>
<li><a class="" data-v-xxxxxxx="" href="/chef/georges">georges</a></li>
※メニューの順序を変えてみるカスタマイズ
APIで取得したデータはWordpressで登録した順に並べられます。これを、好みの順番に入れ替えたいと思います。
computed: {
sortMenu() {
return this.pages.slice().sort((a, b) => {
return a.menu_order - b.menu_order /* 昇順にするという処理 */
});
}
}
script内にcomputed:でsortMenu()メソッドを作ります。slice()で配列から一部のデータを取り出しsort()でmenu_orderの番号の昇順(値が小さい順)に並べ替えるという処理です。 menu_orderの値はWordpressのプラグインIntuitive Custom Post Orderをインストールするとドラッグ&ドロップで順序を変更できて便利です。
<li
v-for="p in sortMenu"
:key="p.id"
>
APIで取得したデータを格納したpagesをループさせるのではなく、computed:で作ったでsortMenu()メソッドに変更します。
sortMenu()メソッドにはthis.pagesを昇順で並び替える処理をするように設定されています。
※APIの取得データをカスタマイズ
this.$axios
.get( '/pages?_fields=id,slug,title,menu_order' )
_fields=でカンマ区切りで取得したい情報だけを選ぶことができる
<template>
<div>
<footer class="footer">
<div class="footer__profile">
<div>
<h2><img src="@/assets/images/cbc_logo.png" alt="logo" /></h2>
</div>
</div>
<div class="footer__contact">
<div>
<h2
v-for="p in pages"
:key="p.id"
>
<router-link :to="{ name: p.slug }">
{{ p.slug }}
</router-link>
</h2>
</div>
</div>
</footer>
<div class="copyright">
<p>© copyright</p>
</div>
</div>
</template>
<script>
export default {
name: 'Footer',
data() {
return {
pages: [],
}
},
methods: {
getPages: function(){
this.$axios
.get( '/pages?_embed' )
.then(response => (this.pages = response.data))
.catch(error => console.log(error))
},
},
created: function(){
this.getPages()
},
}
</script>
<style lang="scss" scoped>
.footer{
display:flex;
align-items: center;
background: #39383C;
color:#fff;
a:link,
a:visited {
color: #eee;
text-decoration: none;
}
&__profile {
display:flex;
justify-content: center;
flex:5;
text-align:center;
height:400px;
padding:20px;
background:url("../assets/images/footer_bg.jpg")no-repeat center center/cover;
> div{
display:flex;
justify-content: center;
flex-direction:column;
}
}
&__contact{
flex:3;
text-align:center;
padding:20px;
}
h2{
font-size:1.2em;
font-weight:300;
line-height:3;
}
.sns-wrap{
margin-top:30px;
}
li {
display: inline-block;
margin: 5px;
}
i{
font-size:3em;
margin-right:40px;
}
}
.copyright {
padding:10px;
height:40px;
color:#fff;
font-size: 0.8em;
text-align:center;
background-color: rgba(0, 0, 0, 0.8);
}
</style>
HeaderComponent.vueやNavi.vueで使った手法のみですね。もう読み解ける思います。
シェフページの下に入れる一覧部分のコンポーネントです。
親コンポーネントである`Chef.vue`のループ内で、子コンポーネントにデータを送る設定をしているので、
例ではオブジェクト型を受けていますが、
親側では`for-child`という属性名を付けて、何かしらの文字列をvalueというデータで保持します。
子側ではpropsで`forChild`を指定し、テンプレート内で使用します。
excerpt_mblength()メソッドという名前で文字を120文字で切って、...を入れるという処理を三項演算子で書いています。
公式:フィルターの使い方
<template>
<div class="list__item">
<router-link :to="{ name: 'chef_page', params: {chefName: forChild.slug} }">
<div class="list__item-image" :style="{ 'background-image': 'url('+ forChild.bgimage +')' }"></div>
<p v-if="forChild.title">{{ forChild.title }}</p>
</router-link>
<p v-html="$options.filters.excerpt_mblength(forChild.content)"></p>
</div>
</template>
<script>
export default {
name: 'cheflist',
props: {
forChild: {
type: Object,
required: true,
}
},
filters: { /* テキストを整形するためのオプション */
excerpt_mblength(text) {
return text.length > 120 ? text.slice(0, 120) + "[…]" : text; /* 120文字目以降は … にする */
},
}
}
</script>
<style lang="scss" scoped>
.list__item{
flex:3;
@include mq('max','lg'){
display:block;
margin-top:40px;
}
p{
display:block;
margin-top:20px;
width:90%;
}
&-image{
width:300px;
height:300px;
margin:10px 0;
background-repeat:no-repeat;
}
}
</style>
親コンポーネントから子コンポーネントに値を渡す
あとにでてくる`Chef.vue`で記述してもよいのですが、他のページでも使い回せる可能性もあるのでコンポーネントを分けています。親コンポーネントである`Chef.vue`のループ内で、子コンポーネントにデータを送る設定をしているので、
props: {
forChild: { /* 親コンポーネント側で設定している`属性名`を記載します */
type: Object, /* ArrayとかObjectとか受けるデータを指定します */
required: true, /* データの受け渡しを必須にする指定 */
}
},
このように子コンポーネント側ではprops:で`forprops`という名前で受け取ることができるようになります。例ではオブジェクト型を受けていますが、
/* 親 */
<child-component :for-child="value">
/* 子 */
props: {
forChild: {
type: String
}
}
文字列はこのように受けます。親側では`for-child`という属性名を付けて、何かしらの文字列をvalueというデータで保持します。
子側ではpropsで`forChild`を指定し、テンプレート内で使用します。
特徴
- 親側の属性名はケバブケース(文字を`-`ハイフンでつなぐ記法)で書いて、子側ではキャメルケースで記述します。
- 子側ではpropsのデータの書き換えはできません。
フィルターオプション
filters: { /* テキストを整形するためのオプション */
excerpt_mblength(text) {
return text.length > 120 ? text.slice(0, 120) + "[…]" : text;
},
}
テキストを変更するときに使います。excerpt_mblength()メソッドという名前で文字を120文字で切って、...を入れるという処理を三項演算子で書いています。
公式:フィルターの使い方
3. コンポーネントの作成(レイアウト)
viewsフォルダに入る、ページレイアウトとしてのコンポーネントファイルを作成します。
<template>
<div class="home">
<section class="logo">
<h1><img src="@/assets/images/cbc_logo_white.svg" alt=""></h1>
</section>
<section class="philosophy">
<div class="philosophy__left">
<div>
<h2 class="philosophy__left-title">PHILOSOPHY</h2>
<p class="philosophy__left-top">
たまには、贅沢を<br>
たまには、わがままを<br>
たまには、ごちそうを
</p>
<p class="philosophy__left-bottom">
その日仕入れた新鮮な食材を、お好きな調理法で、<br>
ジャンルを超えた様々な料理をおとどけいたします。<br>
美容や健康のもととなるお食事を、<br>
楽しく、おいしくお召し上がりください。
</p>
<div class="philosophy__button">
<a href="#">MORE</a>
</div>
</div>
</div>
<div class="philosophy__right"></div>
</section>
<section class="news">
<h2 class="news__title">NEWS<span>今日のおすすめ食材などニュースを発信</span></h2>
<ul class="news__list">
<li>
<a href="#">
<div class="news__list-img">
<img src="@/assets/images/news01.jpg" alt="">
</div>
<div class="news__list-txt">
<dl>
<dt>夏季休業のお知らせ</dt>
<dd>Jul 18,2019</dd>
</dl>
</div>
</a>
</li>
<li>
<a href="#">
<div class="news__list-img">
<img src="@/assets/images/news02.jpg" alt="">
</div>
<div class="news__list-txt">
<dl>
<dt>夏季休業のお知らせ</dt>
<dd>Jul 18,2019</dd>
</dl>
</div>
</a>
</li>
<li>
<a href="#">
<div class="news__list-img">
<img src="@/assets/images/news03.jpg" alt="">
</div>
<div class="news__list-txt">
<dl>
<dt>夏季休業のお知らせ</dt>
<dd>Jul 18,2019</dd>
</dl>
</div>
</a>
</li>
</ul>
<div class="news__button">
<a href="#">MORE</a>
</div>
</section>
<section class="instagram">
<h2 class="instagram__title">INSTAGRAM</h2>
<!-- do_shortcode('[top_instagram]'); を設定していたところ -->
<ul class="instagram__list">
<li
v-for="d in posts"
:key="d.id"
>
<router-link :to="d.slug">
<div class="instagram__list-img" v-html="d.content.rendered"></div>
</router-link>
</li>
</ul>
<div class="instagram__button">
<a href="">FOLLOW US</a>
</div>
</section>
<section class="access">
<h2 class="access__title">ACCESS</h2>
<div class="access__map">
<iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3241.66553052196!2d139.66499671495762!3d35.66061138019912!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6018f36bdb70683b%3A0x243faef2e21a3270!2z44CSMTU1LTAwMzEg5p2x5Lqs6YO95LiW55Sw6LC35Yy65YyX5rKi77yS5LiB55uu77yR77yZ4oiS77yV!5e0!3m2!1sja!2sjp!4v1569944678065!5m2!1sja!2sjp" width="600" height="450" frameborder="0" style="border:0;" allowfullscreen=""></iframe>
</div>
<div class="access__detail">
<dl>
<dt>住所</dt>
<dd>〒155-0031 東京都世田谷区北沢2丁目19−5</dd>
</dl>
<dl>
<dt>アクセス</dt>
<dd>
<div>井の頭線「下北沢駅」東口より徒歩1分</div>
<div>小田急小田原線「下北沢駅」東口より徒歩1分</div>
</dd>
</dl>
<dl>
<dt>営業時間</dt>
<dd>平日:10:30〜19:30</dd>
</dl>
<dl>
<dt>定休日</dt>
<dd>土曜・日曜・祝日</dd>
</dl>
</div>
</section>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
posts: []
}
},
methods: {
getPosts: function(){
this.$axios
.get( '/posts?_embed' ) /* 投稿記事一覧を取得しています */
.then(response => (this.posts = response.data))
.catch(error => console.log(error))
}
},
created: function(){
this.getPosts()
},
}
</script>
<style lang="scss" scoped>
.home{
width:100%;
}
.logo{
color:$mainColor;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 80vh;
position: relative;
background: url("../assets/images/mv.jpg") center/cover no-repeat;
@include mq('max','md'){
height: 467px;
}
h1 {
width: 150px;
}
}
.philosophy {
display: flex;
height: 680px;
background-color: #242424;
@include mq('max','md'){
flex-direction: column;
}
&__left,
&__right{
width: 50%;
@include mq('max','md'){
width: 100%;
}
}
&__right {
background: url("../assets/images/philosophy_bg.jpg") center/cover no-repeat;
@include mq('max','md'){
height: 230px;
}
}
&__left {
display: flex;
justify-content: center;
align-items: center;
padding: 0px 10px;
@include mq('max','md'){
padding: 0 20px;
width: 100%;
height: 450px;
}
&-top,
&-bottom {
line-height: 1.6;
}
&-title {
color: #fff;
font-size: 24px;
@include mq('max','md'){
text-align: center;
font-size: 16px;
}
}
&-top {
margin-top: 40px;
color: #fff;
@include mq('max','md'){
font-size: 12px;
line-height: 1.8;
}
}
&-bottom {
margin-top: 20px;
color: #fff;
@include mq('max','md'){
font-size: 12px;
line-height: 1.8;
}
}
}
&__button {
margin-top: 50px;
border: 1px solid #fff;
width: 180px;
@include mq('max','md'){
width: 100%;
}
a {
text-align: center;
padding: 16px 0;
font-size: 14px;
color: #fff;
&:hover {
color: #242424;
background: #fff;
}
}
}
}
.news {
background: #F0F0F0;
padding: 100px 0;
@include mq('max','md'){
padding: 50px 0;
}
&__title {
width: 90%;
margin: 0 auto;
font-size: 24px;
@include mq('max','md'){
font-size: 16px;
display: flex;
flex-direction: column;
text-align: center;
}
span {
font-size: 16px;
margin-left: 16px;
@include mq('max','md'){
font-size: 12px;
margin-top: 10px;
}
}
}
&__list {
width: 90%;
margin: 0 auto;
display: flex;
margin-top: 40px;
@include mq('max','md'){
flex-wrap: wrap;
}
li {
width: calc(100% / 4 - 15px);
@include mq('max','md'){
width: calc(100% / 2 - 2%);
&:nth-child(odd) {
margin-right: 4%;
}
&:nth-child(n+3) {
margin-top: 24px;
}
}
&:not(:last-child) {
margin-right: 30px;
@include mq('max','md'){
margin-right: 0;
}
}
}
&-txt {
margin-top: 10px;
font-size: 14px;
dd {
margin-top: 10px;
}
}
}
&__button {
border: 1px solid #242424;
width: 180px;
margin: 60px auto 0;
@include mq('max','md'){
width: 90%;
}
a {
text-align: center;
padding: 16px 0;
font-size: 14px;
color: #242424;
&:hover {
color: #fff;
background: #242424;
}
}
}
}
.instagram {
background: #fff;
padding: 100px 0;
@include mq('max','md'){
width: 100%;
padding: 50px 0;
}
&__title {
width: 90%;
margin: 0 auto;
font-size: 24px;
@include mq('max','md'){
display: flex;
flex-direction: column;
text-align: center;
font-size: 16px;
}
span {
font-size: 16px;
margin-left: 16px;
@include mq('max','md'){
margin-top: 10px;
font-size: 12px;
}
}
}
&__list {
width: 90%;
margin: 0 auto;
display: flex;
margin-top: 40px;
@include mq('max','md'){
flex-wrap: wrap;
&:nth-child(odd) {
margin-right: 4%;
}
&:nth-child(n+3) {
margin-top: 24px;
}
}
li {
width: calc(100% / 4 - 15px);
@include mq('max','md'){
width: calc(100% / 2 - 2%);
}
&:not(:last-child) {
margin-right: 30px;
@include mq('max','md'){
margin-right: 0;
}
}
}
&-img {
>>>p {
padding-top: 5px;
font:normal 0.7em/1.3em sans-serif;
}
}
}
&__button {
border: 1px solid #242424;
width: 180px;
margin: 60px auto 0;
@include mq('max','md'){
width: 90%;
}
a {
text-align: center;
padding: 16px 0;
font-size: 14px;
color: #242424;
&::hover {
color: #fff;
background: #242424;
}
}
}
}
.access {
background: #f0f0f0;
padding: 100px 0;
@include mq('max','md'){
padding: 50px 0;
}
&__title {
text-align: center;
font-size: 24px;
}
&__map {
margin-top: 50px;
iframe {
width: 100%;
}
}
&__detail {
width: 50%;
margin: 0 auto;
margin: 40px auto 0;
@include mq('max','md'){
width: 80%;
}
dl {
display: flex;
@include mq('max','md'){
flex-direction: column;
}
&:not(:first-child) {
margin-top: 30px;
@include mq('max','md'){
margin-top: 40px;
}
}
dt {
flex: 1;
font-size: 14px;
@include mq('max','md'){
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
}
}
dd {
flex: 2;
font-size: 14px;
div:not(:first-child) {
margin-top: 10px;
}
}
}
}
}
</style>
cssが多いですが、コピペしてファイルを作りましょう。基本的にはwordpressで作ったときと同じものです。(背景画像のパスが違います)もうひとつは.instagramセレクタの箇所で>>>pとかいてあります。
※ディープセレクタ
&-img {
>>>p {
padding-top: 5px;
font:normal 0.7em/1.3em sans-serif;
}
}
親コンポーネントに<style lang="scss" scoped>とscoped属性を指定した場合は、読み込まれるデータや子コンポーネント内の子要素セレクタにはcssは適応されません。
scopedを設定しているため、親コンポーネントに記載しているセレクタにしか適応されないためです。公式:スコープ付きcss
ですので、v-htmlや読み込まれる子コンポーネント内で使われている子要素セレクタを親コンポーネント内で指定するときには、直前に>>>をつけましょう
※sassの一部のプリプロセッサは >>> を認識しないので、>>>のエイリアス、/deep/や::v-deepを使うこともあります
ここのHome.vueでは、
<div class="instagram__list-img" v-html="d.content.rendered">
とかいてあるので.instagram__list-imgまでしかcssが適応されません。
<template>
<div class="wp_pages">
<template v-for="page in pages">
<div
v-if="contentsName === page.slug"
:key="page.id"
>
<h1>{{ page.title.rendered }}</h1>
<div v-html = "page.content.rendered"></div>
</div>
</template>
</div>
</template>
<script>
export default {
name: 'Pages',
data() {
return {
pages: [],
contentsName: '',
}
},
methods: {
getPages: function(){
this.$axios
.get( '/pages?_embed' )
.then(response => (this.pages = response.data))
.catch(error => console.log(error))
}
},
created: function(){
this.contentsName = this.$route.name /* パスを取得しcontentsNameに代入 */
this.getPages()
},
watch: {
$route(to) {
this.contentsName = to.name; /* 新しいルート名を変数に代入 */
}
},
}
</script>
<style lang="scss" scoped>
.wp_pages {
margin: 150px auto;
padding: 30px 0 20px 0;
width:700px;
min-height:40vh;
color: #000;
font-weight:300;
font-size:17px;
line-height: 1.6;
@include mq('max','md'){
width:95%;
margin:100px 10px 0;
}
}
</style>
ページを遷移するルーティングの際に、一工夫必要です。- 全記事のJSONデータを取得し、表示したい`URL`のデータ(menuやcompanyなどの記事)を一つだけ選んで表示させる
- `URL`を直接書き換えたりナビボタンからリンクを押したとき、新しい`URL`へ移動と同時に新しい`URL`名をデータに保存
- 移動したことを検知して、その新しい`URL`の記事データに書き換える
01)
<template v-for="page in pages">
<div
v-if="contentsName === page.slug"
:key="page.id"
>
<h1>{{ page.title.rendered }}</h1>
<div v-html = "page.content.rendered"></div>
</div>
</template>
全記事のJSONデータをv-forでループさせ、v-ifで`URL`と同じ名前のスラッグ記事のデータだけをv-htmlを使って表示させています。02)
data() {
return {
pages: [],
contentsName: '', /* ←保存用データを用意します */
}
},
リンクする先の`URL`名を保存したいのでcontentsNameというデータを用意します。初期設定は空にします。
created: function(){
this.contentsName = this.$route.name
this.getPages()
},
created:オプションで、contentsNameに現在の`URL`名(name)を保存させます。menuページを表示しているときには`menu`という文字がcontentsNameに入ります。
vue-routerの機能により、this.$route.xxxxxxと指定することで、 現在のURLのデータであるrouteオブジェクトが取得できます。($route.path、$route.params、$route.queryなど) this.$route.nameの`name`は、router.jsで設定している名前付きルート
{ path: '/concept', name: 'concept', component: Pages },
のname:'concept'の、`concept`をあらわします。03)
watch: {
$route(to) {
console.log(to); /* 確認用 */
this.contentsName = to.name; /* 新しい`URL`名(name)をデータに代入 */
}
},
プログラム的な動きとしては`$route`オブジェクトを`watch`する方法でページを表示するということになります。公式:パラメータ変更の検知
<template>
<div class="wp_chef">
<main class="main">
<template v-for="(data, index) in datas">
<div
v-if="chefName === data.slug"
:key="index"
class="chef"
>
<div class="chef__img">
<template v-if="data.thumb"><!--アイキャッチ画像がある場合に出力-->
<div>
<p><img :src="data.thumbnail"></p>
</div>
</template>
</div>
<div class="chef__txt">
<h1>{{ data.title }}</h1>
<div v-html="data.content"></div>
<h2>{{ data.slug }}</h2>
</div>
</div>
</template>
</main>
/* 一覧ページの表示箇所 */
<div class="list">
<div class="list__logo"><img src="@/assets/images/menu.png" alt=""></div>
<div class="list__wrap">
<chef-list v-for="(d, i) in datas" :key="i" :for-child="d"/>
</div>
</div>
</div>
</template>
<script>
import ChefList from '@/components/ChefList'
export default {
name: 'chef',
props: ['chefName'], /* router.jsでprops:trueにしたパラメータ(:)をpropsで渡している状態 */
components: {
'chef-list':ChefList
},
data() {
return {
datas: [],
}
},
methods: {
getPosts: function(){
this.$axios
.get( '/chef?_embed' )
.then( function(response) {
response.data.forEach( (d) => {
let arr = {
slug: d.slug,
title: d.title.rendered,
content: d.content.rendered,
thumb: d._embedded["wp:featuredmedia"],
thumbnail: d._embedded["wp:featuredmedia"][0].source_url,
bgimage: d._embedded['wp:featuredmedia'][0].media_details.sizes.medium.source_url
}
this.datas.push(arr)
})
})
.catch( error => console.log(error) )
},
},
created: function(){
this.getPosts()
},
watch: {
/* 動的なURLはライフサイクルフックが呼ばれないので、URLの変更で何か処理をさせたい場合は、watchで$routeを監視する。*/
$route(to) {
this.chefName = to.params.chefName; /* 新しいルートパラメーター代入 */
}
},
}
</script>
<style lang="scss" scoped>
.wp_chef{
width:95%;
margin:70px auto 0;
@include mq('min','lg'){ /* 1024px以上 */
width:1024px;
}
.main{
padding:10px;
font:normal 1.2em/2 sans-serif;
time{
font:normal 1.1em/2 serif;
}
h1{
margin-bottom:20px;
color:#666;
font:bold 2em/2 sans-serif;
a{
color:#666;
@include mq('max','lg'){
font-size:0.7em;
}
}
}
h2{
display:inline-block;
font:bold 1.2em/2 sans-serif;
}
.category{
display:inline-block;
padding:0px 20px;
border: 1px solid #ccc;
}
.chef {
display:flex;
margin:10px;
font:normal 0.9em/2.4 serif;
&__img{
flex:1;
margin-right:2em;
}
&__txt{
flex:1;
h1{
font-size:1.8em;
a{
color:$title-color;
}
}
h2{
width:100%;
display:inline-block;
text-align:right;
color:$title-color;
}
}
}
}
.list{
margin:20px auto 100px;
&__logo{
img{
margin:20px auto;
}
}
&__wrap{
display:flex;
}
}
}
</style>
Chef.vueは表示する内容が細かいので多少複雑になりますが、コードの組み立て方はPages.vueと同様です。今までと違う点
- JSONデータの各記事のそれぞれの`名前`を、URLにする設定
- ちょっと趣向を変えてデータを表示させてみる
- 一覧ページを表示する
01)
router.jsでchefページのルーティング設定を以下のようにしました。(改行してます)
{ path:
'/chef/:chefName',
name: 'chef_page',
component: () => import( '@/views/Chef' ),
props: true
},
props: trueとすることで:chefNameパラメータを
`routerオブジェクト`の中に入れることができます。
props: ['chefName'],
コンポーネント側でpropsを受けれるように、props: ['パラメータ名']で設定します。そうすることで、
watch: {
$route(to) {
this.chefName = to.params.chefName;
}
},
URLに変化があった時to.params.chefNameで新しいURLのパラメーターを取得することができ
`chefName`に代入し、URLの変更とループ内のv-ifで記事データの変更を実行しています。公式:propsオプションの使い方
02)
.then( (response) => {
response.data.forEach( (d) => { /* 配列名.forEach( コールバック関数(要素の値) ) */
let arr = {
slug: d.slug,
title: d.title.rendered,
content: d.content.rendered,
thumb: d._embedded["wp:featuredmedia"],
thumbnail: d._embedded["wp:featuredmedia"][0].source_url,
bgimage: d._embedded['wp:featuredmedia'][0].media_details.sizes.medium.source_url
}
this.datas.push(arr) /* 配列名.push(変数名) */
})
})
今までと違って処理が成功したときの.then()内で処理することがだいぶ増えています。
.then(response => (this.datas = response.data))
いままではこれで処理していたのですが、APIで取得したJSONデータresponse.dataを、
この段階で処理して、タイトルやスラッグなどそれぞれのデータに名前をつけるという作業をしています。
v-forループでデータを出力するときに長いデータだと
d._embedded['wp:featuredmedia'][0].media_details.sizes.medium.source_url
と、すごい長くなってしまいます。2回使うこともあればさすがに見にくいですね。名前をつけておくとシンプルになります。
取得したJSONデータを加工して使う場合などに使う手法です。
このサンプルでは上記のデータをbgimageという名前で記述できるようにしています。
data() {
return {
datas: [], /* 空の配列を用意しときます */
}
},
まず`datas`という空の配列データを用意しておきます。
let arr = {
slug: d.slug,
....
}
this.datas.push(arr)
JSONデータの名前を変更したデータを、変数arrに入れます。
その後pushを使って`response.data`を新たな`datas`オブジェクトとして作成し直しました。
v-forではこのデータをループさせて使っていきます。一覧ページの表示箇所
<chef-list v-for="(d, i) in datas" :key="i" :for-child="d"/>
ChefList.vueでも説明しましたが、JSONデータ`datas`をループするときにできる配列データ`d`をv-bind:属性名="値"として、
子コンポーネントで受け取れるように設定しています。(v-bindは省略可能)
<template>
<div class="wp_pages">
<h1>404: Page Not Found</h1>
</div>
</template>
<script>
export default {
name: 'NotFound'
}
</script>
<style lang="scss" scoped>
.wp_pages {
margin: 150px auto;
padding: 30px 0 20px 0;
width:700px;
color: #000;
@include mq('max','md'){
width:95%;
margin:100px 10px 0;
}
h1 {
font: 900 3.5em/1.6 'Nunito Sans', Arial, Helvetica, sans-serif;
}
}
</style>
/* router.js */
const routes = [
・・・
{ path: '*', name: 'pageNotFound', component: () => import( '@/views/PageNotFound' ) },
]
すでにrouter.jsで設定したルーティング設定に当てはまらないものをすべて404ページに遷移してもらいます。
path: '*'の*はすべてのページという意味です。ただしこれは静的なページにのみ有効で、パラメーターを付けた動的ルーティングである/chef/xxxxという 存在しないページのときには404ページは表示されません。
そんなときはVueRouterのナビゲーションガードという機能を使います。
ガードとは門番とか監視するという意味であり、拒否するという意味ではないです。現在のページから遷移するときの様々なタイミングで処理を実行させます。
- グローバル: beforeEach, afterEach
- ルート単位: beforeEnter
- コンポーネント単位: beforeRouteEnter, beforeRouteUpdate など
公式:ナビゲーションガード
今回は動的ルーティングを設定しているChef.vueファイルを編集します。
何らかの方法(アドレス直打ちや、リンクなど)でアクセスされたURL(パラメータ名`chefName`)データが、取得したJSONデータの中に該当しなければ ナビゲーションガードを使い、描画される前に判定し、描画時にPageNotFoundコンポーネントを表示させるというロジックです。
手順としては
- PageNotFoundコンポーネントを読み込み、テンプレートに追記
- `beforeRouteEnter`ガードを使って、画面が描画される前にaxiosでJSONを取得する。
- `.then`にJSON取得が成功したときの処理を書く。
if文を使って、JSONの配列データの中に、`chefName`に入ってるアクセスされたパラメータの文字列があるかどうか判断。
入っていない場合(false)、つまりデータのないURLにアクセスされた場合、PageNotFoundコンポーネントで描画
入っていた場合(true)、正規ページとして通常のコンポーネントで描画 - エラーページから、正規ページへのリンクがクリックされた場合の処理
<main class="main">
<PageNotFound v-if="notfound"/> /* 追加 `notfound`データがtrueのときにPageNotFoundコンポーネントが描画される */
<template v-else> /* 追加 `notfound`データがfalseのときに描画される */
<template v-for="(data, index) in datas">
<div
v-if="chefName === data.slug"
:key="index"
class="chef"
>
<div class="chef__img">
<template v-if="data.thumb"><!--アイキャッチ画像がある場合に出力-->
<div>
<p><img :src="data.thumbnail"></p>
</div>
</template>
</div>
<div class="chef__txt">
<h1>{{ data.title }}</h1>
<div v-html="data.content"></div>
<h2>{{ data.slug }}</h2>
</div>
</div>
</template>
</template> /* 追加 */
</main>
templateの<main>部分です。3行追加します。テンプレートはこれまでのものと404用の2つがあり、v-ifのデータの真偽で条件分岐させます。
notfoundというデータを用意し、trueならPageNotFoundコンポーネントを表示させ、falseならその下の`v-else`のテンプレートが描画されます。
notfoundは初期設定ではfalseにしておき、正規でないページ判定のときにtrueになるように<script>部分で設定します。
<script>
import ChefList from '@/components/ChefList2'
import PageNotFound from '@/views/PageNotFound' /* 追加① */
import axios from "axios" /* 追加③-1 */
export default {
name: 'chef',
props: ['chefName'],
components: {
'chef-list': ChefList,
PageNotFound, /* 追加① */
},
data() {
return {
datas: [],
notfound: false, /* 追加② */
}
},
methods: {
変更なし
},
created: function(){
this.getPosts()
},
watch: {
$route(to) {
this.notfound = false /* 追加④ */
this.chefName = to.params.chefName;
}
},
/* 追加③ 判定処理をいれてコンポーネントを描画したいので`beforeRouteEnter`を使う */
beforeRouteEnter(to, from, next){
axios /* ③-1 */
.get( '/chef?_embed' )
.then( (res) => {
/* ③-2 some()メソッド: 検索対象の配列の中に一つでも同じものがあれば即座にtrueを返す。*/
if (res.data.some(d => d.slug === to.params.chefName) === false) {
next(vm => { /* ③-3 引数の`vm`で`this`のようにコンポーネントインスタンスにアクセスできる */
vm.notfound = true; /* notfoundデータを`true`にする */
});
}
next() /* ③-4 正規ページの場合、通常のテンプレートが表示される */
})
.catch(error => console.log(error))
},
}
</script>
<script>部分です①
components:オプションにPageNotFoundコンポーネントの読み込み設定します。②
notfoundデータがtrueになるとPageNotFoundコンポーネントが描画されます。初期値はfalseです。③
ナビゲーションガードを使って描画前にデータを取得して判定させます。③-1
beforeRouteEnterガードはページが描画される前に処理されるので、ここではthisが使えないため`this$axios`が使えません。 このガード用にaxiosをimportして読み込みます。③-2
if (res.data.some(d => d.slug === to.params.chefName) === false) {
axiosの取得結果データであるres.dataをsome()メソッドで判定させます。引数の`d`は、関数内で使うthis(`res.data`を指す)の名前を指定しています。名前は何でも良いです。 アロー(=>)の右側にあるコールバック関数(ここでは比較する処理の無名関数)が実行され真偽の結果が返ります。
パラメータになるJSONデータ内の`d.slug`に、入力されたパラメータ`to.params.chefName`が存在するのかを確認し、あればその場でtrue、最後までなければfalseが返ります。
③-3
ナビゲーションガードはナビゲーションを停止させている状態なので、このあとどのように進むかを必ず指定しなければいけません。
next(vm => {
vm.notfound = true;
});
next()メソッドで先に進むことができます。そのnext()メソッドに引数を入れて、進む前にひと処理いれます。
if文がfalseだったときに、こまでfalseだった`notfound`データにtrueを代入させます。
そうすることでnext()で進んだときテンプレートの<PageNotFound>の方が描画されるということになります。
③-4
if文がtrueだった場合に、ただ進みます。
next();
進むと、このコンポーネントのライフサイクルが実行され、ここではcreated:()オプションが実行されます。
$axios→API→JSON→コンポーネント描画...と進んでいきます。
④
ここでは`notfound`データを`false`を代入して初期化しています。
this.notfound = false
これを設定していないと、URL/chef/xxxxxでアクセスして404コンポーネントが表示されている状態で
正規ページ(例えば/chef/hannah)にアクセスしても404表示になります。これは`notfound`データがtrueのままになっているためです。ですのでwatchプロパティで、ルーティングするときに必ずtrueをfalseにしてリセットさせておきます。
4. アニメーションの設定
画面遷移があまりにも早く、移動したことさえ気づかなく味気ないので、アニメーションさせてみようと思います。 <transition>タグでラッピングした要素がアニメーションされます。公式:トランジション
4つのファイルを改修していきます。
HeaderComponent.vue
テンプレート内
<transition name="fade">
<Navi v-show="isActive"></Navi>
</transition>
scss内
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
Home.vueにある<Navi>タグを<transition>タグでラッピングします。複数transitionがあった場合の対処方法としてクラス名を変更することがあります。`name`属性で`fade`を指定しています。 これでクラス名が.v-enter-activeでなく.fade-enter-activeとなります。 cssでは新しいクラスで指定します。
Chef.vue
<transition-group tag="div">
<template v-for="data in datas">
<div v-if="chefName === data.slug" :key="data.id" class="chef">
・・・
</div>
</template>
</transition-group>
v-forなどで、複数の要素が出力される場合でアニメーションさせるときには<transition-group>を使います。中の要素は:keyをもつ必要があります。tag="div"は、<transition-group>タグが描画されたときのタグの種類を指定できます。この例ではdivタグで描画されます。何も指定しないと<span>タグで出力されます。
またmode属性は使えません。
Pages.vue
<transition-group tag="div">
<template v-for="page in pages">
・・・
</template>
</transition-group>
Chef.vueでつかった<transition-group>タグと同様です。
App.vue
<transition mode="out-in">
<router-view/>
</transition>
<router-view/>をラッピングします。main.scss
.v-enter-active, .v-leave-active {
transition: all .5s ease-in-out;
}
.v-leave-active {
position: absolute;
}
.v-enter, .v-leave-to {
transition: all .2s ease-in-out;
transform: translateX(10px);
opacity: 0;
}
scssファイルの最後に追記しておきます。name属性を設定していない<transition-group>などの要素用です。かんたんなアニメーションの設定です。
公式ページなどではいろいろなアニメーション方法が書いてありますので、思い通りの動きに作り変えてみましょう。
5. ローディングの設定
ローディング画面を作成するときには`vue-loading-overlay`ライブラリを使います。vue-loading-overlay マニュアル
$ npm i vue-loading-overlay@3.4.2
インストールします。※このときのバージョンは3.4.2でした。
main.js
import Loading from 'vue-loading-overlay'
import 'vue-loading-overlay/dist/vue-loading.css'
Vue.use(Loading)
Vue.component('loading', Loading) /* テンプレートで使う名前 */
vue-loading-overlayを読み込んで設定します。Axiosの下辺りで良いと思います。App.vue
<div id="app">
<loading
:active="isLoading" /* データが`true`の場合に指定のローディングを表示させます */
:can-cancel=true /* 画面をクリックするとキャンセルできる */
:is-full-page=true /* 全画面にローディングを表示させる */
loader="dots" /* ローダーの種類(spinner or dots or bars) */
></loading>
<HeaderComponent />
<transition mode="out-in">
<router-view/>
</transition>
<FooterComponent />
・・・
<loading>タグで、読み込んだloading設定を記述します。
data() {
return {
isLoading: true /*loadingは最初は有効*/
}
},
・・・
mounted() { /*DOMが作成された直後*/
setTimeout(() => {
this.isLoading = false
}, 2000);
},
続いてmountedメソッドで画面が表示されてから2,000ミリ秒(2秒)経過したらloadingをfalseにして消す設定をしています。
6. ページ遷移後にページトップに戻す
同じコンポーネント内でコンテンツを切り替えると、ページの下側にいた場合、その場で画面が切り替わるのでページが切り替わったことが分かりづらいです。router.jsの機能を使って画面が切り替わったタイミングでページのトップに移動させるようにします。
router.js
const routes = [・・・ルーティング設定・・・]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
scrollBehavior(to, from, savedPosition) { /* ページ遷移後にtopに移動する */
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
},
})
export default router
標準で用意されているscrollBehavior()メソッドを使います。簡単ですね。wordpressで作られたような一般的なWebサイトの振る舞いになります。
公式: スクロール マニュアル
7. Webサーバにデプロイ
ビルドしたファイルを、サーバにデプロイしてみましょう。
/* vue.config.js */
publicPath: process.env.NODE_ENV === 'production' ? '/vue/' : '/',
outputDir: 'dist/vue',
vue.config.jsで上記のように設定しているので、distフォルダ内にvueフォルダが生成されその中にビルドされます。
$ npm run build
AWSやレンタルサーバなどのルートにvueフォルダごとアップロードします。アドレスはhttps://xxx.xx/vueでアクセスできます。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /vue/ [L]
</IfModule>
# Security Headers
<IfModule mod_headers.c>
Header always set Strict-Transport-Security: "max-age=31536000" env=HTTPS
Header always set Content-Security-Policy "upgrade-insecure-requests"
Header always set Expect-CT "max-age=7776000, enforce"
Header always set Referrer-Policy: "no-referrer-when-downgrade"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>
# End Security Headers
また、vueディレクトリ直下に.htaccessを作成し、上記のものを設定しておきましょう。RewriteRule . /vue/にしておかないと表示されません。
vueとは直接関係ないですが、サイト作成のときにはSecurity Headers についても調べて実装してみましょう。
その他
見た目の機能はwordpressを完全に移植できました。ここまでくれば、vue.jsのもつ機能の大枠はつかめたと思います。あとはそれぞれの、制作したいWebサイトの特徴に合わせてカスタマイズしていくことができると思います。
他にも実装したほうが良いものとしては、コンテンツごとの`title`を変更するの設定や、静的サイトジェネレーターのvuePress 、 中規模以上の開発に適しているフレームワークのNuxt.js など、どんどんチャレンジしてみましょう!