個人的なVue.jsコンポーネント設計のベストプラクティス

Vue.jsを使ったフロントエンド開発において、コンポーネント設計は常に悩ましい課題です。単一責任の原則やAtomicデザイン、細かい分割の是非など、多くのアプローチがありますが、正解はありません。

しかし、ルールがないとプロダクトの品質が低下するリスクがあるため、「指針」が求められます。

この記事では、私自身が現場経験を通して磨いてきた「Page > Pane > Section > Parts単位」のコンポーネント設計手法をご紹介します。

私の考え方

フロントエンドのコンポーネント設計は、
どのような考え方/単位で設計すればいいのでしょうか?

私の場合、
「どうなっていれば楽か」
を基準にコンポーネント設計をしていきます。

ここでいう「楽」とは

  • 実装/管理が楽である
  • デバックが楽である
  • テストの作成/運用が楽である
  • 開発チーム運営が楽である

ことを意味しています。

その手法が、
Page > Pane > Section > Parts単位の設計です。

Page > Pane > Section > Parts単位の設計とは?

ここでは、私のコンポーネント設計方針である「Page > Pane > Section > Parts単位」について解説します。

それぞれの単位に対し、次のような役割を持たせています。

単位の役割

  • Page: ルーティング
  • Pane: APIアクセス、Storeへのデータ保持・参照
  • Section: Storeの参照、Partsの集合体
  • Parts: 表示や入出力を管理するAtom(原子)単位のバーツ
単位依存関係APIアクセスStoreアクセス
PagePane⭕️
PaneSection⭕️⭕️
SectionParts⭕️
PartsParts

フロントエンド開発をやっていると、開発期間に比例してコンポーネント数が増えていきます。

それぞれのコンポーネントが何をやっているのかを、正確に把握することは困難です。

明確な設計方針がないと、そうなります。

しかし、「各単位がどのような役割を持つべきか」をルールとして定めておけば、コンポーネントの管理やバグ発生時の原因の切り分けがしやすいです。

一番避けたいのは、色々なコンポーネントでAPIアクセスをやっていたり、Storeに接続していたりするスパゲッティな状態です。

このルールに基づくと、

例えば「Section」や「Parts」は、

  • 親コンポーネントやストアから供給されるデータが正常に表示されているか
  • 入出力のイベント処理やデータの受け渡しが正常に動作しているか

に責任を持たせることができます。

一方、「Pane」は、APIアクセスやデータ加工、ストアへの保存処理など、機能のメインロジックに集中できます。

「Page」は、ルーティングを制御して「Pane」を配信するだけなので、その他のロジックには関与しません。

各単位で責任が明確に分離しているということは、テストもしやすく、アプリケーションの品質と開発体験を向上できます。

【実践】どのように単位を分けるか

ここでは、具体例を用いて「Page > Pane > Section > Parts」に分割していきます。

以下の画像は、
私が開発しているプログラミングスクール検索サービスの管理画面です。

【コースの一覧画面】

【コースの詳細画面】

シングルページアプリケーションで構築しているので、以下のような構成になっています。

  • ルーティング
    • /course
  • 画面(機能)
    • 一覧
    • 新規作成編集

1つのルートに対して、2つの画面(機能)があります。

なので、以下のように分割していきます。

PageCoursePage.vue:
ルート/course を管理する

一覧/編集モードに合わせて
Paneの表示を切り替える。
PaneCourseListPane.vue:
コースの一覧画面を管理するPane

CourseEditPane.vue
コースの作成/編集画面を管理するPane
Section(一覧画面のみ解説)SchoolSelect.vue
スクールのセレクトボックスを管理

CourseListHeader.vue
ページングや新規作成のボタンを管理

CourseListBody.vue
コース一覧のテーブルを管理
Parts(CourseListBody.vueのみ解説)CourseRecord.vue
一覧のレコードを管理

CourseRecordButton.vue
複製・削除ボタンを管理

【分割イメージ】

ディレクトリ構成にすると以下のようなイメージになります。

src/
    components/
        course/
            page/
                CoursePage.vue
            pane/
                CourseListPane.vue
                CourseEditPane.vue
            section/
                list/
                    SchoolSelect.vue
                    CourseListHeader.vue
                    CourseListBody.vue
                edit/
            parts/
                list/
                    CourseRecord.vue
                    CourseRecordButton.vue
                edit/

もしくは、「Nuxt.js」のようにcomponentsとpagesで分けてもいいです。

src/
    components/
        course/
            pane/
            section/
            parts/
    pages/
        course/
            index.vue // ← これでルーティングを管理する

この設計をすれば、ディレクトリ構成にも意味を持たせることができるので、管理とデバックが楽になります。

また、開発チーム運営の面でも、このように方針をまとめておくと認識を合わせることができます。

  • →設計方針を明確にする
  • →共通認識をもてる
  • →誰が実装しても同じ構成になる = 属人化しない
  • →管理/デバックがしやすい
  • →品質に一貫性が生まれる

のフローを実現できると思います。

設計通り実装してみる

実際に以下のディレクトリ構成でコンポーネントを配置してみます。

src/
    components/
        course/
            page/
                CoursePage.vue
            pane/
                CourseListPane.vue
                CourseEditPane.vue
            section/
                list/
                    SchoolSelect.vue
                    CourseListHeader.vue
                    CourseListBody.vue
                edit/
            parts/
                list/
                    CourseRecord.vue
                    CourseRecordButton.vue
                edit/

詳細のロジックは記載しないので、
Page > Pane > Section > Partsの構造を理解することを意識してください。

Page

@/src/components/course/page/CoursePage.vue

/course のルートを管理し、一覧 or 編集画面を切り替える責任を持ちます。

