BLOG

ブログ

ポートフォリオサイトをNuxt3でJamstack化してみた【Nuxt×WP REST API編】

こんにちは、中谷です。

今回はWordPressサイトをNuxt3を使ってリニューアルしてみようと思い立ってやってみた結果いろいろ大変だったので、リニューアル時に悩みそうなポイントを紹介していこうと思います。

ちなみにこれが完成したサイトです。

まずNuxt3でのサイト制作の仕方がわからない方

Nuxt3でヘッドレスなブログを作成する方法を手っ取り早く勉強するにはmicroCMSのチュートリアルがオススメです。

▶ Nuxt3 + microCMS のブログ作成チュートリアル

はい、これでざっくりNuxtのことがわかりましたね?

要件

サーバーXserver
CI/CD ツールGitHub Actions
ヘッドレスCMSWordPress(REST API)
フロントエンドNuxt3
Nodev18.4.0

サイト制作のポイント

前提としてすでにFigmaでのデザインを制作しており、WPテーマ制作は終わっていました。
なので今回はそのコードをNuxtに置き換えていくことから始めました。

ここでポイントとなるのはカスタム投稿タイプとカスタム投稿フィールドです。
photo / art / newsの3つの投稿記事はどれもカスタム投稿タイプで設定してあります。

WP側の準備

レンタルサーバーを用意

今回Jamstack構成にしたかったのであえてXserverを借りました。
WordPressの管理画面と静的ファイル置き場に使えるので便利ですね。
Xserverは12ヶ月契約なら月額1,100円でレンタルできます。

管理画面はサブドメインを使用するとよさそうです。

functions.phpの記述追加

WP REST APIはWPを初期設定した時点でもう使用できるようになっていますが、そのままだと機能的に足りません。
そのためfunctions.phpにコードを追加する必要があります。

はこちゃんが書いてくれた「WordPressをヘッドレスCMS化するときにfunctions.phpで対応したこと」を参考にしつつ、chatGPTに質問を投げまくって記述していきます。

カスタム投稿フィールドを出力

カスタム投稿フィールドはそのままではAPIに含まれません。
そこで、カスタム投稿タイプ「photo」「art」に以下のレスポンスを追加します。

  • カスタム投稿タイプ「photo」
    カスタム投稿フィールド「photo_date」「photo_camera」「photo_picture」
  • カスタム投稿タイプ「art」
    カスタム投稿フィールド「art_date」「art_picture」

ちなみにWPプラグインはSmart Custom Fieldsを使用しています。
photo_picture / art_pictureは繰り返し機能で画像の配列が入ってきます。

※Smart Custom Fieldsは今後サポート終了に向かうらしいので今度何かに差し替える予定です

rest_api_init というアクションフックでAPIを修正します。

add_action('rest_api_init', 'slug_register_custom_fields');

function slug_register_custom_fields() {
    $custom_post_types = array('photo', 'art'); // カスタム投稿タイプを指定

    $meta_fields = array(
        'photo' => array('photo_date', 'photo_camera', 'photo_picture'), 
        'art' => array('art_date', 'art_picture'),
    );

    foreach ($custom_post_types as $post_type) {
        register_rest_field(
            $post_type,
            'custom',
            array(
                'get_callback' => function ($object, $field_name, $request) use ($post_type, $meta_fields) {
                    $meta = array();
                    foreach ($meta_fields[$post_type] as $field) { 
                        // photo_pictureかart_pictureがあれば添付ファイルのIDを元に画像のURLを配列で取得する
                        if(($field === 'photo_picture') || $field === 'art_picture' ) {
                          $gallery = array();
                          foreach (get_post_meta($object['id'], $field, false) as $sub_field) {
                            $gallery[] = wp_get_attachment_image_src($sub_field, 'large');
                          }
                          $meta[$field] = $gallery;
                        } else {
                          $meta[$field] = get_post_meta($object['id'], $field, false);
                        }
                    }
                    return $meta;
                },
                'update_callback' => null,
                'schema' => null,
            )
        );
    }
}

途中で「photo_pictureかart_pictureがあれば添付ファイルのIDを元に画像のURLを配列で取得する」としているのは、添付ファイルのIDがそのまま出力されてしまってうまく使用できないためです。

json形式のレスポンス

functions.phpをアップロードするとこんな感じにレスポンスのデータが変化していくのが見れるので面白いです。
JSONきれい ~JSON整形ツール~で見やすくできます。

サムネイル画像とページ総数を追加

