Nuxt×TypeScriptで型安全にディレクトリ構造を統一したパンくずリストを実装する


概要

Web開発において、パンくずリストを正しく設定することは、ディレクトリ構造を検索エンジンに伝える上で重要です。
しかし、サイト内の多種多様なページでそれぞれパンくずリストを実装していると管理が煩雑になり、ディレクトリ構造が矛盾してしまうリスクが起こります。
本記事では、Nuxt.jsにおけるSSRなアプリケーション開発において、TypeScriptによる型安全の恩恵を受けながらディレクトリ構造を統一したパンくずリストを実装するTipsを紹介します。

問題提起

本記事で解決手段を提起する問題は、主に以下の2点です。

  • 【ディレクトリ構造の管理が複雑でミスが起きやすい】サイト内の多種多様なページでそれぞれパンくずリストを実装していると管理が煩雑になり、ディレクトリ構造が矛盾してしまうリスクが起こる
  • 【フロントエンドでSEO対策を実装するのは特に難しい】もともとSEOに関するロジックを他のロジックから切り離して管理するのはサーバーサイドでも苦労する点であったが、フロントエンドがSEOのmeta情報等の管理責務を負うようになると素のJSでは特に難しいといえる

以下、順に説明します。

ディレクトリ構造の管理が複雑でミスが起きやすい

ここでは例として、「プログラミング言語ごとにエンジニアが登録されているサイト」を考えます。
あるエンジニアのプロフィールページのパンくずリストが、以下のようになっていたとしましょう。

TOP > JavaScript > 開発太郎さん

言語ごとにエンジニアが登録されているため、このようなディレクトリ構造をしています。
さらに、エンジニアごとにブログ記事を投稿できるようになったとしたら、各記事のパンくずリストは以下のようになるでしょう。

TOP > JavaScript > 開発太郎さん > [記事タイトル]

実装時には、もちろん各ページにそれぞれパンくずリストをコーディングします。
何も問題がないようにも見えますね。では、サイト内のディレクトリ構造がSEO対策チームによって見直され、プロフィールページのパンくずリストが以下のようになったらどうでしょう。

TOP > Webエンジニア > JavaScript > 開発太郎さん

Web系以外のエンジニアも扱うサイトになってきたので、JavaScriptの上の階層に「Webエンジニア」を追加したわけです。
さて、ここで問題が発生します。プロフィールページのパンくずリストを改修するだけでは実は不足しているということです。先程のブログ記事のパンくずリストである、

TOP > JavaScript > 開発太郎さん > [記事タイトル]

こちらも修正しなければいけません。
というより、現実的には、ほとんどすべてのページのパンくずリストを修正する必要が出てくるでしょう。各エンジニアの下にぶらさがっていた全ページのパンくずリストの修正が必要です。
もし変更が漏れてしまった場合、サイトのディレクトリ構造が誤って検索エンジンに伝わってしまう可能性があります。

このように、各ページのパンくずリストを別個で実装していた場合、親の階層の変更が「自動で」子ページに反映されないため、変更漏れのリスクや、確認工数の肥大化といった課題が起こります。
端的に言えば、親ページの階層構造が変わったときに子ページに自動的に反映されないことが問題と言えます。

フロントエンドでSEO対策を実装するのは特に難しい

※こちらの問題については個人的な感覚ですのでご了承ください。

元来、たとえバックエンドであっても、SEO対策に関するロジック(meta情報、パンくずリスト、構造化マークアップ)を他のビジネスロジックから切り離して保守性を高めて管理するのは骨が折れる作業です。
Nuxt.js等のフロントエンドフレームワークがSSRの文脈でSEOに関するロジックを担うようになる中で、素のJavaScriptでは型安全にコードを書くことが難しい上に、どうしてもバックエンドでは当たり前に行われているようなDDDやクリーンアーキテクチャのような責務を分離するノウハウがまだ当たり前にはなっていない印象です。


