🎃

【Vue.js】そのコンポーネント、太ってない?

2023/09/15に公開

【Vue.js】そのコンポーネント、太ってない?

サービス開発でVueに浸かり始めてもうすぐ8ヶ月くらいになりますが、
当初に自分が書いたコンポーネントファイルをみてビックリすることが多い。

「超太ってる!!」(訳:コード記述量が多すぎる)

というわけでコンポーネントファイルをスリムボディにするべく、
なんでもかんでもコンポーネントファイルに処理を記述するのはやめてみようよというお話です。

この記事のターゲット

  • Vueの使い方がなんとなくでもわかる人
  • コンポーネントファイルが肥大化しすぎて困ってる人

この記事のゴール

コンポーネントファイル内の処理を表示ロジック・処理ロジックの2つに分割し、
表示ロジックはコンポーネント内、処理ロジックはcomposable配下にモジュールを作って定義。
これによってコードの可読性向上ロジックの再利用性向上を目指すのがゴールです。

※この記事での表示ロジック・処理ロジックは下記のようなことを指しています。

  • 表示ロジック:特定のコンポーネントやUIの構造に依存する処理
    • DOM要素の操作
    • イベントハンドラーによる処理 など
  • 処理ロジック:特定のコンポーネントの表示やUIに直接依存しない処理
    • データの処理や計算
    • バリデーション など

前提

Vueは3系、CompositionAPI使用前提でサンプルコードを記述しています。

太ったコンポーネントファイルって?

サンプルとしてフォームをコンポーネントとした場合の簡単なコードを書いてみました。
実際に自分が作ったのは500行もあるようなフォームのコンポーネントでしたが...

機能としてはまずは下記を想定して書いてみました。

  • 名前・メールアドレス・電話番号を入力するフォーム
  • それぞれの項目はv-modelで双方向バインディングさせる
  • フォームタイトルはページによって異なる(例:toCフォーム/toBフォーム)
  • 項目の入力ごとにバリデーションを実行する
    • 名前:10文字以内の文字列型の値であるか
    • メールアドレス:入力値がメール形式になっているか
    • 電話番号:11桁以内の半角数字の値であるか
  • 送信ボタンクリック時に下記のバリデーションを実行する
    • 項目ごとのバリデーションエラーが発生していないか
  • バリデーションを通過したら特定のAPIに対してデータを送信する
  • バリデーションでエラーが発生したら対象の項目までスクロールさせる
  • 送信完了後にはフォームを非表示にし、お礼のテキストを画面に表示する

テキトウにスタイルをつけて下記のような感じです。(フォントはスルーで...)

// Form.vue
<script setup>

import { ref, computed } from 'vue';

const props = defineProps({
    formTitle: {
        type: String,
        required: true
    }
});

// フォームDOM
const formElement = ref();

// 各項目のリアクティブ変数
const name = ref("");
const email = ref("");
const tel = ref("");

// フォーム送信状態のリアクティブ変数
const isFormSubmitted = ref(false);

// "名前" バリデーションメッセージ
const nameErrorMsg = computed(() => {
    if (name.value && name.value.length > 10) {
        return "名前は10文字以内で入力してください";
    }
    return null;
});

// "メールアドレス" バリデーションメッセージ
const emailErrorMsg = computed(() => {
    const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
    if (email.value && !emailRegex.test(email.value)) {
        return "正しいメールアドレスを入力してください";
    }
    return null;
});

// "電話番号" バリデーションメッセージ
const telErrorMsg = computed(() => {
    const telRegex = /^\d{1,11}$/;
    if (tel.value && !telRegex.test(tel.value)) {
        return "正しい電話番号を入力してください";
    }
    return null;
});


/**
 * フラグ:フォーム項目にエラーが残存するか
 * @type {ComputedRef<unknown>}
 */
const isExistError = computed(() => nameErrorMsg.value || emailErrorMsg.value || telErrorMsg.value);


/**
 * 対象のDOM要素へスクロール
 * @param targetElement
 */
const scrollToElement = (targetElement) => {
    targetElement.scrollTo({ behavior: 'smooth' });
};

/**
 * 送信ボタンクリックイベント
 */
const submitForm = () => {
    if (isExistError.value) {
        // エラー項目が残存する場合はその項目へスクロール
        scrollToElement(formElement.value.querySelector('.isAlert'))
    } else {
        // バリデーション通過した場合の処理
        // 特定のAPIへ送信する処理など

        // 送信完了のフラグをONへ
        isFormSubmitted.value = true;
    }
};

