使用 vue3 封装表单组件
前言
最近要做 java 大作业, 刚好用 typescript 和 tailwindcss 练练手.
java 一点不会, 只能做前端了. 感觉挺多数据结构都没定, 先写登录/注册/重置密码这些页面了.
在做某集市的时候我的登录页面做的一坨, 连我现在也不太想看. 这次痛定思痛, 对表单组件进行了封装.
问了 AI 应该怎么封装表单组件, 他建议我分成表单容器 FormContainer 和输入容器 FormInput.
第一次尝试
<!-- FormContainer.vue -->
<script setup lang="ts">
defineProps({
formName: {
type: String,
required: false,
default: '提交',
},
});
</script>
<template>
<form
class="bg-[#4545453d]"
@submit.prevent="$emit('SubmitForm')"
>
<slot></slot>
<button type="submit">{{ formName }}</button>
</form>
</template>
这是第一版的 FormContainer, 这里插槽用于放 FormInput, 由页面提供.
<!-- FormInput.vue -->
<script setup lang="ts">
defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
});
const info = defineModel({
type: String,
required: true,
});
</script>
<template>
<div class="relative w-full h-10 mb-4">
<input
class="peer w-full h-full border-none text-base border-b-2 border-blue-300 bg-[#ffffff84]"
:id="id"
:type="type"
v-model="info"
required
/>
<div
class="peer-focus:scale-x-100 peer-valid:scale-x-100 absolute bottom-0 h-[2px] w-full bg-gradient-to-r from-[#eb6b26] to-[#eb6b26] scale-x-0 transition-all duration-300 ease"
></div>
<label
class="absolute bottom-[10px] left-0 text-gray-500 pointer-events-none transition-all duration-300 ease peer-valid:-translate-x-full peer-valid:text-[#eb6b26] peer-valid:text-base peer-valid:font-bold peer-valid:border-0 peer-focus:-translate-x-full peer-focus:text-[#eb6b26] peer-focus:text-base peer-focus:font-bold peer-focus:border-0"
:for="id"
>{{ label }}</label
>
</div>
</template>
这个 defineModel 是比较新的东西, 大概就是更简单的实现双向绑定而不需要改变值的时候使用 emit. 这是第一版的 FormInput, 这里的样式叫什么不太记得了, 大概就是你不聚焦到它身上或者它的输入无效的时候, 它的 label 就放在里面. 像这样
聚焦或者输入有效的时候就是这样
然后在页面使用组件是这样的
<script setup lang="ts">
import { ref } from 'vue';
import FormContainer from '@/components/FormContainer.vue';
import FormInput from '@/components/FormInput.vue';
const username = ref('');
const password = ref('');
const loginHandler = () => {
console.log(username.value);
console.log(password.value);
};
</script>
<template>
<div>
<FormContainer
class="w-1/2 mx-auto mt-11"
@submit-form="loginHandler"
>
<FormInput
id="username"
label="Username"
type="text"
v-model="username"
/>
<FormInput
id="password"
label="Password"
type="password"
v-model="password"
/>
</FormContainer>
</div>
</template>
使用 attrs 透析
怎么说呢, 这玩意其实也能用吧. 但是我在开发者工具里看到建议说, 密码什么的让我加个 auto-complete 属性, 但是很明显在这一版里要加就得改比较多的代码, 比如在 defineProps 加新 prop, 然后再在标签里加个属性. 但是这样如果以后还需要加的话就会很痛苦, 又得加 prop, 然后再加属性.
去翻了 vue 文档关于 attr 传递的部分, 最后改进了一下 FormInput
<!-- FormInput.vue -->
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
const info = defineModel({
type: String,
required: true,
});
</script>
<template>
<div class="relative w-full h-10 mb-4">
<input
v-model="info"
class="peer w-full h-full border-none text-base border-b-2 border-blue-300 bg-[#ffffff84]"
v-bind="$attrs"
required
/>
<div
class="peer-focus:scale-x-100 peer-valid:scale-x-100 absolute bottom-0 h-[2px] w-full bg-gradient-to-r from-[#eb6b26] to-[#eb6b26] scale-x-0 transition-all duration-300 ease"
></div>
<label
class="absolute bottom-[10px] left-0 text-gray-500 pointer-events-none transition-all duration-300 ease peer-valid:-translate-x-[105%] peer-valid:text-[#eb6b26] peer-valid:text-base peer-valid:font-bold peer-valid:border-0 peer-focus:-translate-x-[105%] peer-focus:text-[#eb6b26] peer-focus:text-base peer-focus:font-bold peer-focus:border-0"
:for="$attrs.id"
>{{ $attrs.label }}</label
>
</div>
</template>
设置 inheritAttrs 为 false 让 attrs 传递给非根标签, 然后让 input 绑定上所有 attrs, 这样就可以更好的处理某一些需要多加属性的标签. 虽然还是得在父标签加属性, 不过不用在子标签加属性了.
统一的表单验证
在编写页面的时候发现, 注册/登录/重置密码都需要一个功能类似的函数:表单验证.
对于表单验证, 我的设想是这样的:传一个响应式变量 formData, 这是一个对象数组. 这个对象应该有一个值 value 和一个检验规则(正则), 如果没传检验规则就默认检验是否为空. 初步的一个 FormData 接口就出来了. 为了写的时候知道哪个是哪个, 应该还有个 name 或者 id.
interface FormData {
id: string;
value: string;
reg?: RegExp;
}
接下来的话只需要遍历这个数组就可以了
const useFormExam = (formData: Ref<CustomFormData[]>) => {
const correct = computed<boolean>(() => {
// 虽然感觉没什么必要判断是否为数组, 但还是判断一下
if (!Array.isArray(formData.value)) return false;
for (const item of formData.value) {
if (item.reg) {
if (!item.reg.test(item.value)) {
return false;
}
} else {
if (item.value.trim() === '') {
return false;
}
}
}
return true;
});
return {
correct,
};
};
这里使用了 computed, 所以前面需要一个响应式变量而不是普通变量
完整代码
import { computed, type Ref } from 'vue';
export interface CustomFormData {
value: string;
reg?: RegExp;
}
export const useFormExam = (formData: Ref<CustomFormData[]>) => {
const correct = computed<boolean>(() => {
if (!Array.isArray(formData.value)) return false;
for (const item of formData.value) {
if (item.reg) {
if (!item.reg.test(item.value)) {
return false;
}
} else {
if (item.value.trim() === '') {
return false;
}
}
}
return true;
});
return {
correct,
};
};
封装好之后, 那在页面里只需要声明一个响应式数组然后填数据进去了.
<!-- 部分ResetView.vue -->
<script setup lang="ts">
import { type CustomFormData, useFormExam } from '@/composables/FormExam';
const form = ref<CustomFormData[]>([
{
id: 'email',
value: '',
reg: /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
},
{
id: 'password',
value: '',
reg: /^(?![a-zA-Z]+$)(?![A-Z0-9]+$)(?![A-Z\W_!@#$%^&*`~()-+=]+$)(?![a-z0-9]+$)(?![a-z\W_!@#$%^&*`~()-+=]+$)(?![0-9\W_!@#$%^&*`~()-+=]+$)[a-zA-Z0-9\W_!@#$%^&*`~()-+=]{8,12}/,
},
{
id: 'password2',
value: '',
reg: /^(?![a-zA-Z]+$)(?![A-Z0-9]+$)(?![A-Z\W_!@#$%^&*`~()-+=]+$)(?![a-z0-9]+$)(?![a-z\W_!@#$%^&*`~()-+=]+$)(?![0-9\W_!@#$%^&*`~()-+=]+$)[a-zA-Z0-9\W_!@#$%^&*`~()-+=]{8,12}/,
},
{
id: 'code',
value: '',
},
]);
const { correct } = useFormExam(form);
</script>
这个正则表达式是VScode一个叫any-rule的插件给的, 里面表达式还挺多的.
进一步封装
写到这里会发现, 如果愿意的话这个id可以作为FormInput的id. 如果把其它需要的数据也放进FormData里的话, 最后发现不需要写多个FormInput, 只需要一个v-for即可.
interface CustomFormData {
id: string;
label: string;
value: string;
type?: 'password' | 'email' | 'text';
reg?: RegExp;
autocomplete?: string;
}
在页面里
<script setup lang="ts">
import { ref } from 'vue';
import FormContainer from '@/components/FormContainer.vue';
import { type CustomFormData, useFormExam } from '@/composables/FormExam';
const form = ref<CustomFormData[]>([
{
id: 'username',
label: 'username',
value: '',
type: 'text',
autocomplete: 'off',
},
{
id: 'password',
label: 'password',
value: '',
type: 'password',
autocomplete: 'current-password',
},
]);
const { correct } = useFormExam(form);
const loginHandler = () => {};
</script>
<template>
<FormContainer
class="w-1/2 mt-11"
:disabled="!correct"
@submit-form="loginHandler"
>
<FormInput
v-for="item in form"
:id="item.id"
:label="item.label"
v-model="item.value"
:type="item.type || 'text'"
:autocomplete="item.autocomplete || 'off'"
/>
</FormContainer>
</template>
这里还可以进一步封装, 把FormInput放进FormContainer里, 然后页面只需要传一个form进去即可
<script setup lang="ts">
import { ref } from 'vue';
import FormContainer from '@/components/FormContainer.vue';
import { type CustomFormData, useFormExam } from '@/composables/FormExam';
const form = ref<CustomFormData[]>([
{
id: 'username',
label: 'username',
value: '',
type: 'text',
autocomplete: 'off',
},
{
id: 'password',
label: 'password',
value: '',
type: 'password',
autocomplete: 'current-password',
},
]);
const { correct } = useFormExam(form);
const loginHandler = () => {};
</script>
<template>
<FormContainer
class="w-1/2 mt-11"
:form-data="form"
:disabled="!correct"
@submit-form="loginHandler"
/>
</template>
下面是FormContainer, 依旧留一个插槽, 用来放密码强度提示之类的东西
<script setup lang="ts">
import { type CustomFormData } from '@/composables/FormExam';
import FormInput from '@/components/FormInput.vue';
defineProps({
formName: {
type: String,
required: false,
default: '提交',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
formData: {
type: Array as () => CustomFormData[],
required: true,
},
});
</script>
<template>
<form
class="bg-[#4545453d] p-12 mx-auto"
@submit.prevent="$emit('SubmitForm')"
>
<FormInput
v-for="item in formData"
:id="item.id"
:label="item.label"
v-model="item.value"
:type="item.type || ''"
:autocomplete="item.autocomplete || 'off'"
/>
<slot></slot>
<button
:disabled="disabled"
class="w-full h-10 bg-[#eb6b26] text-white border-0 text-lg cursor-pointer mt-5 rounded-[20px] flex justify-center items-center hover:bg-[#ff7e3b] disabled:bg-zinc-600"
type="submit"
>
{{ disabled ? '请填写完整信息' : formName }}
</button>
</form>
</template>
结束
到这里, 我个人觉得是依旧封装得比较完善了, 感觉之后可以在其它大作业/小项目拿来用, 不过好像一般都用组件库来做, 算是我的一次尝试吧.
第一次封装表单组件的体验还是挺好的, 希望以后不会太折磨. 在之前听说组件应该有两种, 一种是展示组件(ui), 一种是容器组件(数据). ui只负责展示数据, 容器组件负责数据获取和处理?直到现在我也不太明白.