弊社でも、当初はasyncData/fetch内にゴリゴリとパンくずリストのロジックを記述する運用で回していたのですが、前述の管理の課題が浮上したため、

  1. ロジックを切り出し、
  2. TypeScriptで型定義し、
  3. 単体テストも書けて、
  4. パンくずリストのディレクトリ構造が統一できるようなクラス設計を

導入しました。
というわけで、次節以降では具体的なソースコードを添えて説明していきます。

ディレクトリ構造を意識したパンくずリストの実装

パンくずリストの型定義

まずは、各パンくずリストのアイテムの型定義を示します。
ここでいう「アイテム」とは、パンくずリスト内に含まれる各ページを表現するオブジェクトです。

export type BreadcrumbsItem = {
  // 表示する文字
  text: string
  // aタグのtitle属性。SEOやa11yのために設定。
  title?: string
  // nuxt-linkのために設定
  to: string | Location
  // trueにするとリンクではなくなる
  disabled?: boolean
}

これで各ページを示すアイテムを型定義します。例えばトップページを示すアイテム、JavaScriptが得意なエンジニア一覧ページを示すアイテム、といった感じです。

ページごとのパンくずリストを表現する基底クラス

続いて、ページごとのパンくずリストを表現するクラスの基底クラスを示します。
前述のアイテムを組み合わせることで、「あるページに表示されるパンくずリスト」が表現できます。

  • トップページを示すアイテム
  • JavaScriptが得意なエンジニア一覧ページを示すアイテム
  • エンジニアプロフィールページを示すアイテム

を組み合わせることで、あるページのパンくずリストを実現します。

組み合わせを実現する基底クラスを作り、それを継承することで各ページのパンくずリストを実現する手法を採用しました。
パンくずリストの基底クラスBreadcrumbsBuilderはこちらです。

export abstract class BreadcrumbsBuilder {
  breadcrumbsItems: BreadcrumbsItem[] = []
  parentBuilder: BreadcrumbsBuilder | null = null

  parent (builder: BreadcrumbsBuilder) {
    this.parentBuilder = builder
    return this
  }

  push (item: BreadcrumbsItem) {
    this.mainBreadcrumbsItems.push(item)
    return this
  }

  getBreadcrumbs (): BreadcrumbsItem[] {
    if (!this.parentBuilder) {
      return this.breadcrumbsItems
    }
    return this.parentBuilder
      .getBreadcrumbs()
      .concat(this.breadcrumbsItems)
  }
}

1つ目のポイントは、breadcrumbsItemsとしてBreadcrumbsItemの配列を保持していることです。前述の通り、一つのページのパンくずリストには、複数のアイテムが含まれているのでこのように保持します。

2つ目のポイントは、あるページの親を示すパンくずリストをparentBuilderとして保持していることです。このように、あるページは自分自身のページのアイテムと、親のBreadcrumbsBuilderのみを保持する事によって、問題提起の節で例示した、「親ページの階層構造が変わったときに子ページに自動的に反映されない」問題を解消します。

ページごとのパンくずリストを表現する拡張クラス

実際にどのようにこの基底クラスを拡張するか見ていきましょう。
まずはトップページから実装していきます。

export class TopPage extends BreadcrumbsBuilder {
  constructor () {
    super()
    this.push({
      text: 'エンジニアプロフィールサイトEnProfile',
      to: '/',
    })
  }
}

コンストラクタにて、this.push を実行し、自身のページ、すなわちトップページのパンくずアイテムを設定します。これで終了です。
続いて、JavaScriptが得意なエンジニア一覧ページのパンくずリストを見てみましょう。

export class EngineerListLanguage extends BreadcrumbsBuilder {
  constructor (lang: Language) {
    super()
    this.parent(new TopPage()).push({
      text: `${lang.name}`,
      title: `${lang.name}が得意なエンジニア一覧`,
      to: {
        name: 'engineer-languages-langName',
        params: {
          langName: lang.name,
        },
      },
    })
  }
}

こちらの実装にて、「親ページの階層構造が変わったときに子ページに自動的に反映されない」問題を解消するロジックが少しずつ分かっていただけると思います。

this.parent(new TopPage())

こちらのparentメソッドの実行によって、JavaScriptが得意なエンジニア一覧ページの親ページがトップページであることを指定します。

