💡

Nuxt3で動的なぱんくずを作成する(自動取得)

2024/10/30に公開

ぱんくずを動的にしたいとずっと思っていた

UrSTUDX(ユアスタ)のサイトのぱんくずをいつかは動的にしたいとずっと思っていた。

簡単に実現できそうなら最初からやっていたのだが、Nuxt3で親ページを自動取得して動的にぱんくずを作るという記事はなかなか見当たらず、これまでやってこなかった。
それをようやく実現する時が来た!

UrSTUDX(ユアスタ)のサイトには、クラス検索ユアスタMAGAZINEという、二つの検索ページがあって、これらの検索結果と、そこから辿っていった詳細ページには、検索してきた道筋をぱんくずに載せている。


検索結果から遷移した場合のぱんくず

参考ページ
https://urstudx.com/class_search

https://urstudx.com/magazine

それもあって、ぱんくずはかなり複雑になっているのだが、
検索のないほとんどのページは、単純にページのタイトルをぱんくずに表示しているだけである。

複雑なページは複雑で仕方がないのだが、それ以外の単純なページをなんとかしたい。
ページが増えるごとに、単純な追加作業を忘れずにやらなければならないことに辟易していた。(というほどしょっちゅうあるわけではないけど。。)

これまでのコード

これまでのコードは<template>内で条件分岐しているだけなので、親子関係の階層のあるページが増えるごとに、v-ifで条件分岐を追加しなくてはならない。
また、この方法だと配列が作れないので、構造化データはJSON-LDではなくmicrodataで実装せざるを得なかった。

構造化データについての参考
https://developers.google.com/search/docs/appearance/structured-data/breadcrumb?hl=ja

これまでのコード
これまでのコードがどれだけ冗長だったのかをご紹介。。

これまでのコードの<template>部分抜粋
breadcrumb.vue
<template>
  <div v-if="!!route" class="base_wrap">
    <ClientOnly>
      <ol
        v-if="route.name !== 'index'"
        class="breadcrumb"
        itemscope
        itemtype="https://schema.org/BreadcrumbList"
      >
        <li
          class="breadcrumb_item"
          itemprop="itemListElement"
          itemscope
          itemtype="https://schema.org/ListItem"
        >
          <NuxtLink :to="{ name: 'index' }" itemprop="item">
            <v-icon size="small">mdi-home-outline</v-icon>
            <span itemprop="name">TOP</span>
          </NuxtLink>
          <meta itemprop="position" :content="String(i++)" />
        </li>

        <template v-if="String(route.name).includes('information-detail')">
          <li
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <NuxtLink :to="{ name: 'private-information' }" itemprop="item">
              <span itemprop="name">お知らせ</span>
            </NuxtLink>
            <meta itemprop="position" :content="String(i++)" />
          </li>
        </template>

        <template v-if="String(route.name).includes('profile-')">
          <li
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <NuxtLink :to="{ name: 'private-profile' }" itemprop="item">
              <span itemprop="name">基本情報</span>
            </NuxtLink>
            <meta itemprop="position" :content="String(i++)" />
          </li>
        </template>

        <template v-if="route.name === 'class_search'">
          <li
            v-if="detailTitle"
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <NuxtLink :to="{ name: 'class_search' }" itemprop="item">
              <span itemprop="name">クラスを探す</span>
            </NuxtLink>
            <meta itemprop="position" :content="String(i++)" />
          </li>
          <li
            v-else
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <span itemprop="name">クラスを探す</span>
            <meta itemprop="position" :content="String(i++)" />
          </li>
        </template>

        <template v-if="route.name === 'class_search-detail-id'">
          <li
            v-if="teacherId && teacherNickname"
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <NuxtLink
              :to="{ name: 'teacher-detail-id', params: { id: teacherId } }"
              itemprop="item"
            >
              <span itemprop="name">{{ teacherNickname }}[先生紹介]</span>
            </NuxtLink>
            <meta itemprop="position" :content="String(i++)" />
          </li>
          <template v-else>
            <li
              v-if="searchQuery.page && !searchQueryTxt"
              class="breadcrumb_item"
              itemprop="itemListElement"
              itemscope
              itemtype="https://schema.org/ListItem"
            >
              <v-icon size="small">mdi-chevron-right</v-icon>
              <NuxtLink
                :to="{ name: 'class_search', query: { page: searchQuery.page } }"
                itemprop="item"
              >
                <span itemprop="name">クラスを探す</span>
              </NuxtLink>
              <meta itemprop="position" :content="String(i++)" />
            </li>