サムネイル画像も通常状態では帰ってこないため、url・width・heightを配列で返すようにしました。

またページネーションをNuxtで作成するため、「photo」「art」「news」にページ総数を追加していきます。
ちなみにWP REST APIはレスポンスヘッダーでページ総数を取得できるのですが、静的ジェネレートでは相性が悪いのか?うまく行かなかったためこのような形にしました。

add_action('rest_api_init', 'customize_api_response');
function customize_api_response() {
  // レスポンスを追加する投稿タイプ
  $post_types = ['photo', 'art', 'news'];

  foreach ($post_types as $post_type) {
    register_rest_field(
      $post_type,
      'thumbnail',
      array(
        'get_callback'  => function ($post) {
          $thumbnail_id = get_post_thumbnail_id($post['id']);

          if ($thumbnail_id) {
            // サムネイルが設定されていたらurl・width・heightを配列で返す
            $imgLarge = wp_get_attachment_image_src($thumbnail_id, 'large');
            $imgMedium = wp_get_attachment_image_src($thumbnail_id, 'medium');
            $imgThumbnail = wp_get_attachment_image_src($thumbnail_id, 'thumbnail');

            return [
              'large' => $imgLarge[0],
              'medium' => $imgMedium[0],
              'thumbnail' => $imgThumbnail[0],
            ];
          } else {
            // サムネイルが設定されていなかったら空の配列を返す
            return [];
          }
        },
        'update_callback' => null,
        'schema'          => null,
      )
    );

    // ページ総数を取得してレスポンスに含める
    register_rest_field(
      $post_type,
      'total_pages',
      array(
        'get_callback' => function ($post) use ($post_type) {
          $per_page = isset($_REQUEST['per_page']) ? intval($_REQUEST['per_page']) : 10; // デフォルト 10
          $total_posts = wp_count_posts($post_type)->publish;
          $total_pages = ceil($total_posts / $per_page);
          return $total_pages;
        },
        'update_callback' => null,
        'schema' => null,
      )
    );
  }
}

投稿の抜粋

newsページは一覧ページに記事の要約を表示するようにしています。
現在は全文取得しかできないので要約も取得できるようにします。

function add_news_excerpt_to_rest() {
  register_rest_field(
      'news', // 投稿タイプのスラッグ
      'excerpt', // REST APIに追加するフィールド名
      array(
          'get_callback' => function ($post) {
              // 投稿の抜粋を取得して返す
              return get_the_excerpt($post['id']);
          },
          'update_callback' => null,
          'schema' => null,
      )
  );
}
add_action('rest_api_init', 'add_news_excerpt_to_rest');

ページネーション用に前後の記事を取得

詳細ページで次の記事や前の記事を取得したい場合にWPテーマではget_previous_post()などの関数があるのですが、Nuxtでは使用できません。
なのでfunctions.phpでAPIに前後のページ情報を追加しておきます。