具体的に、トップページのパンくずリストがどのようなものか、知る必要はありません。

改めて基底クラスを見直すと、parentメソッドの実装は以下のようになっています。

  parent (builder: BreadcrumbsBuilder) {
    this.parentBuilder = builder
    return this
  }

parentBuilderに親を設定の上、thisを返すことでメソッドチェーンも可能にしています。

続いてpushメソッドを実行して、自身のページについてもパンくずリストを保存します。

    push({
      text: `${lang.name}`,
      title: `${lang.name}が得意なエンジニア一覧`,
      to: {
        name: 'engineer-langName',
        params: {
          langName: lang.name,
        },
      },
    })

最後に、各エンジニアのプロフィールページのパンくずリストを実装します。

class EngineerProfile extends BreadcrumbsBuilder {
  constructor (
    engineer: Engineer,
    lang: Language
  ) {
    super()
    // 親を設定後、自分のページを設定する
    this.parent(new EngineerListLanguage(lang)).push({
      text: engineer.name + 'さん',
      to: {
        name: 'engineer-id',
        params: {
          id: `${engineer.user_id}`,
        },
      },
    })
  }
}

このように、エンジニアのプロフィールページの実装時には、その直接の親が言語別のエンジニアの一覧であることが確定しているだけで、さらにその親がどんなページなのかを知らなくていいようになっています。

こうすることで、問題提起の節で例示した、「Web系以外のエンジニアも扱うサイトになってきたので、JavaScriptの上の階層に「Webエンジニア」を追加した」というケースでも、プロフィールページやそれ以下の層のページでは改修が不要で、自動的に親の親のページがパンくずリストに追加されることになります。

パンくずリストのレンダリング

以上にて、パンくずリストそのもののデータは一つのクラス内にディレクトリ構造を意識した上で格納することができたわけなので、実際にパンくずリストをレンダリングする処理にも言及します。
まずは、作成したクラスをPage Componentにて初期化します。SSRするのでasyncData内で実行します。※Nuxt2.12以降ならfetch内でthis.$XXXで実行します。

          // これより前の処理でAPIからEngineer型のデータとLanguage型のデータを取得済み。
          app.$setBreadcrumbs(
            new EngineerProfile({
              engineer,
              lang
            })
          )

便宜上、appオブジェクトからパンくずリスト設定用のメソッドを呼べると便利なので、下記のようにinjectしておきます。

