コンポーネント設計は習うより慣れろ

..

この記事でいいたいこと

  • ワタシはNuxt.js(Vue.js)を勉強し始めて間もない初学者
  • コンポーネント設計というやつを考え始めた
  • 考え方はこちらの記事をパクった
  • 他には、@yametaroさんのこの記事を読んで「おお」ってなった
  • それを踏まえ、とりあえずこういうことかしら?という現時点の考えをまとめた
  • 同じようなフェーズにいる初学者の人に読んでみてほしい

コンポーネント化の目的を振り返る

  • 同じコードを何度も書くのはめんどくさい。(コピペした数だけ修正が必要になってしまうから)
  • なのでコードをコンポーネント化(要は変数化)して、呼び出すだけでいくらでも使いまわせるようにしたい。

アプリを作りたいわけじゃなくてもマークアップで楽をするためにNuxt.jsを使ってもいいんじゃ?というくらい、楽できます。

例えば、これは冒頭で紹介した@yametaroさんの記事のパクリですが…

TextCaption.vue
<template>
  <div class="text-caption">
    <slot />
  </div>
</template>

<script>
export default {
}
</script>

Vuetifyでは、規定のクラス名に応じてテキストサイズを自動的に調整してもらえます。
text-captionは一番小さいサイズの指定になるわけですが、毎回それを書くのはだるい。それだけでなく、Vuetifyのバージョンアップに伴って名前が変わったりなどしたら、text-captionを指定している箇所をしらみつぶしに探し、変更する必要が出てしまうことが考えられます。

なので、こんなふうに「中に書かれた文字がtext-captionサイズになる要素」をラップしたコンポーネントを用意することで、クラス名の代わりにこのコンポーネントで囲ってしまえば一箇所の変更で済むわけですね。

<TextCaption>テキスト</TextCaption>

同じようなことをaタグでやるのもいいんでない?と思います。

Link.vue
<template>
  <a v-bind="$attrs" target="_blank" rel="noopener noreferrer">
    <slot />
  </a>
</template>

<script>
export default {
}
</script>

target="_blank"には脆弱性があることがだいぶ前に指摘されたため、指摘した人が紹介していたセキュアな書き方を導入しています。

@aLiz様のこちらの記事を参考に、呼び出し先からhref属性の値を差し込みます。(インスタンスプロパティ"$attrs"について)

<Link href="https://qiita.com/inarikawa">筆者のQiita</Link>

これらのコンポーネントは、あらゆる場所から呼び出されることだけを想定して書いています。
VuetifyのUIコンポーネントは別として、こいつら自身はコンポーネントを呼び出したりはしません。いわば親コンポーネントの存在のみを想定した「末っ子コンポーネント」です。

こういう汎用的な末っ子たちをcomponents配下に直接作成するとなかなか捗る気がします。

併せて、Nuxt.jsの自動インポートを活用する

Nuxt.jsは、デフォルトでコンポーネントの自動インポートを行うことができます。
components直下なら名前をそのまま、ディレクトリ内にネストされてるコンポーネントはディレクトリ名を頭につければ呼び出せます。


// components/Link.vue
<Link href="https://qiita.com/inarikawa">筆者のQiita</Link>

// components/Article/Parts/Icon.vue
<ArticlePartsIcon />

これらの発展形としてのコンポーネント設計

トップページを構成するコンポーネントであるindex.vueに、実現したい画面のHTMLを書いてみます。

今回は、Qiitaに投稿した記事をAPIで取得・表示するサイトを作っています。
UIフレームワークとしてVuetifyを導入しています。

ページ上部に表示する「最新の記事」のマークアップ

