一个中后台风格的移动表单里,往往有十几个 van-field,每个都重复写 label、rules、错误提示和插槽。Vant 组件二次封装的价值不是少写几行标签,而是把校验规则、透传行为和类型约束收敛到一处,让业务页面只关心字段本身。本文用 Vue 3 + TypeScript 从零封装一个 AppField,再配一套类型安全的规则工厂与 useVantForm 组合式函数,最后给出完整页面示例和避坑清单。
封装前先定三条底线
| 底线 | Vue 3 实现 | 不做会怎样 |
|---|---|---|
| 双向绑定不能丢 | defineModel<string>() | 父组件还得自己监听事件,封装失去意义 |
| 原生属性要透传 | inheritAttrs: false + v-bind="$attrs" | placeholder、maxlength 被吃掉或落到外层 div |
| 实例能力要暴露 | defineExpose({ ... }) | 父组件拿不到 ref 上的聚焦、校验方法 |
defineModel 从 Vue 3.4 起转为稳定 API,配合 Vite 5+ 与 @vitejs/plugin-vue 5+ 使用。老项目可以退化为 props.modelValue + emit('update:modelValue'),本文其余部分不受影响。实战一:AppField 组件
设计目标是:必填规则自动生成、业务规则由外部传入、Vant Field 的所有属性和常用插槽原样透传、对外暴露 focus 与 validate。
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { FieldRule } from '@/utils/rules';
defineOptions({ inheritAttrs: false }); // 不让属性自动落到根元素
interface Props {
label: string;
name: string;
required?: boolean;
rules?: FieldRule[];
}
const props = withDefaults(defineProps<Props>(), {
required: false,
rules: () => [],
});
const model = defineModel<string>({ default: '' });
const mergedRules = computed<FieldRule[]>(() =>
props.required
? [{ required: true, message: `请填写${props.label}` }, ...props.rules]
: props.rules
);
const fieldRef = ref<{ focus?: () => void; validate?: () => Promise<void> } | null>(null);
const focus = () => fieldRef.value?.focus?.();
const validate = () => fieldRef.value?.validate?.() ?? Promise.resolve();
defineExpose({ focus, validate });
</script>
<template>
<van-field
ref="fieldRef"
v-bind="$attrs"
:model-value="model"
:label="label"
:name="name"
:rules="mergedRules"
@update:model-value="model = $event"
>
<template #label v-if="$slots.label"><slot name="label" /></template>
<template #input v-if="$slots.input"><slot name="input" /></template>
<template #right-icon v-if="$slots['right-icon']"><slot name="right-icon" /></template>
</van-field>
</template>
关键点有两个:一是 v-bind="$attrs" 放在 van-field 上,父组件传的 placeholder、maxlength、type 才能生效;二是把 required 转成一条默认规则并与外部 rules 合并,业务页面不用再手写十遍「请填写 XX」。
实战二:类型安全的规则工厂
Vant 的 rules 支持 required、pattern、validator 等字段。把这些字段定义成自己的 FieldRule 接口,规则就能集中复用、可推导、可组合,异步校验(比如手机号是否已注册)也能自然接入:
export interface FieldRule {
required?: boolean;
message?: string;
pattern?: RegExp;
validator?: (value: unknown) => boolean | Promise<boolean>;
}
export const rules = {
required: (label: string): FieldRule => ({
required: true,
message: `请填写${label}`,
}),
phone: {
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
} satisfies FieldRule,
length: (min: number, max: number): FieldRule => ({
validator: (v) => {
const n = String(v ?? '').trim().length;
return n >= min && n <= max;
},
message: `长度需在 ${min} ~ ${max} 个字符之间`,
}),
asyncUnique: (check: (value: string) => Promise<boolean>): FieldRule => ({
validator: (v) => check(String(v ?? '')),
message: '该值已被占用',
}),
};
validator 返回 Promise 时,Vant 会等待其完成后再决定是否通过校验;返回 false 或 reject 则展示 message。异步校验记得做防抖,别在每次输入时打接口。实战三:useVantForm 组合式函数
表单状态、提交中与重置逻辑,用泛型 composable 收口。T 由初始值推导,页面里拿到 formData.phone 时就有类型提示,不再满屏 any:
import { reactive, ref } from 'vue';
export function useVantForm<T extends Record<string, unknown>>(initial: T) {
const formData = reactive({ ...initial }) as T;
const submitting = ref(false);
const reset = () => Object.assign(formData, initial);
// van-form 的 submit 事件只在校验通过后触发,这里再包一层防重复提交
const wrapSubmit = (handler: (values: T) => Promise<void> | void) =>
async () => {
if (submitting.value) return;
submitting.value = true;
try {
await handler(formData);
} finally {
submitting.value = false;
}
};
return { formData, submitting, reset, wrapSubmit };
}
实战四:拼成一个完整页面
<script setup lang="ts">
import { ref } from 'vue';
import { showToast } from 'vant';
import AppField from '@/components/AppField.vue';
import { rules } from '@/utils/rules';
import { useVantForm } from '@/composables/useVantForm';
import { saveProfile } from '@/api/user';
const fieldRef = ref<InstanceType<typeof AppField> | null>(null);
const { formData, submitting, reset, wrapSubmit } = useVantForm({
phone: '',
nickname: '',
company: '',
});
const onSubmit = wrapSubmit(async (values) => {
await saveProfile(values); // values 自动推导为 { phone:string; ... }
showToast('保存成功');
});
const onFailed = () => showToast('请检查标红字段');
</script>
<template>
<van-form @submit="onSubmit" @failed="onFailed">
<van-cell-group inset>
<AppField
ref="fieldRef"
v-model="formData.phone"
label="手机号" name="phone" required
:rules="[rules.phone]"
placeholder="请输入 11 位手机号" maxlength="11" type="tel"
/>
<AppField
v-model="formData.nickname"
label="昵称" name="nickname" required
:rules="[rules.length(2, 16)]"
placeholder="2 ~ 16 个字符"
/>
<AppField v-model="formData.company" label="公司" name="company" placeholder="选填" />
</van-cell-group>
<div style="margin: 16px">
<van-button round block type="primary" native-type="submit" :loading="submitting">保存</van-button>
<van-button round block plain type="default" @click="reset">重置</van-button>
</div>
</van-form>
</template>
业务页面现在只剩三件事:声明字段、绑定规则、写提交回调。placeholder、maxlength、type 全部由 $attrs 透传,required 自动补规则,重复劳动被压缩到零。
| 维度 | 直接使用 van-field | 封装 AppField 后 |
|---|---|---|
| 必填提示 | 每个字段手写一遍 | required 自动生成 |
| 规则复用 | 复制粘贴正则 | rules.phone 集中维护 |
| 属性透传 | 天然支持 | $attrs 显式转发,等价 |
| 类型提示 | 表单值散落在各 ref | useVantForm<T> 统一推导 |
| 改版成本 | 逐页修改 | 改一个组件即可全站生效 |
常见坑速查
inheritAttrs: false,属性落到根元素导致样式错位;② 把业务规则写死在组件内部,组件迅速膨胀成「万能表单」;③ 用 any 声明表单类型,等于放弃 TS 的价值;④ defineModel 在 Vue 3.4 以下不可用;⑤ validator 里直接抛异常而不是返回布尔值,错误提示会丢失;⑥ 以为 @submit 无论如何都会触发——它只在全部规则校验通过后触发,失败要走 @failed。另外要克制封装的粒度:一层就够。在 AppField 之上再套 AppPhoneField、AppIdField 这类「业务专用组件」,很快就会变成难以维护的组件迷宫;差异用规则工厂和 props 表达,比新增组件更划算。
总结
好的二次封装不是把组件包得更厚,而是把不变的部分(透传、必填、类型)固化、把变化的部分(规则、字段、提交逻辑)留给业务。defineModel、$attrs、defineExpose 加一个泛型 composable,就足够撑起一套类型安全、可长期维护的 Vant 表单体系。
封装的边界,应该划在「重复」和「差异」之间。