inject('setBreadcrumbs', (builder: BreadcrumbsBuilder) => {
  store.commit(
    'meta/setBreadcrumbs',
    builder.getBreadcrumbs()
  )
}

実装している処理としては、Storeのメタ情報設定用のmutationを呼んでいます。
どうしてStoreを使うかというと、Page ComponentからパンくずリストのComponentにデータを渡すためです。
正直、Storeにパンくずリストを設定するであったりとか、Mutationを直接呼ぶといった方法に納得感はありません。とりあえずこの方法でやっている感があり、となると、Page ComponentからStoreを直接呼んでいるとこの実装をリファクタしたいときに影響範囲が甚大になってしまいます。
ということで、injectを間に噛ませておけば、Store以外の実装方法への移行コストが下がり、リファクタがしやすくなるという考えです。

さて、パンくずリストComponentはlayout/default.vue にてIncludeされており、その内部実装は以下のようなTemplateで実装します。
コード中、itemsという変数が、Storeからパンくずリストを取り出して設定されているPropsです。
※Vuetify×Pugという尖った実装例ですのでご参考までに。ちなみにPugはLinter等のエコシステム恩恵を受けられない点で気に入らないので脱却予定です

<template lang="pug">
nav
  v-breadcrumbs(:items="items")
    template(v-slot:item="props")
      v-breadcrumbs-item(
        active-class=""
        :disabled="props.item.disabled"
        :title="props.item.title ? props.item.title : props.item.text"
      )
        span(:class="{ disabled: props.item.disabled }") {{ props.item.text }}
    template(v-slot:divider)
      v-icon(size="18") {{ icons.mdiChevronRight }}
</template>

テストコード

テストコードを実装するときは、パンくずリスト自体がVueやNuxtになんの依存もないクラスとして独立しているため、jestであればシンプルにクラスを生成したと、ts-auto-mock でEngineerやLanguageのMockを作ってクラスのインスタンスに渡し、getBreadcrumbs()を実行して内容をチェックすればいいです。

雑に書きますと下記のような感じですね。

// @see https://typescript-tdd.github.io/ts-auto-mock/create-mock
import { createMock } from 'ts-auto-mock'

// 略

describe('正常系', () => {

  const mockEngineer = createMock<Engineer>({
    name: 'test',
  })
  const mockLanguage = createMock<Language>({
    name: 'javascript',
  })

  const breadcrumbs = new EngineerProfile(mockEngineer, mockLanguage).getBreadcrumbs()

  it('パンくずのアイテム数が意図したとおりであること', async () => {
    expect(breadcrumbs.length).toBe(3)
  })

  // 以下、親パンくずの内容やタイトルについてExpectationしていく
})

以上まで、ディレクトリ構造を統一したパンくずリストの実装と、それをNuxt.jsにてレンダリングする実装例を提示しました。

JSON-LD

こちらは本題からずれるので詳細は割愛します(別記事で気が向いたら書きます)が、JSON-LDも、BreadcrumbsItem型の変数を駆使して生成します。
nuxt-jsonldというライブラリと、schema-dtsというJSON-LDの型定義ライブラリを併用すれば、JSON-LDまで一気通貫で型安全の恩恵を受けながら構築できます。

例示した実装内容の問題点

ここまでお話したパンくずリストの実装内容は、実際に弊サービスにて運用されています。
ただ、完璧な実装とは思っていないので、免責も兼ねて認識している問題点についてここでまとめておきます。

toメンバ変数がVueRouterのLocationになっている

できればVueやNuxtから完全に独立した仕組みにしたかったのですが、成り行きでtoの型にLocationが含まれてしまいました。
例えばLocationに対して$router.resolve().hrefでstringに変換するアダプタを別で定義してあげて、パンくずリストのクラスを初期化するときなどにそれを間に噛ませるといった実装にしてtoにはあくまでstringしか渡らないようにすることをやってみたいです。

子階層になればなるほどパンくずリストに必要なデータ数が増える

エンジニアプロフィールページを生成するときに、直接関係ない親ページに必要なLanguage型のデータが必要となっているように、これは更に深い階層のページになればなるほど、祖先の代で必要とするすべてのデータを渡さなければならないことを意味します。
原理的に考えればこれは当たり前のことですし、TypeScriptの構造的な型定義を活用すれば子ページの都合に応じて親に必要なデータを柔軟に渡すことは可能です。※つまり、親ページで利用を想定しているAPIを必ず子ページでCallしなければならないということではない
なので、問題点というのも微妙なのですが、例えば各パンくずリストアイテムをComponentとしても扱うようにして、自身のComponentに必要なデータはComponentのfetch()メソッドで取得するようにすればPage Componentでデータの用意に苦心することはなくなるので、そういった実装方針もありなのかな、と考えたりはします。

現実問題パフォーマンスを考えればAPI呼び出しはPage Componentに統一して管理したいので、なんともいえません。

なんか抽象クラスで頑張っているのがTSっぽくない

これは個人的な感覚ですw
ジェネレータやNamespaceなど、関数やスコープ周りの機能を駆使してもっとJavaScript/TypeScriptっぽく書けないのかなとは思ってます。

いっそnuxt-ts-breadcrumbsみたいな名前でnpmライブラリとして公開できるくらい抽象化できないのか

なんかできそうな気がするので、余裕があればやってみようと思います。npmライブラリの公開、やったことがないので良い機会でもありそうです。
もし本記事の内容をぜひともOSSにしてほしいというような方がいらっしゃれば、一緒にやりましょう!

まとめ

自身の親のみを参照する、という設計指針でパンくずリストのクラスを作成することで、ディレクトリ構造を統一したパンくずリストを実現することができました。