index.vue
<template>
  <v-card 
    outlined
    class="mb-4"
  >
    <div id="newest-article-wrapper">
      <div id="newest-article-tagicon">
        <img src="@/assets/img/tagicons/nuxt.js.svg" alt="">
      </div>
      <div id="newest-article-info-wrapper">
        <v-card-title 
          class="text-xs-h6 text-md-h5"
        >
          <p>Nuxt.js+Vuetifyの機能を理解しながらヘッダーとスライドメニューをカスタマイズした記録。</p>
          <v-chip-group
            column
          >
              <v-chip class="text-xs-caption">nuxt.js</v-chip>
              <v-chip class="text-xs-caption">Vuetify</v-chip>
              <v-chip class="text-xs-caption">マテリアルデザイン</v-chip>
              <v-chip class="text-xs-caption">CSS</v-chip>
          </v-chip-group>
        </v-card-title>
          <div id="newest-article-reaction-wrapper-pc">
            <div class="text-caption">
              <span>
                <!-- LGTM -->
              </span>
              21
            </div>
            <div class="text-caption">
              <span>
                <!-- ストック -->
              </span>
              4
            </div>
            <div class="text-caption">
              <a href="https://qiita.com/inarikawa" target="_blank" rel="noopener noreferrer">Qiitaで開く</a>
            </div>
            <div class="text-caption">
              2020/12/04(更新)
            </div>
          </div>
      </div>
      <div id="newest-article-reaction-wrapper-mobile">
        <div class="text-caption">
          <span>
            <!-- LGTM -->
          </span>
          21
        </div>
        <div class="text-caption">
          <span>
            <!-- ストック -->
          </span>
          4
        </div>
        <div class="text-caption">
          <a href="https://qiita.com/inarikawa" target="_blank" rel="noopener noreferrer">Qiitaで開く</a>
        </div>
        <div class="text-caption">
          2020/12/04(更新)
        </div>
      </div>
    </div>
  </v-card>
</template>

<script>
  export default {
  }
</script>

<style lang="scss" scoped>
  #newest-article-wrapper {
    width: 100%;
    height: auto;
    display: grid;
    grid-template-rows: 192px;
    grid-template-columns: 192px 1fr;
    @include mq('sm') {
      grid-template-rows: 130px;
      grid-template-columns: 1fr;
    }
    @include mq('md') {
      grid-template-rows: 200px;
      grid-template-columns: 200px 1fr;
    }
    #newest-article-tagicon  {
      padding: 12px;
      display: flex;
      justify-content: center;
      align-items: center;
      img {
        width: 100%;
        height: 100%;
      }
    }
  }

  #newest-article-reaction-wrapper-pc {
    display: none;
    @include mq('md') {
      display: flex;
      div {
        display: flex;
        justify-content: center;
        align-items: center;
        margin-right: 20px;
        span {
          display: inline-block;
          width: 24px;
          height: 24px;
          border-radius: 50%;
          background: rgb(198, 198, 198);
          font-size: 10px;
          margin-right: 10px;
        }
      }
    }
  }
  #newest-article-reaction-wrapper-mobile {
    @include mq('md') {
      display: none;
    }
    display: flex;
    div {
      display: flex;
      justify-content: center;
      align-items: center;
      margin-right: 20px;
      span {
        display: inline-block;
        width: 24px;
        height: 24px;
        border-radius: 50%;
        background: rgb(198, 198, 198);
        font-size: 10px;
        margin-right: 10px;
      }
    }
  }
</style> 

つまりこんな画面になります。

スクリーンショット 2021-11-18 12.57.49.png
スクリーンショット 2021-11-18 12.58.18.png

最下部に横並びになっている要素は、左からLGTM数ストック数Qiitaで同じ記事を開くリンク投稿日or更新日を表示する想定です。
(グレーの丸は何かしらの画像にする予定です。決まってないのでCSSで丸くしたspan要素を暫定的に入れています)
(同タイトルの記事を投稿済みですが、LGTM・ストック数や投稿日付はダミーです)

各パーツをコンポーネントに切り出す

現状では、Vuetifyのカードコンポーネントに直接HTMLを詰め込んでいます。
これを一個のコンポーネント呼び出しで描画できるようにし、なおかつ同じレイアウトを簡単に流用できるようにするのがゴールです。

▼完成形

index.vue
<template>
  <div>
    <ArticleNewestCard />
  </div>

</template>

