🌈

【Vue.js】コンポーネントをComposable関数(カスタムフック)から提供する

2022/05/09に公開

『コンポーネントをカスタムフックで提供してみた』という記事に感動したので、Vue.js(以下Vue)で利用する方法を紹介します。概念などは当該記事にて詳しく書いてあるので、この記事では主に実装と個人的な感想を述べます。

https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/

Vue.jsバージョン

Vueでもバージョン3から正式導入されているComposition APIを使えば当該記事のようにカスタムフック、Vueでの名称はコンポーザブル関数(Composable Function)に切り分けることができます。ただし、VueはJSXそのままではテンプレート内で使えないので、少し工夫が必要です。

ソースコード全体はこちら

▼Checksコンポーネント

<script setup lang="ts">
type Props = {
  checkList: readonly boolean[];
  labels: readonly string[];
  onCheck: (index: number) => void;
};
defineProps<Props>();
</script>

<template>
  <ul>
    <li v-for="(item, index) in checkList" :key="index">
      <label>
        <input
          type="checkbox"
          @change="
            (e) => {
              onCheck(index);
            }
          "
          :checked="item"
        />
        {{ labels[index] }}
      </label>
    </li>
  </ul>
</template>

▼親コンポーネント

<script setup lang="ts">
import { useChecks } from "./composables/useChecks";
// コンポーザブル
const { isAllChecked, UseChecksComponent } = useChecks();
</script>

<template>
  <section>
    <div>
      <UseChecksComponent />
    </div>
    <button :disabled="!isAllChecked">次へ</button>
  </section>
</template>

▼コンポーザブル関数

import { computed, defineComponent, h, ref } from "vue";
import Checks from "../components/Checks.vue";

export const useChecks = () => {
  const checkList = ref([false, false, false]);
  const labels = ["check 1", "check 2", "check 3"];
  const handleCheck = (index: number) => {
    checkList.value[index] = !checkList.value[index];
  };
  const isAllChecked = computed(() => {
    return checkList.value.every((item) => item);
  });

  const render = () =>
    h(Checks, { checkList: checkList.value, labels, onCheck: handleCheck });
    
  const UseChecksComponent = defineComponent({ render });

  return { isAllChecked, UseChecksComponent };
};

コード解説

子コンポーネントたるChecksコンポーネントは記事のChecksコンポーネントをVueに書き換えたものです。特に独自の工夫はありません。

親コンポーネントはuseChecksコンポーザブル関数から呼び出したisAllChecked`とUseChecksComponent`コンポーネントを使います。useChecks関数は純粋なJavaScript(TypeScript)関数ですが、Vueコンポーネントを返すことができます(後述)。

コンポーザブル関数はcheckListuseState()の代わりにref()で状態管理しています。handleCheck()ref()に応じた形で少し変えています。

一番の変更点はコンポーネント描画のための関数です。Vueに用意されている、仮想ノードを返すh()関数を使ってコンポーネント化しています。

const render = () =>
    h(Checks, { checkList: checkList.value, labels, onCheck: handleCheck });

h()関数は、第1引数にはVueコンポーネントを渡せ、第2引数にはコンポーネントのpropsをオブジェクトベースで渡せます。そのためこのコンポーザブル関数内でコンポーネントの管理ができます。

const UseChecksComponent = defineComponent({ render });

render変数に格納したh()関数の返り値はコンポーネントのレンダー関数に使えます。Vueの場合は<template>内で使用するためにdefineComponent()関数を使ってコンポーネント化する必要があります。このdefineComponent()関数の引数、renderプロパティにこの返り値を渡すことでコンポーネントを作成できます。

このコンポーネント化したUseChecksComponentuseChecks()関数から返すことで、親コンポーネントから呼び出して<template>でコンポーネントとして利用します。コンポーネント化して返すという部分がVue独特の作法になると思います。

コンポーザブル化のメリット

コンポーザブル化のメリットはコンポーネントからロジックを切り出せることです。たとえば、汎用テキスト入力コンポーネントを作った場合、type属性であったり、バリデーター・バリデーションメッセージであったりといったパターンが欲しくなります。これをコンポーネント側にもたせても良いですが、その分だけコンポーネントに記述する必要があります。

▼コンポーネント側にパターンをもたせた場合

