6
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

複雑な画面開発に立ち向かう時に捗るVue Composition APIのTips

本記事は CBcloud Advent Calendar 2020 の9日目の記事です。


こんにちは。
現場でNuxtを使ってゴリゴリとフロントを書いているdadayamaです。
VueのComposition APIは少し前から気になっていたのですが、正式版がリリースされたのをいいことにすぐに実務で導入しました。

  • コンポーネントの描画(View)と状態・ロジック(View Model)の分離
  • 関心事の集約
  • ロジックの再利用
  • 型推論の強化(要はTSフレンドリー)

といったメリットはがあるということは公式ドキュメントや紹介記事で理解していましたが、実際に導入した結果、Composition APIは複雑な状態やロジックを持つ画面を開発する際に非常に有効だと改めて納得できました。
今回は表題の通り、そういった複雑な画面を作る時に有効だった手法をTipsとして紹介します。

前置き

  • 実際のコードは載せていません。本記事用に書いたサンプルです
  • 実務ではNuxtを利用していますが、NuxtがVue3.x系に対応しきっていないため、Composition API単体のライブラリを導入しています
  • Composition APIに関して詳細な説明は行いません。優れた紹介記事を書いてくれている方が多いので割愛します

Composition APIとは?

Vue3.x系で導入された新しい機能です。
これまでVueコンポーネントでView Modelを実装するためにはOptions APIという機能を利用する必要がありましたが、それを代替するような形で登場しました。

値を増減させる簡単なカウンターコンポーネントを例にすると以下のような感じです。

Options API ver.

Counter.vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Doubled: {{ doubled }}</p>
    <button type="button" @click="increment">Increment</button>
    <button type="button" @click="decrement">Decrement</button>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';

export default Vue.extend({
  data: (): { count: number } => {
    return {
      count: 0,
    };
  },
  computed: {
    doubled(): number {
      return this.count * 2
    },
  },
  methods: {
    increment(): void {
      this.count++;
    },
    decrement(): void {
      if (this.count > 0) {
        this.count--;
      }
    },
  },
});
</script>

Composition API ver.

Counter.vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Doubled: {{ doubled }}</p>
    <button type="button" @click="increment">Increment</button>
    <button type="button" @click="decrement">Decrement</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';

export default defineComponent({
  setup() {
    const count = ref(0);
    const doubled = computed(() => count.value * 2);
    const increment = (): void => {
      count.value++;
    };
    const decrement = (): void => {
      if (count.value > 0) {
        count.value--;
      }
    };

    return {
      count,
      doubled,
      increment,
      decrement,
    };
  },
});
</script>

上記のように関心事をまとめるため、可読性があがります。また基本的にはただの関数なのでテスタビリティも高いです。

詳細は以下記事を参考に。

参考

複雑な画面でComposition APIを利用する時のTips

本題です。
私が主に実装を手掛けた画面は、以下のような仕様になっていました。

  1. 状態変化のパターンが多岐にわたる
  2. 様々な状態ごとに入力バリデーションのロジックが変わる
  3. 複雑な条件が揃うと画面遷移なしにAPIを叩く
  4. ↑のAPIの戻り値を反映し、再度状態を書き換える

このような複雑な仕様を複雑なまま実装しないように取り入れた手法を書き連ねます。

できる限り状態とロジックをComposableに寄せる

コンポーネントから状態とロジックを引き剥がし、関数として別のファイルに寄せるよう徹底しました。
ちなみに切り出した関数はComposableと呼ぶようです。
再利用可能な部品、アプリケーションを構成することができる部品、といった意味で利用していると思うので、例に習います。

先程のカウンターコンポーネントを例にするとこうなります。

composables/useCounter.ts
import { ref, computed, Ref } from '@vue/composition-api';