<script>
  export default {
  }
</script>

<style lang="scss">

</style>

各要素の洗い出し

この記事カードを構成する要素は以下の通りです。

  • 記事カードそのもの
  • アイコン(一番目のタグに書かれた技術のロゴを表示する想定)
  • タイトル
  • タグ
  • リアクションその他
    • LGTM数
    • ストック数
    • Qiitaで同じ記事を開くリンク
    • 投稿or更新日

これら全てをコンポーネント化し、さらにそれらを集結させる記事カードコンポーネントを作成することにします。

再利用することを想定したコンポーネント設計

なお、この記事カードのレイアウトは、最新記事以外の記事表示にも流用します。
なので「記事カードは常にこのUIですよ」ということを定義するための、いわば「記事カード用末っ子コンポーネント」を作成します。

ベースコンポーネントを作成

今回作るのは、汎用的に全体で使う細かいコンポーネントではなくあくまで「記事カードのUI定義」です。
なので純粋な末っ子というよりは「ベースとなるコンポーネント」という意味合いが近い。

これらの意味合いがわかるように名前をつけ、ディレクトリを作成します。

components
└── Common
    └── Article

Article内にCard.vueを作成します。

components/Common/Article/Card.vue
<template>
  <v-card 
    outlined
    class="mb-4"
  >
    <div id="article-wrapper">
      <slot></slot>
    </div>
  </v-card>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
  #article-wrapper {
    width: 100%;
    height: auto;
    display: grid;

    @include mq('sm') {
      grid-template-rows: 130px;
      grid-template-columns: 1fr;
    }

    @include mq('md') {
      grid-template-rows: 200px;
      grid-template-columns: 200px 1fr;
    }
  }
</style>

propsoutlinedを指定したVuetifyのカードコンポーネントをラップし、その中にdiv要素を作成。
なんの要素なのかわかりやすいような名前をつけ、この要素に対して必要なスタイルだけを転記しました。

このコンポーネントでは、記事カードの一番外側を決めたのみ。中に何が入ってくるかは定義していません。

パーツごとにコンポーネントを作成

▼このような構成になるよう、各パーツをコンポーネント化します。

components
└── Common
    └── Article
        ├──Parts
        │   ├── ChipGroup.vue
        │   ├── Info.vue
        │   ├── Link.vue
        │   ├── Reaction.vue
        │   ├── ReactionWrapper.vue
        │   ├── TagIcon.vue
        │   └── Title.vue
        └──Card.vue

例えば記事タイトルコンポーネントは、以下のように定義しています。

Common/Article/Parts/Title.vue
<template>
  <v-card-title 
    class="text-xs-h6 text-md-h5"
  >
    <p>
      <slot />
    </p>
  </v-card-title>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
  p {
    margin-bottom: 0;
  }
</style>

それぞれのコンポーネント名はTitle.vueなどと単純ですが、呼び出すときは以下のようになります。

<CommonArticlePartsTitle>
  タイトル
</CommonArticlePartsTitle>

このコンポーネントはどこにあるのか、そしてどんなコンポーネントなのかが何となくわかるようなコードにできました。
可読性を確保するためには、ディレクトリやコンポーネントの名前がわかりやすいことが重要になりそうです。そのへんは関数や変数といっしょですね。

ベースコンポーネントを使用先でさらにラップする

あまり調べてないのでわからないんですけど、個人的には/Common/Articleに作成したベースコンポーネントは、直接使わずに使用先でラップするのがいいんじゃないかと思います。
例えば今回の記事カードUIは、少なくとも「最新記事」と「それ以外の記事」に使用します。

そもそもベースコンポーネントを作ったのは、いろんな親から呼び出しやすいようにするため。
なるべくバラバラにしたのは、パーツの一部を使ったり使わなかったりしやすくするため。

さらにいえば使用先で再度ラップすることで、使用先ディレクトリがそのまま「パーツの一覧表」として機能してくれることも期待できます。