// 前後のポストを取得
add_action('rest_api_init', function() {

  /**
   * ニュースの前後記事を取得しレスポンスに追加
   */
  // 前
  register_rest_field(
    'news', // 投稿タイプ
    'prev', // レスポンスのフィールド名
    [
      'get_callback' => 'register_prev_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );
  // 次
  register_rest_field(
    'news', // 投稿タイプ
    'next', // レスポンスのフィールド名
    [
      'get_callback' => 'register_next_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );

  /**
   * アートの前後記事を取得しレスポンスに追加
   */
  // 前
  register_rest_field(
    'art', // 投稿タイプ
    'prev', // レスポンスのフィールド名
    [
      'get_callback' => 'register_prev_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );
  // 次
  register_rest_field(
    'art', // 投稿タイプ
    'next', // レスポンスのフィールド名
    [
      'get_callback' => 'register_next_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );

  /**
   * 写真の前後記事を取得しレスポンスに追加
   */
  // 前
  register_rest_field(
    'photo', // 投稿タイプ
    'prev', // レスポンスのフィールド名
    [
      'get_callback' => 'register_prev_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );
  // 次
  register_rest_field(
    'photo', // 投稿タイプ
    'next', // レスポンスのフィールド名
    [
      'get_callback' => 'register_next_post',
      'update_callback' => null,
      'schema' => null,
    ]
  );
});


/**
 * 前の記事を取得し、配列生成
 */
function register_prev_post() {
  global $post;
  $prev_post = get_previous_post();
  $result = [];

  if (!$prev_post) {
    return null;
  }

  $result['id'] = $prev_post->ID;
  $result['slug'] = $prev_post->post_name;
  $result['title'] = get_the_title($prev_post);

  return $result;
}

/**
 * 次の記事を取得し、配列生成
 */
function register_next_post() {
  global $post;
  $next_post = get_next_post();
  $result = [];

  if (!$next_post) {
    return null;
  }

  $result['id'] = $next_post->ID;
  $result['slug'] = $next_post->post_name;
  $result['title'] = get_the_title($next_post);

  return $result;
}

これでfunctions.phpは準備完了です!

Nuxt側を制作していく!

ここで詳しく解説はしませんが、ファイル構成はこんな感じになりました。

photo/page[slug].vueは写真一覧の2P目以降のページになります。
photo/index.vue内で完結させたいのですが、詳細ページを見て一覧ページへブラウザバックするとまた1P目に戻ってしまう、またユーザーがページをブックマークできないためページネーション用にスラッグを用意しました。

APIからデータを取得

WP REST APIからデータを取得して一覧表示するにはuseFetch関数を使えば簡単にできます。
(axiosを使用しても静的出力がうまくいかなかったのでuseFetchが必要みたいです)

※今回はカスタム投稿タイプのphotoを例にご紹介します。

const perPage = 36 // 1ページに取得する記事数
const { data: photos } = await useFetch(
  `https://{管理画面ドメイン}/wp-json/wp/v2/photo?per_page=${perPage}&page=1`
)

記事詳細ページの場合は[slug].vueでroute.params.slugを使えば現在のページのスラッグ名を取得できます。

const route = useRoute()
const slug = route.params.slug

const { data: photo } = await useFetch(`https://{管理画面ドメイン}/wp-json/wp/v2/photo/${slug}`)

スライダー

トップページのフェードインスライド部分

スライダーはNuxt 3でSwiperを使用するを参考にSwiperを使用しました。
オプションは【Swiper】カスタマイズ用オプションまとめ一覧を参考にするといいかもです!

npm install swiper -D
export default defineNuxtConfig({
  vue: {
    compilerOptions: {
      isCustomElement: (tag) => /^(swiper|swiper-slide|swiper-container)$/.test(tag),
    },
  },
})
<template>
  <!-- 省略 -->
        <swiper-container
          loop="true" // ループさせる
          effect="fade" // フェードインアニメーション
          autoplay="true" // 自動再生
          speed="2000" // スライド間の遷移時間(単位:ms)
          disable-on-interaction="false" // ユーザーがスライダーを操作したときに自動再生を止めるか
          pause-on-mouse-enter="false" // 自動再生時にカーソルを乗せると自動再生が一時停止するか
          simulate-touch="false" // クリック&ドラッグ操作可能か
          class="swiper"
        >
          <swiper-slide v-for="(slide, index) in slides" :key="index">
            <img src="~/assets/image/dot.gif" class="c-dot" />
            <picture class="swiper-img">
              <source :srcset="`image/webp/${slide.imagePath}.webp`" type="image/webp" />
              <img :src="`image/src/${slide.imagePath}.jpg`" alt="" />
            </picture>
          </swiper-slide>
        </swiper-container>
      </div>
  <!-- 省略 -->
</template>

<script setup>
import { ref } from 'vue'
import { register } from 'swiper/element/bundle'
register()

// スライド画像名
const slides = ref([
  { imagePath: 'mv_01' },
  { imagePath: 'mv_02' },
  { imagePath: 'mv_03' },
  { imagePath: 'mv_04' },
])
</script>

<swiper-container>タグではv-bindでカルーセルスライダーの設定を変えられるんですよね。
今回は自動でループしつつフェードインアニメーションができるようにしました。

svg画像の挿入

SNSアイコンとかですね

今回はvite-svg-loaderプラグインを使います。

npm i -D vite-svg-loader
import svgLoader from 'vite-svg-loader'
export default defineNuxtConfig({
  vite: {
    plugins: [svgLoader({ defaultImport: 'component' })],
  },
})

これでSVGファイルをVueコンポーネントとして読み込むことができます。

<template>
  <logo role="img" aria-label="ShalCan" />
</template>

<script setup>
import logo from '~/assets/image/logo_shalcan.svg'
</script>

日付のフォーマット

APIから取得した日付は「2023-10-08T20:54:47」という形で出てきます。

そのためにnuxtのプラグインをprovideを使用して作成しました。
Nuxt3ではpluginsフォルダ下にファイルを作成すれば自動的にプラグインとして認識してくれます。

npm install @nuxtjs/date-fns -D
import { format } from 'date-fns'

export default defineNuxtPlugin(() => {
  // 日付をフォーマット
  const dateFormat = 'yyyy.MM.dd'
  return {
    provide: {
      formatDate: (date: string) => format(new Date(date), dateFormat),
    },
  }
})

これですべてのVueコンポーネント内で「$」をつければ関数が使用できます。

<p class="article_time">
  {{ $formatDate(date) }} // 2023-10-08T20:54:47 が 2023.10.08 に変換される
</p>

SEO設定

sitemap.xmlもモジュールで楽ちん実装

メタタグやsitemapなどこれまでAIOSEOプラグインで代用できていたものは、自分で設定していかないといけません。

サイトマップ

nuxt-simple-sitemapを使えばいい感じにsitemap.xmlを作ってくれます。

npm install nuxt-simple-sitemap -D
export default defineNuxtConfig({
  modules: ['nuxt-simple-sitemap'],
  site: {
    // nuxt-simple-sitemap ベースURL
    url: 'https://{ドメイン}',
  },
  sitemap: {
    // nuxt-simple-sitemap 画像は除外
    discoverImages: false,
  },
})

メタタグ

サイト全体に適応するメタタグはnuxt.config.tsで設定します。
OGタグ系は種類が多いので定数にして時短しましょう。

const metaImage = 'https://{ドメイン}/ogp.jpg'
const metaTitle = 'いい感じのタイトル'
const metaDescription = 'いい感じのディスクリプション'

export default defineNuxtConfig({
  app: {
    head: {
      charset: 'utf-8',
      viewport: 'width=device-width, initial-scale=1',
      title: metaTitle,
      meta: [
        { name: 'description', content: metaDescription },
        { property: 'og:site_name', content: metaTitle },
        { property: 'og:title', content: metaTitle },
        { property: 'og:description', content: metaDescription },
        { property: 'og:type', content: 'website' },
        { property: 'og:image', content: metaImage },
        { property: 'twitter:title', content: metaTitle },
        { property: 'twitter:description', content: metaDescription },
        { property: 'twitter:image', content: metaImage },
        { property: 'twitter:card', content: 'summary_large_image' },
      ],
    },
  },
})

各ページでのメタタグ設定もuseHead()関数を使用することでできます。

const route = useRoute()
const slug = route.params.slug

const { data: photo } = await useFetch(`https://{ドメイン}/wp-json/wp/v2/photo/${slug}`)

const metaImage = photo.value.thumbnail.large
const metaTitle = `${photo.value.title.rendered} | いい感じのタイトル`
useHead({
  title: metaTitle,
  meta: [
    { property: 'og:title', content: metaTitle },
    { property: 'og:image', content: metaImage },
    { property: 'twitter:title', content: metaTitle },
    { property: 'twitter:image', content: metaImage },
  ],
})

GAタグ

Googleアナリティクスのタグはvue-gtagを使用していきます。(自分の場合はv2.0.1を使用)

npm install vue-gtag -D
import VueGtag from 'vue-gtag'

// Nuxtプラグインの登録
export default defineNuxtPlugin((nuxtApp) => {
  // ルーター取得
  const router = useRouter()

  // Vue登録
  nuxtApp.vueApp.use(
    VueGtag,
    {
      appName: 'HOGEHOGE', // サイトの名称
      pageTrackerScreenviewEnabled: true, // ページトラッキングスクリーンビューを有効
      config: { id: `G-XXXXXXXXXX` }, // GoogleAnalytics(GA4)の測定IDを指定する
    },
    router
  )
})

これでようやくサイト公開に必要な状態ができました!!

機能改善もしたい!

さてこんな感じでいろいろやって、基本的なページ表示はできるようにしたところで動作の改善をしたくなりました。
Nuxtでつくるならアプリっぽくしたいよね?

スワイプ操作&キー操作可能にする

NEXTやPREVをマウスで押さなくてもキー操作でサクサク移動したい!
ESCキーで一覧へ戻りたい!
スマホでスワイプしてスルスル移動したい!

そう、Instagramのように!

なかなか大変でしたが、一気にネタバレしていきます(笑)
こちらはphoto詳細ページのコードです。

import { onMounted, onUnmounted } from 'vue'
const route = useRoute()
const router = useRouter()
const slug = route.params.slug

const { data: photo } = await useFetch(`https://{ドメイン}/wp-json/wp/v2/photo/${slug}`)

// eventActiveがtrueのときは操作を受け付けないようにしたい
const eventActive = useState('eventActive', () => false)

// キーボードの矢印を押してページ推移
const handleKeyDown = async (event) => {
  if (eventActive.value) {
    return
  }
  if (event.key === 'ArrowLeft' && photo.value.next) { // 左キー
    eventActive.value = true
    try {
      await router.push({ path: `/photo/${photo.value.next.id}` })
    } catch {
      console.error('Error occurred during navigation:', error)
    } finally {
      setTimeout(() => {
        eventActive.value = false
      }, 800)
    }
  } else if (event.key === 'ArrowRight' && photo.value.prev) { // 右キー
    eventActive.value = true
    try {
      await router.push({ path: `/photo/${photo.value.prev.id}` })
    } catch {
      console.error('Error occurred during navigation:', error)
    } finally {
      setTimeout(() => {
        eventActive.value = false
      }, 800)
    }
  } else if (event.key === 'Escape') { // エスケープキーで戻る
    eventActive.value = true
    try {
      await router.push({ path: `/photo` })
    } catch {
      console.error('Error occurred during navigation:', error)
    } finally {
      setTimeout(() => {
        eventActive.value = false
      }, 800)
    }
  }
}

const touchStartX = ref(0) // タッチ開始位置
const swipeThreshold = 70 // スワイプのしきい値

// タッチ開始
const handleTouchStart = (event) => {
  // タッチの開始位置を保存
  touchStartX.value = event.touches[0].clientX
}

// タッチ終了
const handleTouchEnd = async (event) => {
  if (eventActive.value) {
    return
  }
  const touchEndX = event.changedTouches[0].clientX
  const deltaX = touchEndX - touchStartX.value
  // 画面端での操作は戻る操作と被るので除外
  const windowWidth = window.innerWidth
  if (touchStartX.value < swipeThreshold || touchStartX.value > windowWidth - swipeThreshold) {
    return
  }

  // 一定のスワイプ距離を超えたら次のページに遷移
  if (deltaX > swipeThreshold && photo.value.next) { // 左にスワイプ
    eventActive.value = true
    try {
      router.push({ path: `/photo/${photo.value.next.id}` })
    } catch {
      console.error('Error occurred during navigation:', error)
    } finally {
      setTimeout(() => {
        eventActive.value = false
      }, 800)
    }
  } else if (deltaX < -swipeThreshold && photo.value.prev) { // 右にスワイプ
    eventActive.value = true
    try {
      router.push({ path: `/photo/${photo.value.prev.id}` })
    } catch {
      console.error('Error occurred during navigation:', error)
    } finally {
      setTimeout(() => {
        eventActive.value = false
      }, 800)
    }
  }
}

// コンポーネントがマウントされたときにイベントを追加
onMounted(() => {
  window.addEventListener('keydown', handleKeyDown)
  window.addEventListener('touchstart', handleTouchStart)
  window.addEventListener('touchend', handleTouchEnd)
})

// コンポーネントがアンマウントされるときにイベントを削除
onUnmounted(() => {
  window.removeEventListener('keydown', handleKeyDown)
  window.removeEventListener('touchstart', handleTouchStart)
  window.removeEventListener('touchend', handleTouchEnd)
})

ここで難しかったポイント

キー操作でサクサク移動するとエラーが起こる

こちらのサイトはページ推移時にフェードアウト&フェードインを設定しており、キー操作を連打するなどいたずらに早く推移させると次のページの表示が間に合わなくなってエラーが起きます。

なのでフェード推移が終わる0.8秒まではeventActiveをtrueにしておき操作を受け付けないようにしました。

const eventActive = useState('eventActive', () => false)

通常のref関数だとページ推移で状態がリセットされてしまうためuseStateで状態管理を行いました。
(Nuxt2のVuex的なやつ)

スワイプ操作とブラウザの戻るの挙動が被る

iPhoneのブラウザで画面左端から右にスワイプするブラウザバックの動作をすると、Nuxtで設定した「左にスワイプ」が間違って動作してしまいます。
なので外側から70pxの範囲では動作しないようにしきい値を設定しました。

const swipeThreshold = 70 // スワイプのしきい値

まとめ

今回は情報ゴン盛り回でしたが、こんな風にサイトのリニューアルを進めていきました。
次回はNuxt3の静的ジェネレートで苦労したポイントやGithub Acitonsの話をします!

お楽しみに!

RELATED ARTICLE