<template>
  <div>
    <CourseListPane v-if="isListMode" />
    <CourseEditPane v-else />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import CourseListPane from '../pane/CourseListPane.vue';
import CourseEditPane from '../pane/CourseEditPane.vue';

// ルーティングで一覧か編集を決定
const isListMode = ref(true); // ルーティングや条件で切り替え
</script>

Pane

@/src/components/course/pane/CourseListPane.vue

コースの一覧画面を管理し、Sectionに責任を分離します。データの取得や更新など、APIに関連する処理などはこちらで実施します。

<template>
  <div>
    <SchoolSelect />
    <CourseListHeader />
    <CourseListBody />
  </div>
</template>

<script setup>
import SchoolSelect from '../section/list/SchoolSelect.vue';
import CourseListHeader from '../section/list/CourseListHeader.vue';
import CourseListBody from '../section/list/CourseListBody.vue';
</script>

@/src/components/course/pane/CourseEditPane.vue

コースの編集画面を管理するPaneです。

<template>
  <div>
    <!-- コース編集画面用のSectionやPartsをここに配置する -->
    <p>Course Edit Screen</p>
  </div>
</template>

<script setup>
</script>

Section

@/src/components/course/section/list/SchoolSelect.vue

コースの一覧画面におけるスクール選択ボックスを管理します。

<template>
  <div>
    <label for="school">School:</label>
    <select id="school">
      <option value="1">School A</option>
      <option value="2">School B</option>
    </select>
  </div>
    <div>
        <button>外部リンク</button>
    </div>
</template>

<script setup>
</script>

@/src/components/course/section/list/CourseListHeader.vue

コース一覧のページングや新規作成ボタンを管理します。

<template>
  <div>
    <button @click="onCreateCourse">新規作成</button>
    <p>Page 1 of 5</p> <!-- ページング要素 -->
  </div>
</template>

<script setup>
const onCreateCourse = () => {
  console.log('New course creation initiated');
};
</script>

@/src/components/course/section/list/CourseListBody.vue

コース一覧のテーブルを表示し、個別のコースレコードを管理するPartsに分割します。

<template>
  <table>
    <tbody>
      <CourseRecord
          v-for="course in courses"
          :key="course.id"
          :course="course"
         />
    </tbody>
  </table>
</template>

<script setup>
import { ref } from 'vue';
import CourseRecord from '../../parts/list/CourseRecord.vue';

const courses = ref([
  { id: 1, name: 'Vue.js Basics' },
  { id: 2, name: 'Advanced Vue.js' }
]);
</script>

Parts

@/src/components/course/parts/list/CourseRecord.vue

コース一覧の1つのレコード(行)を管理します。

<template>
  <tr>
    <td>{{ course.id }}</td>
    <td>{{ course.name }}</td>
    <td>
      <CourseRecordButton label="Edit" />
      <CourseRecordButton label="Delete" />
    </td>
  </tr>
</template>

<script setup>
import CourseRecordButton from './CourseRecordButton.vue';

defineProps({
  course: Object
});
</script>

@/src/components/course/parts/list/CourseRecordButton.vue

レコードごとのボタンを管理します。

<template>
  <button @click="onClick">{{ label }}</button>
</template>

<script setup>
defineProps({
  label: String
});

const onClick = () => {
  console.log(`Button clicked: ${label}`);
  // ここでemitを使ってカスタムイベント発火する
};
</script>

以上の構成になります。

テスト設計のしやすさについて

私が「Page > Pane > Section > Parts」単位の設計をしている理由として、テストのしやすさがあります。最後に、この観点に触れておきましょう。

私の設計は、コンポーネントテストを容易にします。

E2Eテストやユニットテストはやっているけど、コンポーネントテストはやっていない。

このような現場が多いです。

なぜ、コンポーネントテストをやらないのか。

それは、明確なコンポーネント設計方針がないので、コンポーネントの構成も粒度もバラバラになってしまっているからです。その結果、同じ見た目なのに微妙に動きが違ったり、コードが肥大化したりします。

私の設計手法を採用すると、Storybookでのコンポーネント設計が容易に実装でき、デザイナーとの連携も深めることができます。

Storybookとの連携

ここでは、Section 、Partsにフォーカスしてください。

これまで説明してきた通り、SectionとPartsは、コンポーネント単体でAPIと通信したり、ストアのデータを更新することもありません。

Partsは最小単位のコンポーネントとして表示の役割をもち、
SectionはPartsの集合体としての役割を持ちます。

ということは、
Storybookを活用したコンポーネントテストを
ロジックの開発と独立した形で実施できます。

Parts単位でストーリを作成し、
input部品などのUI検証をします。

そして、
Section単位で複合的なストーリーを作成し、
各Partsが連携した状態で、正常に動作しているかを確認できるのです。

Storybook上でコンポーネントを開発するため、
デザイナーも早期にUIの検証に参加できます。

このように、
ロジックに影響を受けずに
コンポーネントテストができる点でも
「Page > Pane > Section > Parts」単位で設計する理由です。

まとめ

コンポーネント設計において、明確な正解はありません。しかし、効果的な基準を持つことで、開発の効率や品質を大きく向上させることができます。私の提案する「Page > Pane > Section > Parts単位」の設計手法は、各コンポーネントの責任を明確にし、開発・管理のしやすさを追求したものです。このアプローチを導入することで、ディレクトリ構成にも一貫性が生まれ、チーム全体で共通の認識を持ち、誰が開発しても同じような構造に落とし込むことができます。

この記事は役に立ちましたか?

もし参考になりましたら、下記のボタンで教えてください。

関連記事

コメント

この記事へのトラックバックはありません。