Vue コンポーネント設計とコーディング規約

こんにちは、HITO-Link 開発チームのテックリードの楳田 (@wwwumeda) です。

昨年の 10/2 に  HITO-Link パフォーマンス の大幅リニューアルがありました。 前回の記事では、クライアントサイドが技術的にどう変わったかについて全体的な概要を記載しましたが、 今回は詳細な話として、Vue のコンポーネント設計について記載します。

developers.hito-link.jp

はじめに

リニューアル前のコンポーネント設計は開発者依存でカオスな状態でした。 可読性、保守性が低く、バグがあった際にどのコンポーネントが原因なのか調査が難しい状態でした。

この反省を踏まえ、リニューアル時はコンポーネントをどう設計するかを規約として定義しました。

ゼロから独自に定義するのは大変なので、Vue のコンポーネント設計の記事をいくつか参考にし、 Atomic Design の導入してみましたが、途中でやめました。

やめた理由は以下のとおりです。

  1. Atomic Design は、コンポーネントの粒度を見た目で定義していますが、 開発の過程で、コンポーネントを見た目で分離するのでなく、役割で分離するほうが重要と感じたためです。 Atomic Design は、Atom, Molecule, Organism, Template, Pages の 5 層でコンポーネントを定義しますが、 どのコンポーネントにどこまでの役割をもたせるか定義されていません。
  2. Vue のフレームワーク(うちでは Vuetify)を導入しましたが、 Atomic Design で言うところの Atom や Molecule のほとんどをフレームワークが提供しており、開発するコンポーネントが Organism だらけになるため導入のメリットないと感じました。

というわけで、新しくコンポーネント設計の規約を定義しましたので、これについて記載していきます。 規約は今後も変更する可能性があります。

本記事に記載するサンプルは、TypeScript + vue-class-component+ vue-property-decorator の利用前提としています。 Vue コンポーネントは class コンポーネントとして定義し、 Vue のプロパティは @Prop などのデコレータで定義しています。

コンポーネントの定義ルール

画面の要素(見た目)ではなく、役割に応じて 3 階層で定義することとしました。

  • Page コンポーネント
  • View コンポーネント
  • Component コンポーネント

以下のようなイメージで親子関係を持ちます。

Page コンポーネント
└ View コンポーネント
  └ Component コンポーネント

画面の複雑度に応じて階層は増減しても良いことにしています。

Page コンポーネント
└ ParentView コンポーネント
  └ ChildView コンポーネント
    └ ParentComponent コンポーネント
      └ ChildComponent コンポーネント

次に各コンポーネントの役割を記載していきます。

Page コンポーネント

Page コンポーネントは、ブラウザに 1 つだけ配置される最上位のコンポーネントです。

Vuex や Router へアクセス可能とし、API 実行可能とします。

Vue ルーターの直下に定義するコンポーネントとなり、以下のように path が Vue ルーターのルートになります。

export default new VueRouter({
  routes: [
    {
      path: '/foo',
      component: () => import('@/pages/foo/FooPage.vue')
    }
  ]
});

View コンポーネント

View コンポーネントは、Page 内に配置されるコンポーネントです。

Vuex や Router へアクセス可能とし、API 実行可能とします。

必ず router-view を経由して配置することとし、以下のように Page コンポーネントの children として定義します。

export default new VueRouter({
  routes: [
    {
      path: '/foo',
      component: () => import('@/pages/foo/FooPage.vue')
      children: [
        {
          path: 'bar',
          component: () => import('@/views/bar/BarView.vue')
        }
      ]
    }
  ]
});
View in View

Page, View, Component の 3 層で定義すると View の役割が多く複雑になりがちなので、View 内に View を配置することを許可し、View の役割を分離できるようにしています。 ただし、View の template 内に View を直接配置することは禁止しています。

