[Vue3] 動的フォーム設計:連結リスト構造を用いた実装と最適化

公開日 2025年1月29日
更新日 2025年1月30日
27 分で読める
Vue

複雑な業務シナリオでは、フォーム項目が静的ではなく、条件に応じて動的に表示・非表示を切り替える必要があることがよくあります。例えば、フォーム内の OtherFavorite は、Favorite の値が「Other」を含む場合にのみ表示されます。一般的な方法として、if 条件を使って表示ロジックを制御しますが、動的なフォーム項目が増えるにつれてコードの複雑性が大幅に増し、メンテナンスが難しくなります。

この記事では、連結リスト構造を使用した柔軟な設計を紹介し、動的フォームの表示および検証の課題を解決します。


背景

以下は簡単なフォームの要件例です:

順序 項目 種類 表示条件
1 Name text 文字列 常に表示
2 Address text 文字列 常に表示
3 Favorite checkbox [“Apple”, “Banana”, “Cherry”, “Other”] 常に表示
4 OtherFavorite text 文字列 Favorite に「Other」が含まれる場合表示
5 Comment textarea 文字列 常に表示

一般的な実装では、以下のように v-if を使用して OtherFavorite を動的に表示し、watch を用いて検証ルールを動的に調整することができます。

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
    :validate-on-rule-change="false"
    status-icon
  >
    <el-form-item label="Name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Address" prop="address">
      <el-input v-model="ruleForm.address" />
    </el-form-item>
    <el-form-item label="Favorite" prop="favorite">
      <el-checkbox-group v-model="ruleForm.favorite">
        <el-checkbox label="Apple" value="Apple" />
        <el-checkbox label="Banana" value="Banana" />
        <el-checkbox label="Cherry" value="Cherry" />
        <el-checkbox label="Other" value="Other" />
      </el-checkbox-group>
    </el-form-item>
    <!-- Favorite の値に基づいて OtherFavorite を表示 -->
    <el-form-item v-if="ruleForm.favorite.includes('Other')" label="OtherFavorite" prop="other">
      <el-input v-model="ruleForm.other" />
    </el-form-item>
    <el-form-item label="Comment" prop="comment">
      <el-input v-model="ruleForm.comment" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submitForm(ruleFormRef)">作成</el-button>
      <el-button @click="resetForm(ruleFormRef)">リセット</el-button>
    </el-form-item>
  </el-form>
</template>

<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';

const ruleFormRef = ref<FormInstance>();

const ruleForm = ref<FormInputs>({
  name: '',
  address: '',
  favorite: [],
  other: '',
  comment: '',
});

const rules = ref<FormRules<FormInputs>>({
  name: [{ required: true, message: '名前を入力してください', trigger: 'blur' }],
  address: [{ required: true, message: '住所を入力してください', trigger: 'blur' }],
  favorite: [{ required: true, message: '好きなものを選択してください', trigger: 'change' }],
  comment: [{ required: true, message: 'コメントを入力してください', trigger: 'blur' }],
});

// Favorite の値を監視して、Other の検証ルールを動的に設定
watch(
  () => ruleForm.value.favorite,
  (value) => {
    if (value.includes('Other')) {
      rules.value.other = [
        { required: true, message: '他のお気に入りを入力してください', trigger: 'blur' },
      ];
    } else {
      rules.value.other = [];
    }
  },
);

const submitForm = async (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  await formEl.validate((valid, fields) => {
    if (valid) {
      console.log('submit!');
    } else {
      console.log('error submit!', fields);
    }
  });
};

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  formEl.resetFields();
};
</script>

この方法はシンプルなケースでは機能しますが、動的項目や条件が増えるにつれてコードのメンテナンスが困難になります。


解決策:連結リスト構造

各フォーム項目を連結リストのノードとして抽象化します。各ノードは以下の情報を持ちます:

  • フォーム項目の基本情報(例:id、label、type など)
  • 動的表示の条件ロジック
  • 次のノードのポインタ

データ構造設計

フォーム項目のメタデータ型を定義します:

import type { FormItemRule } from 'element-plus';

/**
 * フォーム入力タイプ
 */
