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