・・・・・・ 略 ・・・・・・
 
        <template v-if="!(searchTop && !detailTitle)">
          <li
            class="breadcrumb_item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          >
            <v-icon size="small">mdi-chevron-right</v-icon>
            <span itemprop="name"
              ><template v-if="detailTitle">{{ detailTitle }}[{{ route.meta.title }}]</template
              ><template v-else>{{ route.meta.title }}</template></span
            >
            <meta itemprop="position" :content="String(i++)" />
          </li>
        </template>
      </ol>
    </ClientOnly>
  </div>
</template>

親ページ(祖先ページ)の情報を自動で取得する

今回のポイントとなるがこのコード。
routerを使用して、今いるページのパスroute.pathから、祖先ページの情報を自動で取得する。

router.getRoutes()で下の情報が取得できるので、これを利用しようというものだ。

祖先ページのぱんくずを自動で取得するコード

const route = useRoute()
const router = useRouter()

interface Breadcrumb {
  name: string
  title: string
  path: string
}

const parentBreadcrumbs = computed<Breadcrumb[]>(() => {
  const newBreadcrumbs: Breadcrumb[] = []
  let currentPath = route.path

  while (currentPath) {
    // 現在のページから親ページを見つける
    const parentRoute = router
      .getRoutes()
      .find((r) => currentPath.startsWith(r.path) && currentPath !== r.path)

    if (parentRoute) {
      // 見つかった親ページの情報を先頭に追加
      newBreadcrumbs.unshift({
        name: parentRoute.name as string,
        title: parentRoute.meta.title as string,
        path: parentRoute.path,
      })
      // 次のループでさらに上の親を探すために currentPath を更新
      currentPath = parentRoute.path
    } else {
      break
    }
  }
  return newBreadcrumbs
})

前提として、各ページにはdefinePageMetaでページタイトルを入れておく必要がある。

例)index.vue
definePageMeta({
  title: 'TOP',
})

これを使って、手書きで冗長だったコードを書き直してみた。

完成した動的なぱんくずのコード

<template>部分

ループ1回だけになった!!

Breadcrumb.vue
<template>
  <div v-if="!!route" class="base_wrap">
      <ol
        v-if="route.name !== 'index'"
        class="breadcrumb"
        itemscope
        itemtype="https://schema.org/BreadcrumbList"
      >
        <li
          v-for="(crumb, index) in breadcrumbs"
          :key="index"
          class="breadcrumb_item"
          itemprop="itemListElement"
          itemscope
          itemtype="https://schema.org/ListItem"
        >
          <v-icon v-if="index > 0" size="small">mdi-chevron-right</v-icon>
          <NuxtLink
            v-if="crumb.path"
             <NuxtLink
            v-if="crumb.path"
            :to="crumb.query ? { path: crumb.path, query: crumb.query } : { path: crumb.path }"
            itemprop="item"
          >
            <v-icon v-if="index === 0" size="small">mdi-home-outline</v-icon>
            <span itemprop="name">{{ crumb.title }}</span>
          </NuxtLink>
          <span v-else itemprop="name">{{ crumb.title }}</span>
          <meta itemprop="position" :content="String(index + 1)" />
        </li>
      </ol>
  </div>
</template>

<script>部分

<script>の読み込み箇所を簡単に説明

下記の<script>部分で使用している、
piniaに格納しているデータを先に簡単に説明しておきます。
※piniaのコードについては省略します。

const {
  detailTitle, // 詳細タイトル or 検索結果タイトル
  searchQuery, // クラス検索query
  searchResultTxt, // クラス検索結果テキスト
  magazineSearchQuery, // マガジン検索query
  magazineSearchResultTxt, // マガジン検索結果テキスト
  teacherId, // 先生ページからクラス詳細へ遷移した場合、先生ID
  teacherNickname, // 先生ページからクラス詳細へ遷移した場合、先生名
} = storeToRefs(useMetaStore())

<script>部分①