</script>
<template>
    <div>
        <h1 class="form-title">{{ formTitle }}</h1>
        <!-- フォーム送信前 -->
        <template v-if="!isFormSubmitted">
            <div class="form" ref="formElement">
                <div :class="['form__item', {'isAlert': nameErrorMsg}]">
                    <label class="form__item-label"><span>名前:</span><input class="form__item-input" v-model="name" id="name" placeholder="名前を入力"></label>
                    <p v-if="nameErrorMsg">{{ nameErrorMsg }}</p>
                </div>
                <div :class="['form__item', {'isAlert': emailErrorMsg}]">
                    <label class="form__item-label"><span>メールアドレス:</span><input class="form__item-input" v-model="email" id="email" placeholder="メールアドレスを入力"></label>
                    <p v-if="emailErrorMsg">{{ emailErrorMsg }}</p>
                </div>
                <div :class="['form__item', {'isAlert': telErrorMsg}]">
                    <label class="form__item-label"><span>電話番号:</span><input class="form__item-input" v-model="tel" id="tel" placeholder="電話番号を入力"></label>
                    <p v-if="telErrorMsg">{{ telErrorMsg }}</p>
                </div>
                <button class="form__button" @click="submitForm">登録</button>
            </div>
        </template>
        <!-- フォーム送信後 -->
        <template v-else>
            <p>ご登録ありがとうございます!</p>
        </template>
    </div>
</template>

これでもかなりシンプルな方だと思います。実際にはここにpinia・vuexあたりの状態管理ライブラリが絡んできたり、propsで受け取る値が多岐に渡ったり、項目がもっと多かったりするので愚直に書いていたら500行とかはすぐに超えると思います。

このサンプルコードでは下記のような感じでざっくりとロジックが記述されています。

  • 表示ロジック
    • フォーム送信ボタンのクリックイベント
    • エラー項目へのスクロール
  • 処理ロジック
    • v-model用のリアクティブ変数の定義
    • バリデーション用の算出プロパティの定義

composableへ処理ロジック周りを切り出してみる

では、ここからcomposable/useFormSetup.jsへ処理ロジックを切り出してみます。

// === composable/useFormSetup.js ===

import { ref, computed } from 'vue';

export default function useFormSetup() {
    // 各項目のリアクティブ変数
    const name = ref("");
    const email = ref("");
    const tel = ref("");

    // "名前" バリデーションメッセージ
    const nameErrorMsg = computed(() => {
        if (name.value && name.value.length > 10) {
            return "名前は10文字以内で入力してください";
        }
        return null;
    });

    // "メールアドレス" バリデーションメッセージ
    const emailErrorMsg = computed(() => {
        const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
        if (email.value && !emailRegex.test(email.value)) {
            return "正しいメールアドレスを入力してください";
        }
        return null;
    });

    // "電話番号" バリデーションメッセージ
    const telErrorMsg = computed(() => {
        const telRegex = /^\d{1,11}$/;
        if (tel.value && !telRegex.test(tel.value)) {
            return "正しい電話番号を入力してください";
        }
        return null;
    });

    return {
        name,
        email,
        tel,
        nameErrorMsg,
        emailErrorMsg,
        telErrorMsg,
    };
}

このuseFormSetup関数をコンポーネントファイルで呼び出して使います。

// Form.vue


<script setup>

import { ref, computed } from 'vue';
import useFormSetUp from '../composable/useFormSetUp.js';

// バリデーション関連の変数やロジックをComposableから取得
const { name, email, tel, nameErrorMsg, emailErrorMsg, telErrorMsg } = useFormSetUp();

const props = defineProps({
    formTitle: {
        type: String,
        required: true
    }
});

// フォームDOM
const formElement = ref();

// フォーム送信状態のリアクティブ変数
const isFormSubmitted = ref(false);

/**
 * 対象のDOM要素へスクロール
 * @param targetElement
 */
const scrollToElement = (targetElement) => {
    targetElement.scrollTo({ behavior: 'smooth' });
};

/**
 * フラグ:フォーム項目にエラーが残存するか
 * @type {ComputedRef<unknown>}
 */
const isExistError = computed(() => nameErrorMsg.value || emailErrorMsg.value || telErrorMsg.value);

/**
 * 送信ボタンクリックイベント
 */