禁止する理由は、View コンポーネント間を疎結合にするためです。 また、本来 Component として定義すべきにもかかわらず、API を呼びたいとか Vuex へアクセスしたいとか、プロパティを受け渡すのが面倒を理由に Component でなく View として定義して安易な実装に逃げがちなので、それを抑止することにも繋がります。

<!-- ParentView -->
<template>
  <!-- ↓ View を直接配置することは禁止 -->
  <ChildView></ChildView>
  <!-- ↓ これが ChildView であってもOK -->
  <router-view></router-view>
</template>

Component コンポーネント

Page または View に配置されるコンポーネントです。

Vuex や Router へのアクセスは禁止とし、API 実行も禁止とします。

Component は親から受け取った情報を(必要に応じて加工して)表示したり、Component 内で発生したイベントを親へ伝達するだけとし、状態を持たないように設計します。

<!-- ParentView -->
<template>
  <!-- ↓ これはOK -->
  <ChildComponent></ChildComponent>
  <!-- ↓ これが ChildComponent であってもOK -->
  <router-view></router-view>
</template>
Core Component

Component は Vuex、Vue ルーターへの参照は禁止、API 呼び出しは禁止ですが、例外として機能横断的な汎用的な Component に限り許容します。

HITO-Link パフォーマンス を例にすると、OKR 機能、1on1 機能の Component では禁止ですが、社員情報、組織情報を表示するだけのコンポーネントであれば OK とします。

アクセス制限をかけるための実装上の工夫

Page, View は Vuex へアクセス可能、Component はアクセス不可と書いてきましたが、 実装してしまえば Component でもアクセスできるため、そうならないような実装を紹介します。

  1. 各コンポーネントの層に応じた親クラスを作成します。
  2. 各コンポーネントは自分に該当する親クラスを継承(mixin)します。
  3. 各親クラスは、操作可能な共通処理を定義しておきます。 例えば、ルーターの定義や認証情報(Vuex)を参照する定義を Page と View では用意しており、Component では用意していません。 これにより、Component はそういうものだと、後からチームへ参画する人へ意識付けができます。
// view-vue(Viewの親クラス)
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class ViewVue extends Vue {
  // 認証情報の getter(computed) を実装している
  public get auth() {
    return this.$store.state.auth;
  }
}
// component-vue(Componentの親クラス)
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class ComponentVue extends Vue {
  // 認証情報の getter(computed) を実装していない(実装して例外をスローするのもありかもしれない)
  // public get auth() {
  // }
}
// FooView
import { Component, Mixins } from 'vue-property-decorator';
import ViewVue from '@/mixins/view-vue';
@Component
export default class FooView extends Mixins(ViewVue) {} // View は ViewVue を継承する
// BarComponent
import { Component, Mixins } from 'vue-property-decorator';
import ComponentVue from '@/mixins/component-vue';
@Component
export default class BarComponent extends Mixins(ComponentVue) {} // Component は ComponentVue を継承する

操作権限とコンポーネント配置ルールのまとめ

各コンポーネントの操作権限は以下のとおりです。

コンポーネント 操作内容 可能かどうか
Page Vuex へのアクセス
Router へアクセス
API の実行
View Vuex へのアクセス
Router へアクセス
API の実行
Component Vuex へのアクセス
Router へアクセス
API の実行

各コンポーネントのコンポーネントの配置ルールは以下のとおりです。

コンポーネント 配置対象 配置可能かどうか 備考
Page Page Page は画面内で唯一のコンポーネント。
View router-view を指定して配置する。直接配置することは禁止。
Component そのまま配置可能。router-view を経由しても良い。
View Page Page は上位層なので配置禁止。
View router-view を指定して配置する。直接配置することは禁止。
Component そのまま配置可能。router-view を経由しても良い。
Component Page Page は上位層なので配置禁止。
View View は上位層なので配置禁止。
Component そのまま配置可能。router-view を経由しても良い。

イベントのやり取り/プロパティの受け渡し

親から子へ