完成したscript①
Breadcrumb.vue
<script setup lang="ts">
import type { LocationQueryRaw, RouteLocationNormalizedLoaded } from 'vue-router'

const route = useRoute()
const router = useRouter()
const {
  detailTitle,
  searchQuery,
  searchResultTxt,
  magazineSearchQuery,
  magazineSearchResultTxt,
  teacherId,
  teacherNickname,
} = storeToRefs(useMetaStore())

const exceptionPage = (route: RouteLocationNormalizedLoaded) =>
  String(route.name).includes('class_search') || String(route.name).includes('magazine')

const searchTop = (route: RouteLocationNormalizedLoaded) =>
  route.name === 'class_search' || route.name === 'magazine'

interface Breadcrumb {
  name: string
  title: string
  path: string
  query?: LocationQueryRaw
}

const generateParentBreadcrumbs = (currentPath: string): Breadcrumb[] => {
  const newBreadcrumbs: Breadcrumb[] = []

  while (currentPath) {
    // 現在のページから親ページを見つける
    const parentRoute = router
      .getRoutes()
      .find((r) => currentPath.startsWith(r.path) && currentPath !== r.path)

    if (parentRoute && parentRoute.path !== '/') {
      // 見つかった親ページの情報を先頭に追加
      newBreadcrumbs.unshift({
        name: parentRoute.name as string,
        title: parentRoute.meta.title as string,
        path: parentRoute.path,
      })
      // 次のループでさらに上の親を探すために currentPath を更新
      currentPath = parentRoute.path
    } else {
      break
    }
  }
  return newBreadcrumbs
}

const breadcrumbs = computed<Breadcrumb[]>(() => {
  let newBreadcrumbs: Breadcrumb[] = [
    {
      name: 'index',
      title: 'TOP',
      path: '/',
    },
  ]

  if (!exceptionPage(route)) {
    const parentBreadcrumbs = generateParentBreadcrumbs(route.path)
    newBreadcrumbs = [...newBreadcrumbs, ...parentBreadcrumbs]
  } else if (searchTop(route)) {
    if (detailTitle.value) {
      // 検索結果がある場合
      newBreadcrumbs.push({
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
      })
    }
  } else if (route.name === 'class_search-detail-id') {
    if (teacherId.value && teacherNickname.value) {
      newBreadcrumbs.push({
        name: 'teacher-detail-id',
        title: `${teacherNickname.value}[先生紹介]`,
        path: `/teacher/detail/${teacherId.value}`,
      })
    } else {
      newBreadcrumbs.push({
        name: 'class_search',
        title: 'クラスを探す',
        path: '/class_search',
        ...(searchQuery.value.page &&
          !searchResultTxt.value && { query: { page: searchQuery.value.page } }),
      })
      if (searchResultTxt.value) {
        newBreadcrumbs.push({
          name: 'class_search',
          title: `${searchResultTxt.value}[クラスを探す]`,
          path: '/class_search',
          query: searchQuery.value,
        })
      }
    }
  } else if (route.name === 'magazine-detail-id') {
    newBreadcrumbs.push({
      name: 'magazine',
      title: 'ユアスタMAGAZINE',
      path: '/magazine',
      ...(magazineSearchQuery.value.page &&
        !magazineSearchResultTxt.value && { query: { page: magazineSearchQuery.value.page } }),
    })
    if (magazineSearchResultTxt.value) {
      newBreadcrumbs.push({
        name: 'magazine',
        title: `${magazineSearchResultTxt.value}[ユアスタMAGAZINE]`,
        path: '/magazine',
        query: magazineSearchQuery.value,
      })
    }
  }

  newBreadcrumbs.push({
    name: route.name as string,
    title: detailTitle.value
      ? `${detailTitle.value}${route.meta.title}`
      : (route.meta.title as string),
    path: '',
  })

  // console.log('updateBreadcrumbs', newBreadcrumbs)
  return newBreadcrumbs
})
</script>

<script>部分②

さらに処理部分を分けて記述してみる。
ページごとに条件分岐している処理をそれぞれ外部に出して、最後に配列を合体させてみた。

完成したscript②
Breadcrumb.vue
<script setup lang="ts">
import type { LocationQueryRaw, RouteLocationNormalizedLoaded } from 'vue-router'