export const useCounter = (): {
  count: Ref<number>
  doubled: Ref<number>
  increment: () => void
  decrement: () => void
} => {
  const count = ref(0);
  const doubled = computed(() => count.value * 2);
  const increment = (): void => {
    count.value++;
  };
  const decrement = (): void => {
    if (count.value > 0) {
      count.value--;
    }
  };

  return {
    count,
    doubled,
    increment,
    decrement,
  };
};
Counter.vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Doubled: {{ doubled }}</p>
    <button type="button" @click="increment">Increment</button>
    <button type="button" @click="decrement">Decrement</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { useCounter } from '~/composables/useCounter';

export default defineComponent({
  setup() {
    const {
      count,
      doubled,
      increment,
      decrement,
    } = useCounter();

    return {
      count,
      doubled,
      increment,
      decrement,
    };
  },
});
</script>

別ファイルにしただけで何が良いのか?と思う方もいるかもしれませんが、以下の点で優れています。

  1. ロジックを再利用できる
  2. テストが容易
  3. コンポーネントの記述量が減り、可読性が上がる

個人的には2が大きいです。
複雑な状態管理をコンポーネントから引き剥がしてテストすることができるため、テスト実装が容易です。
それこそコンポーネントが無くても実装が可能なので、ロジックだけ先行してTDDで開発することもありだと思います。

できる限り関心事の単位でComposableを分割する

アプリケーションロジックが大きく複雑な場合、Composableが肥大化してしまいます。
そのため関心事単位でComposableを分割して可読性を下げないようにしました。

以下はA・Bの2地点の場所の管理と、作業時間の管理を分けた例です。

composables/useTimes.ts
import { reactive, computed } from '@vue/composition-api'

/**
 * A地点とB地点の時間管理Composable
 */
export type Times = { hour: number }

export type TimeConditions = {
  start: Times // 作業開始時間
  end: Times // 作業終了時間
}

export type TimesState = {
  a: TimeConditions // A地点の作業時間
  b: TimeConditions // B地点の作業時間
}

export const useTimes = (): {
  timesState: TimesState
} => {
  const state: TimesState = reactive({
    a: {
      start: {
        hour: 0,
      },
      end: {
        // A地点の終了時間は開始時間の1時間後
        hour: computed(() => state.a.start.hour + 1),
      },
    },
    b: {
      start: {
        // B地点の開始時間はA地点の開始時間の2時間後
        hour: computed(() => state.a.end.hour + 2),
      },
      end: {
        // B地点の終了時間は開始時間の1時間後
        hour: computed(() => state.b.start.hour + 1),
      },
    },
  });

  return {
    timesState: state,
  };
};
composables/useSpots.ts
import { reactive, toRefs, Ref } from '@vue/composition-api'
import { useTimes, TimesState } from '~/composables/useTimes';

/**
 * A地点とB地点の全体管理Composable
 */
type Spot = {
  name: string // 地点名
  time: TimesState
}

type SpotsState = {
  a: Spot // A地点
  b: Spot // B地点
}

type SpotsRef = Ref<SpotsState>

export const useSpots = (): {
  spotsRef: SpotsRef
} => {
  const { timesState } = useTimes();

  const state: SpotsState = reactive({
    a: {
      name: '',
      // useTimesから取得
      time: timesState.a,
    },
    b: {
      name: '',
      // useTimesから取得
      time: timesState.b,
    }
  });

  return {
    spotsRef: reactive({ spotsRef: state }),
  };
};

分割したことで、地点管理のComposableは時間の内訳を知る必要が無くなりました。
勿論わざわざuseSpots内でuseTimesを呼び出してStateに結合しなくてはいけない、といった訳ではないので、その辺りは適宜調整してもらえればと思います。

異なる関心事(Composable)への入力を監視する

画面全体の状態を監視しエラーを表示するといったケースがあった場合、複数のComposableの状態をエラー管理Composableが知る必要が出てきます。
この対応策として、今回は以下の方法を利用しました。

  1. provideinjectを使う
  2. エラー管理Composableの引数に他のComposableを渡す

実際には以下の通りです。

useInput.ts
/**
 * 入力管理Composable
 */
import { reactive, Ref, InjectionKey } from '@vue/composition-api';

export type InputState = {
  name: string
}

export const useInput = (): {
  inputState: InputState
} => {
  const state: InputState = reactive({
    name: '',
  });

  return { state };
};