親から子へは、プロパティの受け渡しのみを許可します。 親は子へ何かを命令することはできないこととし、親が子のメソッドを直接実行することは禁止とします。

親が子のメソッドを直接実行する NG 例
<!-- ParentView -->
<template>
  <ChildComponent ref="child" />
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import ChildComponent from '@/components/ChildComponent.vue';
  @Component({ components: { ChildComponent } })
  export default class ParentView extends Vue {
    public mounted(): void {
      (this.$refs.child as any).foo(); // 親が子のメソッドを直接実行しているが、これは禁止
    }
  }
</script>
<!-- ChildComponent -->
<template></template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  @Component
  export default class ChildComponent extends Vue {
    public foo(): void {}
  }
</script>

子から親へ

子から親へイベントを投げるときは Vue の emit を使います。 親から子へ関数をプロパティで渡し、子がその関数をコールバックとして実行することでも同じことができますが、その方法は禁止とします。

子が親から受け取ったメソッドを実行する NG 例
<!-- ParentView -->
<template>
  <ChildComponent :callback="foo" />
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import ChildComponent from '@/components/ChildComponent.vue';
  @Component({ components: { ChildComponent } })
  export default class ParentView extends Vue {
    public foo(): void {}
  }
</script>
<!-- ChildComponent -->
<template></template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  @Component
  export default class ChildComponent extends Vue {
    @Prop({ type: Function, required: true })
    public callback!: () => void;
    public mounted(): void {
      this.callback(); // 子が親のメソッドを直接実行しているが、これは禁止
    }
  }
</script>

親子関係以外(兄弟)のやり取り

以下のようなタグの構成で、EditorView が何かを変更し、ListView へ変更を通知したい場合は EventBus を使うこととします。

<!-- ParentView -->
<template>
  <!-- ListView -->
  <router-view></router-view>
  <!-- EditorView -->
  <router-view></router-view>
</template>
// ParentView
import { Component, Provide, Vue } from 'vue-property-decorator';
@Component
export default class ParentView extends Vue {
  @Provide('eventBus')
  public eventBus: Vue = new Vue(); // 親で eventBus のインスタンスを定義しておく
}
// ListView
import { Component, Inject, Vue } from 'vue-property-decorator';
export default class ListView extends Vue {
  @Inject({ from: 'eventBus', default: () => new Vue() })
  public eventBus!: Vue;
  public created(): void {
    this.eventBus.$on('edit', () => {}); // 変更を検知できるようにイベントをフックする
  }
}
// EditorView
import { Component, Inject, Vue } from 'vue-property-decorator';
export default class ListView extends Vue {
  @Inject({ from: 'eventBus', default: () => new Vue() })
  public eventBus!: Vue;
  public edit(): void {
    this.eventBus.$emit('edit'); // これにより、ListView へ変更を通知できる
  }
}

親が状態を持ち、子の入力を親へ反映させる

Component の再利用性を考えたときに、子(Component)では状態をもたず、親(View)で状態を管理したほうが良いです。 子の入力内容(状態)を子で保持せず、親へ反映させるサンプルを記載します。

シンプルなパターン(モデルの型がプリミティブな場合)

モデルの型がプリミティブな場合、入力内容のそのまま emit するだけです。

<!-- ParentView -->
<template>
  <ChildComponent v-model="model" />
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import ChildComponent from '@/components/ChildComponent.vue';
  @Component({ components: { ChildComponent } })
  export default class ParentView extends Vue {
    public model = '';
  }
</script>
<!-- ChildComponent -->
<template>
  <input :value="value" @input="$emit('input', $event.target.value)" />
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  @Component
  export default class ChildComponent extends Vue {
    @Prop({ type: String, default: '' })
    public value!: string;
  }
</script>

値がオブジェクトの場合

モデルの型がオブジェクトの場合、スプレッド構文を使うことでシンプルに実装できます。