const route = useRoute()
const router = useRouter()
const {
  detailTitle,
  searchQuery,
  searchResultTxt,
  magazineSearchQuery,
  magazineSearchResultTxt,
  teacherId,
  teacherNickname,
} = storeToRefs(useMetaStore())

const exceptionPage = (route: RouteLocationNormalizedLoaded) =>
  String(route.name).includes('class_search') || String(route.name).includes('magazine')

const searchTop = (route: RouteLocationNormalizedLoaded) =>
  route.name === 'class_search' || route.name === 'magazine'

interface Breadcrumb {
  name: string
  title: string
  path: string
  query?: LocationQueryRaw
}

const generateParentBreadcrumbs = (currentPath: string): Breadcrumb[] => {
  const newBreadcrumbs: Breadcrumb[] = []

  while (currentPath) {
    // 現在のページから親ページを見つける
    const parentRoute = router
      .getRoutes()
      .find((r) => currentPath.startsWith(r.path) && currentPath !== r.path)

    if (parentRoute && parentRoute.path !== '/') {
      // 見つかった親ページの情報を先頭に追加
      newBreadcrumbs.unshift({
        name: parentRoute.name as string,
        title: parentRoute.meta.title as string,
        path: parentRoute.path,
      })
      // 次のループでさらに上の親を探すために currentPath を更新
      currentPath = parentRoute.path
    } else {
      break
    }
  }
  return newBreadcrumbs
}

const normalBreadcrumbs = computed<Breadcrumb[]>(() => {
  if (!exceptionPage(route)) {
    return generateParentBreadcrumbs(route.path)
  }
  return []
})

const searchTopBreadcrumbs = computed<Breadcrumb[]>(() => {
  const newBreadcrumbs: Breadcrumb[] = []
  if (searchTop(route)) {
    if (detailTitle.value) {
      // 検索結果がある場合
      newBreadcrumbs.push({
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
      })
    }
  }
  return newBreadcrumbs
})

const classDetailBreadcrumbs = computed<Breadcrumb[]>(() => {
  const newBreadcrumbs: Breadcrumb[] = []
  if (route.name === 'class_search-detail-id') {
    if (teacherId.value && teacherNickname.value) {
      newBreadcrumbs.push({
        name: 'teacher-detail-id',
        title: `${teacherNickname.value}[先生紹介]`,
        path: `/teacher/detail/${teacherId.value}`,
      })
    } else {
      newBreadcrumbs.push({
        name: 'class_search',
        title: 'クラスを探す',
        path: '/class_search',
        ...(searchQuery.value.page &&
          !searchResultTxt.value && { query: { page: searchQuery.value.page } }),
      })
      if (searchResultTxt.value) {
        newBreadcrumbs.push({
          name: 'class_search',
          title: `${searchResultTxt.value}[クラスを探す]`,
          path: '/class_search',
          query: searchQuery.value,
        })
      }
    }
  }
  return newBreadcrumbs
})

const magazineDetailBreadcrumbs = computed<Breadcrumb[]>(() => {
  const newBreadcrumbs: Breadcrumb[] = []
  if (route.name === 'magazine-detail-id') {
    newBreadcrumbs.push({
      name: 'magazine',
      title: 'ユアスタMAGAZINE',
      path: '/magazine',
      ...(magazineSearchQuery.value.page &&
        !magazineSearchResultTxt.value && { query: { page: magazineSearchQuery.value.page } }),
    })
    if (magazineSearchResultTxt.value) {
      newBreadcrumbs.push({
        name: 'magazine',
        title: `${magazineSearchResultTxt.value}[ユアスタMAGAZINE]`,
        path: '/magazine',
        query: magazineSearchQuery.value,
      })
    }
  }
  return newBreadcrumbs
})

const currentBreadcrumb = computed<Breadcrumb[]>(() => [
  {
    name: route.name as string,
    title: detailTitle.value
      ? `${detailTitle.value}${route.meta.title}`
      : (route.meta.title as string),
    path: '',
  },
])

const breadcrumbs = computed<Breadcrumb[]>(() => {
  const topBreadcrumb: Breadcrumb[] = [
    {
      name: 'index',
      title: 'TOP',
      path: '/',
    },
  ]
  // console.log('updateBreadcrumbs')

  return [
    ...topBreadcrumb,
    ...normalBreadcrumbs.value,
    ...searchTopBreadcrumbs.value,
    ...classDetailBreadcrumbs.value,
    ...magazineDetailBreadcrumbs.value,
    ...currentBreadcrumb.value,
  ]
})
</script>