export type FormInputs = {
  name: string;
  address: string;
  favorite: string[];
  other?: string;
  comment: string;
};

/**
 * フォーム項目メタデータ
 */
export type FormItemMeta = {
  /** フォーム項目ID。FormInputs のキーである必要があります。 */
  id: keyof FormInputs;
  /** フォーム項目ラベル */
  label: string;
  /** フォーム項目タイプ */
  type: 'text' | 'checkbox' | 'textarea';
  /** フォーム項目の検証ルール */
  rules: Array<FormItemRule> | undefined;
  /** チェックボックスのオプション */
  options?: Array<{ label: string; value: string }>;
  /** 次のフォーム項目 */
  next?: FormItemMeta | Array<{ condition: string; item: FormItemMeta } | FormItemMeta>;
};

この構造を用いてフォーム項目を連結リスト形式で整理します:

export const formItemMeta: FormItemMeta = {
  id: 'name',
  label: 'Name',
  type: 'text',
  rules: [{ required: true, message: '名前を入力してください', trigger: 'blur' }],
  // 次のフォーム項目は address
  next: {
    id: 'address',
    label: 'Address',
    type: 'text',
    rules: [{ required: true, message: '住所を入力してください', trigger: 'blur' }],
    // 次のフォーム項目は favorite
    next: {
      id: 'favorite',
      label: 'Favorite',
      type: 'checkbox',
      rules: [{ required: true, message: '好きなものを選択してください', trigger: 'change' }],
      options: [
        { label: 'Apple', value: 'apple' },
        { label: 'Banana', value: 'banana' },
        { label: 'Cherry', value: 'cherry' },
        { label: 'Other', value: 'other' },
      ],
      // 次のフォーム項目は other と comment
      next: [
        // Favorite に "Other" が含まれる場合に OtherFavorite を表示
        {
          condition: 'other',
          item: {
            id: 'other',
            label: 'Other',
            type: 'text',
            rules: [{ required: true, message: '他のお気に入りを入力してください', trigger: 'blur' }],
          },
        },
        // Comment は常に表示
        {
          id: 'comment',
          label: 'Comment',
          type: 'textarea',
          rules: [{ required: true, message: 'コメントを入力してください', trigger: 'blur' }],
        },
      ],
    },
  },
};

レンダリングロジック:再帰的な実装

FormItemRenderer.vue コンポーネントを定義し、メタデータに基づいてフォーム項目を動的にレンダリングします。

<!-- FormItemRenderer.vue -->
<template>
  <el-form-item :label="meta.label" :prop="meta.id">
    <!-- text型の項目 -->
    <el-input v-if="meta.type === 'text'" :id="meta.id" v-model="value" />
    <!-- textarea型の項目 -->
    <el-input v-if="meta.type === 'textarea'" type="textarea" :id="meta.id" v-model="value" />
    <!-- checkboxt型の項目 -->
    <el-checkbox-group v-if="meta.type === 'checkbox'" v-model="value">
      <el-checkbox
        v-for="option in meta.options"
        :key="option.value"
        :label="option.label"
        :value="option.value"
      />
    </el-checkbox-group>
  </el-form-item>

  <!-- 次の項目をレンダリングする -->
  <FormItemRenderer
    v-for="nextFormItem in nextFormItemList"
    :key="nextFormItem.id"
    :meta="nextFormItem"
    :values="values"
    v-on:value-changed="onValueChanged"
    v-on:rule-changed="onRuleChanged"
  />
</template>

<script setup lang="ts">
import type { FormInputs, FormItemMeta } from '@/models/form.model';
import type { FormItemRule } from 'element-plus';

import { computed, onMounted, onUnmounted, type PropType } from 'vue';

const props = defineProps({
  meta: {
    type: Object as PropType<FormItemMeta>,
    required: true,
  },
  values: {
    type: Object as PropType<FormInputs>,
    required: true,
  },
  onValueChanged: {
    type: Function as PropType<(id: string, value: string | string[] | undefined) => void>,
    required: true,
  },
  onRuleChanged: {
    type: Function as PropType<(id: string, rules: Array<FormItemRule> | undefined) => void>,
    required: true,
  },
});