// コンポーネント間で共有するためのキー設定
export type InputComposable = ReturnType<typeof useInput>
export const InputComposableKey: InjectionKey<InputComposable> = Symbol();
components/InputName.vue
<template>
  <input type="text" v-model.sync="inputState.name" />
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { InputComposable, InputComposableKey } from '~/composables/useInput';

export default defineComponent({
  setup() {
    // injectで親コンポーネントと状態を共有する
    const { inputState } = inject(inputKey) as InputComposable;
    return { inputState };
  },
});
</script>
composables/useErrors.ts
/**
 * エラー管理Composable
 */
import { reactive, computed } from '@vue/composition-api'
import { InputState } from '~/composables/useInput';

type Errors = {
  inputError: string | null
}

export const useErrors = (
  // コンポーネントから他のComposableの状態を引数で受け取る
  inputState: InputState
): {
  errors: Errors
} => {

  const errors: Errors = reactive({
    inputError: computed(() => {
      // 受け取った引数のComposableの値を判定に使う
      return inputState.name === '' ? '未入力です。' : null
    }),
  });

  return { errors };
};
components/Errors.vue
<template>
  <input-name />
  <div class="errors">
    <template v-for="error in errors" :key="error">
      <p v-if="error">{{ error }}</p>
    </template>
  </div>
</template>

<script lang="ts">
import { defineComponent, provide } from '@vue/composition-api';
import { useInput, InputComposableKey } from '~/composables/useInput';
import InputName from '~/components/InputName.vue';

export default defineComponent({
  components: {
    InputName,
  },
  setup() {
    // provideで子コンポーネントと状態を共有する
    provide(InputComposableKey, useInput())

    // provideした状態をinjectで取得する
    const { inputState } = inject(inputKey) as InputComposable;

    // 共有されているComposableの状態を引数として渡す
    const { errors } = useErrors(inputState);

    return { errors };
  },
});
</script>

エラー管理Composableが他のComposableを監視できていることが分かりますでしょうか。
仮に監視項目が増えたとしてもエラー管理Composableの引数を増やせばすぐに監視できるので、拡張も容易です。

こうすることでエラーのことはエラー管理Composableが、入力等はその他のComposableが責任を持っている状態を作れます。
餅は餅屋ってことですね。

ちなみに何で面倒な引数渡しをしているかというと、provideinjectを用いず呼び出されたComposableはスコープが異なってしまうからです。
詳細は「Vue3 Composition APIにおいて、Providerパターン(provide/inject)の使い方と、なぜ重要なのか、理解する。」に素晴らしくよくまとまっているので、見てもらったほうが早いです。

Composableを完全にグローバルに公開してしまえば実装は楽なのですが、そうなるとグローバル変数が大量に発生してしまうので避けました。

Composableをテストする

これは簡単で、Jestなりのテストフレームワークを使ってComposableをテストするだけです。
ただの(リアクティブな)変数と関数のセットでしかないので、入出力のチェックをすれば終わります。
もし1つ前のTipsに挙げたような「引数渡し」があったとしても、テストファイルから呼び出して渡せばいいだけです。
簡単ですね。

ただ(無いとは思いますが)、万が一provideinjectをComposableから呼び出す実装になっていた場合、これは避けたほうが良いと思います。
これら2つの関数はコンポーネントのライフサイクルに依存しているため、コンポーネント抜きでprovideinjectはうまく動きません。
当然Composableのみのテストではコケます。

まとめ

ダラダラと長くなりましたが、私が実務で取り入れたComposition APIの活用方法をTipsとして紹介させてもらいました。
これらの手法のおかげでアプリケーションロジックがあるべきファイルに集約され、可読性やテスタビリティが大きく向上したと思います。
これを読んだ方々のお役に立てれば幸いです。

ただ正式版がリリースしたとはいえ、Composition APIはまだ出て間もない機能です。
そのため使い方に関して改善点や誤りがありましたらツッコんでもらえると嬉しいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
6
Help us understand the problem. What are the problem?