<!-- ParentView -->
<template>
  <ChildComponent v-model="model" />
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import ChildComponent from '@/components/ChildComponent.vue';
  @Component({ components: { ChildComponent } })
  export default class ParentView extends Vue {
    public model = { key1: '', key2: '' };
  }
</script>
<!-- ChildComponent -->
<template>
  <div>
    <input
      :value="value.key1"
      @input="$emit('input', { ...value, key1: $event.target.value })"
    />
    <input
      :value="value.key2"
      @input="$emit('input', { ...value, key2: $event.target.value })"
    />
    <!-- 以下のように書いても動きますが、子が親の値を直接書き換えているのでNGです。 -->
    <!-- 親から受け取ったプロパティに代入すると Vue が警告をしてくれますが、
         以下のようにオブジェクト内の値を書き換えても警告してくれないため注意が必要です。 -->
    <input :value="value.key1" @input="value.key1 = $event.target.value" />
    <input :value="value.key2" @input="value.key2 = $event.target.value" />
  </div>
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  @Component
  export default class ChildComponent extends Vue {
    @Prop({ type: Object, default: () => ({ key1: '', key2: '' }) })
    public value!: { key1: string; key2: string };
  }
</script>

コンポーネントが入れ子の場合

コンポーネントの階層が深くなってもすることは同じです。

<!-- ParentView -->
<template>
  <ChildComponent v-model="model" />
</template>

<script lang="ts">
  import { Component, Vue } from 'vue-property-decorator';
  import ChildComponent from '@/components/ChildComponent.vue';
  @Component({ components: { ChildComponent } })
  export default class ParentView extends Vue {
    public model = { key1: '', key2: '' };
  }
</script>
<!-- ChildComponent -->
<template>
  <!-- 中間のコンポーネントは、受け取った値をそのまま親へ投げる -->
  <GrandchildComponent :value="value" @input="$emit('input', $event)" />
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  import GrandchildComponent from '@/components/GrandchildComponent.vue';
  @Component({ components: { GrandchildComponent } })
  export default class ChildComponent extends Vue {
    @Prop({ type: Object, default: () => ({ key1: '', key2: '' }) })
    public value!: { key1: string; key2: string };
  }
</script>
<!-- GrandchildComponent -->
<template>
  <div>
    <input
      :value="value.key1"
      @input="$emit('input', { ...value, key1: $event.target.value })"
    />
    <input
      :value="value.key2"
      @input="$emit('input', { ...value, key2: $event.target.value })"
    />
  </div>
</template>

<script lang="ts">
  import { Component, Prop, Vue } from 'vue-property-decorator';
  @Component
  export default class ChildComponent extends Vue {
    @Prop({ type: Object, default: () => ({ key1: '', key2: '' }) })
    public value!: { key1: string; key2: string };
  }
</script>

ルーター(URL)で画面の状態を維持

今回の設計では、コンポーネント間の依存関係を減らすために Vue ルーターを細かく定義していますが、 目的は他にもあり、細かく定義することで以下のような恩恵が得られます。

  1. ブラウザをリフレッシュしてもリフレッシュ前の状態を維持できるようになりました。 細かく定義するだけで維持できるわけではありませんので、実装でも意識する必要があります。
  2. リフレッシュ前の状態を維持できるので、任意の画面でブックマークできるようになりました。
  3. 未認証状態でブックマークから画面を開くと、ログイン画面を経由したあとでブックマークしている画面へ遷移しますが、 この実装が非常にシンプルにできました。 リニューアル前は、一部の画面しかサポートしておらず、また、実装も非常に複雑になっていましたが、 リニューアル後は、どの画面でも同じ処理で対応できています。

まとめ

HITO-Link パフォーマンス のコンポーネント設計の規約についてまとめました。 ブログと言うよりは、後から参画する開発メンバー向けのコーディング規約になりましたが、他のプロジェクトでも参考にしていただけたら幸いです。