<script lang="ts" setup>
type Props = {
  type: string;
};
const props = defineProps<Props>();

const data = ref("");

const validate = (text:string)=>{
  let regex;
  switch(props.type){
    case "email": regex = /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/gi;
    case "tel" : regex = /^0[-0-9]{9,12}$/gi;
    // etc...
  }
  return regex.test(text);
}

 const isValid = computed(() => {
    if(data.value === ""){
      return true
    }
    return validate(data.value)
  });
  
const invalidMessage = computed(()=>{
  switch(props.type){
    case "email": return "メールアドレスを入力してください";
    case "tel" : return "電話番号を入力してください";
    // etc...
  }
})
</script>

<template>
  <div>
    <input :type="type" v-model="data" />
    <p v-show="!isValid" class="invalidMessage">{{ invalidMessage }}</p>
  </div>
</template>

パターンの数だけ増えてバリデーターやらメッセージが増えていきます。これをコンポーザブルに切り出します。

▼インプットコンポーネント

<script lang="ts" setup>
type Props = {
  type: string;
  value: string;
  invalidMessage: string;
  onChange: () => void;
  isValid: boolean;
};
defineProps<Props>();
</script>

<template>
  <div>
    <input :type="type" :value="value" @change="onChange" />
    <p v-show="!isValid" class="invalidMessage">{{ invalidMessage }}</p>
  </div>
</template>

▼useEmailInput.ts

import { computed, defineComponent, h, ref, resolveComponent } from "vue";
import TextInput from "../components/TextInput.vue";

export const useEmailInput = () => {
  const data = ref("");
  const onChange = ($event: InputEvent) => {
    data.value = ($event.target as HTMLInputElement).value;
  };
  const validate = (text: string) => {
    const regex =
      /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/gi;
    return regex.test(text);
  };

  const isValid = computed(() => {
    if(data.value === ""){
      return true
    }
    return validate(data.value)
  });

  const render = () =>
    h(TextInput, {
      type: "email",
      value: data.value,
      invalidMessage: "メールアドレスを入力してください",
      onChange,
      isValid: isValid.value,
    });
  const EmailInput = defineComponent({ render });
  return {
    EmailInput,
    emailValue: computed(() => data.value),
    isEmailValid: isValid.value,
  };
};

▼useTelInput.ts

import { computed, defineComponent, h, ref, resolveComponent } from "vue";
import TextInput from "../components/TextInput.vue";

export const useTelInput = () => {
  const data = ref("");
  const onChange = ($event: InputEvent) => {
    data.value = ($event.target as HTMLInputElement).value;
  };
  const validate = (text: string) => {
    const regex = /^0[-0-9]{9,12}$/gi;
    return regex.test(text);
  };

  const isValid = computed(() => {
    if (data.value === "") {
      return true;
    }
    return validate(data.value);
  });

  const render = () =>
    h(TextInput, {
      type: "tel",
      value: data.value,
      invalidMessage: "電話番号を入力してください",
      onChange,
      isValid: isValid.value,
    });
  const TelInput = defineComponent({ render });
  return {
    TelInput,
    telValue: computed(() => data.value),
    isTelValid: isValid.value,
  };
};

コンポーザブル関数に切り出すことでインプットコンポーネントはpropsを受け取るだけのシンプルなコンポーネントになりました。バリデーターなどはコンポーザブル関数が行います。親コンポーネントは入力値と妥当性だけ知りたいので、それらだけreturnで返しています。パターンが増えてもコンポーザブル関数を増やしていけば対応可能です。

上記の例ではコンポーネントのロジックが、バリデーターとメッセージの出し分けくらいなので切り出す旨味は少ないですが、インプットにまつわるロジックが増えてくるとその真価を発揮するでしょう。

そのほか、見た目は同じだけど、微妙に振る舞いが違うような場合も、見た目はコンポーネント、振る舞いはコンポーザブルに分けることで解決できます。コンポーザブル化(カスタムフック化)のメリットは見た目と振る舞いを分離できることにあると思います。

まとめ

Composition APIの登場でロジックの分離が容易になりました。これらを活用することで保守性や見通しの良いコードになります。元記事の関数からコンポーネントという概念は目から鱗でした。このテクニックを使えばより関心の分離を心がけたコードができそうです。

参考

https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/

https://ics.media/entry/210929/

Discussion