const submitForm = () => {
    if (isExistError.value) {
        // エラー項目が残存する場合はその項目へスクロール
        scrollToElement(formElement.value.querySelector('.isAlert'))
    } else {
        // バリデーション通過した場合の処理
        // 特定のAPIへ送信する処理など

        // 送信完了のフラグをONへ
        isFormSubmitted.value = true;
    }
};
</script>
<template>
    省略
</template>

元がシンプルな構成なので40行ほどしか変わってないのですが、フォームの項目に関するリアクティブ変数や算出プロパティをcomposable配下のファイルから取得してくることでコンポーネントファイル側では呼び出すだけで完結しました! 実際に私が作っていたフォームでは他にも住所・性別・年代・自由入力テキスト・日付などがありましたので項目が増えれば増えるほど、バリデーションが複雑になればなるほど恩恵は大きいと思います。

他コンポーネントへの再利用

また、可読性以外にもメリットがあります。 他コンポーネントでも再利用しやすいのです。

例えばフォームには2種類あったとし、もう一方のフォームには規約に同意するといった項目があるとします。しかし、デザインが異なるのでコンポーネントとしてはわけたい。その場合にはuseFormSetup関数を下記のように調整してみます。

// === composable/useFormSetup.js ===

import { ref, computed } from 'vue';

export default function useFormSetup() {
    
    // 既存の記述は省略

    // ★★新しく追加★★ 利用規約の同意 リアクティブ変数
    const hasAcceptedTerms = ref(false);
    

    // ★★新しく追加★★ 利用規約のバリデーションメッセージ
    const termsErrorMsg = computed(() => {
        if (!hasAcceptedTerms.value) {
            return "利用規約に同意してください";
        }
        return null;
    });


    return {
        name,
        email,
        tel,
        hasAcceptedTerms,
        nameErrorMsg,
        emailErrorMsg,
        telErrorMsg,
        termsErrorMsg
    };
}

次に新しく作ったフォームコンポーネントです。

<script setup>

import { ref, computed } from 'vue';
import useFormSetUp from '../composable/useFormSetUp.js';

// すべてのバリデーション関連の変数やロジックをComposableから取得
const {name, email, tel, nameErrorMsg, emailErrorMsg, telErrorMsg, hasAcceptedTerms, termsErrorMsg} = useFormSetUp();

const props = defineProps({
    formTitle: {
        type: String,
        required: true
    }
});

// フォームDOM
const formElement = ref();

// フォーム送信状態のリアクティブ変数
const isFormSubmitted = ref(false);

/**
 * 対象のDOM要素へスクロール
 * @param targetElement
 */
const scrollToElement = (targetElement) => {
    targetElement.scrollTo({ behavior: 'smooth' });
};

/**
 * フラグ:フォーム項目にエラーが残存するか
 * @type {ComputedRef<unknown>}
 */
const isExistError = computed(() => nameErrorMsg.value || emailErrorMsg.value || telErrorMsg.value || termsErrorMsg.value);

/**
 * 送信ボタンクリックイベント
 */
const submitForm = () => {
    if (isExistError.value) {
        // エラー項目が残存する場合はその項目へスクロール
        scrollToElement(formElement.value.querySelector('.isAlert'))
    } else {
        // バリデーション通過した場合の処理
        // 特定のAPIへ送信する処理など

        // 送信完了のフラグをONへ
        isFormSubmitted.value = true;
    }
};

// ... (以前のコード)

</script>
<template>
    <div>
        <!-- ... (既存フォームと同じコードは省略) -->

        <!-- 利用規約の項目を追加 -->
        <div :class="['form__item', {'isAlert': termsErrorMsg}]">
            <label class="form__item-checkbox">
                <input type="checkbox" v-model="hasAcceptedTerms">
                <span>利用規約に同意する</span>
            </label>
            <p v-if="termsErrorMsg">{{ termsErrorMsg }}</p>
        </div>
        <!-- ... (以前のコード) -->
    </div>
</template>

不要な項目は呼び出し先で取得しなければいいだけなので必要な項目に併せて再利用できますし、
より再利用性を高めるのであればエラーメッセージとバリデーション処理を分離してあげるなどまだまだ改善の余地はあります。また、スクロール処理であったり、送信処理であったりも必要であればヘルパー関数として外部モジュール化してあげればより記述量は減るでしょう。

終わり

これにて太ったコンポーネントファイルとはおさらばできそうです!
また、処理ロジックだけを分離することでテストコードも実装しやすくなるので、
痩せさせられたらVite導入してVitestの導入もやってみたいな〜

Discussion