const value = computed({
  get: () => props.values[props.meta.id],
  set: (newValue: string | string[]) => props.onValueChanged(props.meta.id, newValue),
});

/**
 * 次の項目を取得する
 */
const nextFormItemList = computed(() => {
  // nextない場合、空の配列を返す
  if (props.meta.next === undefined) {
    return [];
  }
  // nextはFormItemMeta型の場合、その項目を返す
  if ('id' in props.meta.next) {
    return [props.meta.next] as Array<FormItemMeta>;
  }
  // nextは配列の場合、配列を繰り返して見て、条件(condition)に従って結果を返す
  const next = props.meta.next as Array<{ condition: string; item: FormItemMeta } | FormItemMeta>;
  let nextItems: Array<FormItemMeta> = [];
  for (const nextItem of next) {
    if ('condition' in nextItem && 'item' in nextItem) {
      // 条件がある場合
      const nextItemWithCondition = nextItem as { condition: string; item: FormItemMeta };

      // 現在項目の値はstirng型、かつ値はconditionと同じ場合、nextItems配列を更新する
      if (typeof value.value === 'string' && value.value === nextItemWithCondition.condition) {
        nextItems.push(nextItemWithCondition.item);
      }
      // 現在項目の値は配列、かつ値の配列にconditionが入ってる場合、nextItems配列を更新する
      if (Array.isArray(value.value) && value.value.includes(nextItemWithCondition.condition)) {
        nextItems.push(nextItemWithCondition.item);
      }
    } else {
      // conditionがない場合,nextItems配列を更新する
      nextItems.push(nextItem);
    }
  }

  return nextItems;
});

/**
 * onMountedされるとき、親コンポーネントのチェックルールを更新する
 */
onMounted(() => {
  if (props.meta.rules) {
    props.onRuleChanged(props.meta.id, props.meta.rules);
  }
});

/**
 * onUnmountedされるとき、チェックルールと値をクリアする
 */
onUnmounted(() => {
  props.onRuleChanged(props.meta.id, undefined);
  props.onValueChanged(props.meta.id, undefined);
});
</script>

Form全体をレンダリング

親コンポーネントでは、 FormItemRenderer.vue を使用してFormをレンダリングする:

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="form"
    :rules="rules"
    label-width="auto"
    :validate-on-rule-change="false"
    status-icon
  >
    <FormItemRenderer
      :meta="formItemMeta"
      :values="form"
      v-on:value-changed="onValueChanged"
      v-on:rule-changed="onRuleChanged"
    />
    <el-form-item>
      <el-button type="primary" @click="submitForm(ruleFormRef)"> Create </el-button>
      <el-button @click="resetForm(ruleFormRef)">Reset</el-button>
    </el-form-item>
  </el-form>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import type { FormInstance, FormItemRule, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';
import FormItemRenderer from './FormItemRenderer.vue';
import { formItemMeta } from '@/models/form.model';

const ruleFormRef = ref<FormInstance>();

const form = ref<FormInputs>({
  name: '',
  address: '',
  favorite: [],
  other: '',
  comment: '',
});

const rules = ref<FormRules<FormInputs>>({});

const onValueChanged = (id: string, value: string | string[] | undefined) => {
  form.value = {
    ...form.value,
    [id]: value,
  };
};

const onRuleChanged = (id: string, itemRules: Array<FormItemRule> | undefined) => {
  rules.value = {
    ...rules.value,
    [id]: itemRules,
  };
};

const submitForm = async (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  await formEl.validate((valid, fields) => {
    if (valid) {
      console.log('submit!');
    } else {
      console.log('error submit!', fields);
    }
  });
};

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  formEl.resetFields();
};
</script>

まとめ

連結リスト構造を使用した動的フォーム設計により、コードのロジックを簡素化し、拡張性や保守性を向上させることができます。複雑な表示条件や検証ルールもメタデータの定義によって簡単に実現可能です。

概要

技術的洞察、経験、思考を共有する個人ブログ

クイックリンク

お問い合わせ

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 CODE赤兎. 無断転載禁止