components
└── Article
    └── Newest
        ├──Parts
        │   └── ReactionWrapper
        │       ├── Mobile.vue
        │       └── PC.vue
        │   ├── ChipGroup.vue
        │   ├── Info.vue
        │   ├── Link.vue
        │   ├── Reaction.vue
        │   ├── ReactionWrapper.vue
        │   ├── TagIcon.vue
        │   └── Title.vue
        └──Card.vue

▲「最新記事」コンポーネントでは全てのパーツを使うので、全てラップします。

components/Article/Newest/Parts/Title.vue
// ベースコンポーネントをラップしただけの「最新記事タイトル」コンポーネント。
<template>
  <CommonArticlePartsTitle>
    <slot />
  </CommonArticlePartsTitle>
</template>

<script>
export default {

}
</script>
components/Article/Newest/Card.vue
// Common/Article/Card.vue を呼び出し、各ラッパーを挿入。
<template>
  <CommonArticleCard>
    <ArticleNewestPartsTagIcon>
      <img src="@/assets/img/tagicons/nuxt.js.svg" alt="nuxt.jsロゴマーク">
    </ArticleNewestPartsTagIcon>
    <ArticleNewestPartsInfo>
      <ArticleNewestPartsTitle>
        Nuxt.js+Vuetifyの機能を理解しながらヘッダーとスライドメニューをカスタマイズした記録。
        <ArticleNewestPartsChipGroup>
          <v-chip>nuxt.js</v-chip>
          <v-chip>Vuetify</v-chip>
          <v-chip>マテリアルデザイン</v-chip>
          <v-chip>CSS</v-chip>
        </ArticleNewestPartsChipGroup>
        <ArticleNewestPartsReactionWrapperPC />
      </ArticleNewestPartsTitle>
      <ArticleNewestPartsReactionWrapperMobile />
    </ArticleNewestPartsInfo>
  </CommonArticleCard>
</template>

<script>
export default {

}
</script>

これを、index.vueで呼び出すことで…

index.vue
<template>
  <div>
    <ArticleNewestCard />
  </div>

</template>

<script>
  export default {
  }
</script>

<style lang="scss">

</style>

スクリーンショット 2021-11-12 13.09.58.png
無事にリファクタリングできました。

もし、他のページ等で一部分だけのパーツを使って簡易表示したいときなどは、このようにすればいいわけです。

components
└── Article
    └── Simple
        ├──Parts
        │   ├── TagIcon.vue
        │   └── Title.vue
        └──Card.vue
Article/Simple/Card.vue
<template>
  <CommonArticleCard>
    <ArticleSimplePartsTagIcon>
      <img src="@/assets/img/tagicons/nuxt.js.svg" alt="nuxt.jsロゴマーク">
    </ArticleSimplePartsTagIcon>
    <ArticleSimplePartsTitle>
      Nuxt.js+Vuetifyの機能を理解しながらヘッダーとスライドメニューをカスタマイズした記録。
    </ArticleSimplePartsTitle>
  </CommonArticleCard>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>
/* なんやかんや */
</style>
<ArticleSimpleCard />

現時点ではまだAPIとの繋ぎ込みを行なっていないので、実際に動的にデータを反映させる上で問題が生じるかもしれません。
そのときはそのときですが、とりあえずコンポーネント設計の基礎を体験できたのでは?とは思います。

学んだことまとめ

  • 実現したいHTML構造に則って、いくつかの末っ子コンポーネントに分割すべし
  • 各親のディレクトリ内に子コンポーネントを設置し、末っ子をラップする
  • そうすると、各親の構造をファイル構成そのもので明示できるのでいいかもしれない
  • Nuxtの自動インポートに則した書き方をすると、どの末っ子を呼んでるのかわかりやすくなって一石二鳥
  • CSSはそれぞれの末っ子コンポーネントの中でscopedで記述すると、全体を汚染せずに済むので便利
  • HTMLタグをラップするだけでも、属性値やクラス名の記述が一回で済むので便利
  • Vuetifyのコンポーネントをラップするだけでも、ヘルパークラスやpropsの記述が一回で済むので便利