①と②の違い

<script>部分①と②は、どちらも正しく動くが、②の方がbreadcrumbの値が再計算される回数が少なくなる。

computedは、内包しているリアクティブな値に変更があると再計算される。

②の方が内包している値が少なくなるので、現在いるページに関係ない値が更新された時に再計算されなくなり、必要なときにのみbreadcrumbの値が再計算されるようになる。
また、②の方が可読性と保守性は上がると言える。

最後に、シンプルな動的ぱんくず全コード

最後に、静的なページのみの、シンプルな全コードも載せておきます。
検索機能などがなくて、ページのタイトルを個別に動的にする必要もない場合はこれで充分だと思います。css付きでどうぞ!

もし、今いるページのタイトルを動的にしたい場合はcurrentBreadcrumbtitleの値を動的にしてください。
(ちなみにroute.meta.titleはサーバーサイドで定義されるので、クライアントサイドで書き換えることはできません。)

※アイコンはVuetifyを使用しています。

Breadcrumb.vue
<template>
  <div v-if="!!route" class="base_wrap">
    <ol
      v-if="route.name !== 'index'"
      class="breadcrumb"
      itemscope
      itemtype="https://schema.org/BreadcrumbList"
    >
      <li
        v-for="(crumb, index) in breadcrumbs"
        :key="index"
        class="breadcrumb_item"
        itemprop="itemListElement"
        itemscope
        itemtype="https://schema.org/ListItem"
      >
        <v-icon v-if="index > 0" size="small">mdi-chevron-right</v-icon>
        <NuxtLink v-if="crumb.path" :to="{ path: crumb.path }" itemprop="item">
          <v-icon v-if="index === 0" size="small">mdi-home-outline</v-icon>
          <span itemprop="name">{{ crumb.title }}</span>
        </NuxtLink>
        <span v-else itemprop="name">{{ crumb.title }}</span>
        <meta itemprop="position" :content="String(index + 1)" />
      </li>
    </ol>
  </div>
</template>

<script setup lang="ts">
const route = useRoute()
const router = useRouter()

interface Breadcrumb {
  name: string
  title: string
  path: string
}

const parentBreadcrumbs = computed<Breadcrumb[]>(() => {
  const newBreadcrumbs: Breadcrumb[] = []
  let currentPath = route.path

  while (currentPath) {
    // 現在のページから親ページを見つける
    const parentRoute = router
      .getRoutes()
      .find((r) => currentPath.startsWith(r.path) && currentPath !== r.path)

    if (parentRoute) {
      // 見つかった親ページの情報を先頭に追加
      newBreadcrumbs.unshift({
        name: parentRoute.name as string,
        title: parentRoute.meta.title as string,
        path: parentRoute.path,
      })
      // 次のループでさらに上の親を探すために currentPath を更新
      currentPath = parentRoute.path
    } else {
      break
    }
  }
  return newBreadcrumbs
})

const breadcrumbs = computed<Breadcrumb[]>(() => {
  const currentBreadcrumb: Breadcrumb[] = [
    {
      name: route.name as string,
      title: route.meta.title as string,
      path: '',
    },
  ]

  return [...parentBreadcrumbs.value, ...currentBreadcrumb]
})
</script>

<style lang="scss" scoped>
.base_wrap {
  margin: 0 auto;
  padding-left: 1rem;
  padding-right: 1rem;
  max-width: 1200px;
}
.breadcrumb {
  display: flex;
  align-items: center;
  gap: 5px;
  margin: 12px 0 16px;
  overflow-x: auto;
  &_item {
    display: flex;
    align-items: center;
    gap: 2px;
    font-weight: bold;
    font-size: 14px;
    color: #999;
    white-space: nowrap;
    > i {
      margin-right: 3px;
      margin-top: 2px;
      color: #c4c4c4;
    }
    a {
      display: flex;
      align-items: center;
      gap: 2px;
      color: #63bed7;
    }
  }
}
</style>

さて、ここまで出来たらもうJSON-LDにもできるので、移行しようかなと思うのだけど、
それはまた今度に!

Discussion