一个中后台风格的移动表单里,往往有十几个 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。

vue src/components/AppField.vue
<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 接口,规则就能集中复用、可推导、可组合,异步校验(比如手机号是否已注册)也能自然接入:

typescript src/utils/rules.ts
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:

typescript src/composables/useVantForm.ts
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 };
}

实战四:拼成一个完整页面

vue src/views/ProfileForm.vue
<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 显式转发,等价
类型提示表单值散落在各 refuseVantForm<T> 统一推导
改版成本逐页修改改一个组件即可全站生效

常见坑速查

⚠️
六个容易翻车的地方
① 忘了 inheritAttrs: false,属性落到根元素导致样式错位;② 把业务规则写死在组件内部,组件迅速膨胀成「万能表单」;③ 用 any 声明表单类型,等于放弃 TS 的价值;④ defineModel 在 Vue 3.4 以下不可用;⑤ validator 里直接抛异常而不是返回布尔值,错误提示会丢失;⑥ 以为 @submit 无论如何都会触发——它只在全部规则校验通过后触发,失败要走 @failed。

另外要克制封装的粒度:一层就够。在 AppField 之上再套 AppPhoneField、AppIdField 这类「业务专用组件」,很快就会变成难以维护的组件迷宫;差异用规则工厂和 props 表达,比新增组件更划算。

总结

好的二次封装不是把组件包得更厚,而是把不变的部分(透传、必填、类型)固化、把变化的部分(规则、字段、提交逻辑)留给业务。defineModel、$attrs、defineExpose 加一个泛型 composable,就足够撑起一套类型安全、可长期维护的 Vant 表单体系。

封装的边界,应该划在「重复」和「差异」之间。