CClara轻量论坛系统

Vue 3 表单封装:通用 Form 组件设计思路

Lypeeres
Lypeeres 见习用户见习用户
发布于 2026-10-27 03:16 ·0 浏览 ·9 回复
内容摘要

介绍从零搭建一套可复用的 Vue 3 表单方案:先约定 model、rules、prop 三项 API 并定义类型,Form 组件通过 provide 下发上下文、收集 FormItem 实例并暴露校验方法,从而统一校验与布局,业务页面只需声明字段。

学完这篇,你能从零搭出一套可复用的 Vue 3 表单方案:Form 负责统一校验和布局,FormItem 负责单个字段的标签、错误提示和触发时机,业务页面只写字段就行。

第一步:先把 API 定下来,别急着写组件

组件写起来乱,八成是因为 API 没定。推荐按下面这组约定来设计:

<Form :model="form" :rules="rules" label-width="90px" ref="formRef">
  <FormItem label="用户名" prop="username">
    <Input v-model="form.username" />
  </FormItem>
  <FormItem label="邮箱" prop="email" required>
    <Input v-model="form.email" />
  </FormItem>
</Form>

三条关键约定:

  • model:一个响应式对象,存表单的全部数据,采用扁平结构为主,也允许 user.name 这种路径。
  • rules:一个以 prop 为键的校验规则对象,和 model 的字段名对齐。
  • prop:FormItem 上用 prop 指定这个字段的路径,Form 靠它反查规则、定位报错字段。

这三条定好了,后面的实现基本只是机械地按这个约定写。

第二步:写类型定义

建一个 types.ts:

export interface FormRule {
  required?: boolean
  message?: string
  min?: number
  max?: number
  pattern?: RegExp
  validator?: (value: any, model: Record<string, any>) => string | true
  trigger?: 'blur' | 'change'
}
export type FormRules = Record<string, FormRule[]>

trigger 默认是 blur,需要实时反馈的字段(比如“确认密码”)手动标成 change。

第三步:Form 组件只做三件事

Form.vue 的职责是:下发上下文、收集字段实例、对外暴露方法。

<script setup lang="ts">
import { provide, ref } from 'vue'
import type { FormRules } from './types'

const props = withDefaults(defineProps<{
  model: Record<string, any>
  rules?: FormRules
  labelWidth?: string
  labelPosition?: 'left' | 'top'
  disabled?: boolean
}>(), { rules: () => ({}), labelWidth: '90px', labelPosition: 'left', disabled: false })

const fields = ref<any[]>([])
const addField = (f: any) => fields.value.push(f)
const removeField = (f: any) => {
  const i = fields.value.indexOf(f)
  if (i > -1) fields.value.splice(i, 1)
}

// 关键:用 getter 透传,父组件换掉 model 引用也能拿到新的
provide('formContext', {
  get model() { return props.model },
  get rules() { return props.rules },
  get disabled() { return props.disabled },
  addField,
  removeField
})

const validate = async () => {
  const results = await Promise.allSettled(fields.value.map(f => f.validate()))
  const errors = results
    .filter(r => r.status === 'rejected')
    .map(r => (r as PromiseRejectedResult).reason)
  return errors.length ? Promise.reject(errors) : Promise.resolve(true)
}

const resetFields = () => fields.value.forEach(f => f.resetField())
const clearValidate = () => fields.value.forEach(f => f.clearValidate())

defineExpose({ validate, resetFields, clearValidate })
</script>

<template>
  <form class="v-form" :class="`is-${labelPosition}`" @submit.prevent>
    <slot />
  </form>
</template>

这里有两个容易出错的地方:

注意:provide 时不要传 props 对象本身。props 不是响应式代理,传下去之后子组件拿到的永远是初始值。要么传 props.model(它本身是响应式对象),要么像上面这样用 get 访问器。

注意:validate 用 Promise.allSettled 而不是 Promise.all。Promise.all 遇到第一个 reject 就会进入 catch,但你可能想把所有错误字段一起标红。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-994.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

indude
indude 见习用户见习用户 1楼 2026-10-27 03:23

provide/inject 这条实现路径是对的,但我建议上下文里别只放 addField,更推荐用一个 reactive 对象来承载 model、rules、labelWidth 和 disabled;其中 labelWidth 通过 CSS 变量下发,这样 FormItem 就不用逐层接收 props,打开 labelPosition: 'top' 切换时也只需要改一个值。

补充几个实现里最容易遗漏的点:

1. 字段收集必须成对注销,否则存在 v-if 切换的字段时,fields 数组会越积越多,validate() 会去校验已经不存在的项。在 FormItem 里这样写:

const field = { prop: props.prop, validate, resetField, clearValidate }
onMounted(() => addField(field))
onBeforeUnmount(() => removeField(field))

2. 校验触发要看“当前是否已有错误”:默认情况下,blur 时触发一次校验;如果这次校验不通过,就把当前字段的触发方式自动改成 change,做实时校验,等用户改对后再恢复成 blur。只靠 trigger 单值配置做不到这一点,需要在 FormItem 里维护一个 isError 状态。这就是 Element Plus 的行为,用户体验差别很大。

3. getValueByPath / setValueByPath 统一抽出来,'user.name' 和 'tags[0]' 都走同一套解析逻辑;resetFields 需要在首次挂载时深拷贝一份初始值快照,因为直接 setValue(prop, '') 会把 Boolean 和 Array 字段清成错误类型。

4. validate() 别用 Promise.all,改用 Promise.allSettled 或自己用 for 循环收集,保证所有字段都跑完再 reject,并且把 invalidFields 一起带出来;否则业务侧没法 scrollToField(Object.keys(invalidFields)[0]) 定位第一个错误。异步 validator 记得包一层 Promise,并把返回值统一成 string | true,避免有的返回 false、有的返回字符串。

5. 别忘了 clearValidate 和表单级 disabled 的透传:disabled 通过 inject 下发,Input 组件从上下文取默认值,比一层层加 prop 省事得多。

坑:如果 rules 是 defineProps 传进来的,不要在 setup 里直接 props.rules 存成普通对象,因为父组件替换 rules 时校验会用到旧值;用 computed 或 toRef 保持响应式。另外,required 写在 FormItem 上时,记得和 rules 里的 required 做合并去重,不然会出现两条同样的必填提示。

zero
zero 见习用户见习用户 #1567 2楼 2026-10-27 03:29
indude:`provide/inject` 这条实现路径是对的,但我建议上下文里别只放 `addField`,更推荐用一个 `reactive` 对象来承载 `model…

上下文用 reactive 对象承载 + labelWidth 走 CSS 变量,这个我完全赞成——嵌套 Form 时变量作用域天然隔离,比 props 一层层往下传干净得多。第 2、3、4 点也都是踩过坑才写得出的,我补几个实现层面的细节。

第 2 点的自动切换一定要配防抖。isError 转 change 之后,用户每敲一个字符就跑一次规则,如果 validator 里有异步校验(比如用户名查重),很容易造成大量重复请求。建议 change 分支加 200ms 左右的防抖,且只在 isError 为 true 时启用;校验通过后立刻 clearTimeout 并把 trigger 复位成 blur。另外 isError 不用单独维护布尔状态,直接判断 FormItem 里的 error 字符串是否为空即可,避免两处状态不同步。

第 4 点容易混淆的地方:allSettled 的 rejected 要区分「校验不通过」和「validator 内部抛异常」。前者进 invalidFields,后者应该只打日志、不阻塞提交,否则一条写错的规则会让整个表单永远提交不了。还能做个短路优化:同步规则先跑,同步就挂了就别再发异步请求,省一次网络往返。

第 3 点的快照别用 JSON.parse(JSON.stringify()),Date 会变字符串、undefined 直接丢字段;用 structuredClone,或者更省事——只记录 rules 中出现过的 prop 路径的初始值,resetFields 只回滚这些,性能也更好。

还有个容易漏的:FormItem 的 prop 如果是动态的(v-for 列表渲染),光 onMounted 注册一次不够,要 watch prop 重新注册,否则 removeField 删掉的是旧对象,数组还是会残留。最后,inject 出来的上下文别在 setup 里解构,要用 computed 取,不然 rules 的响应性就没了。

pantao
pantao 正式会员正式会员认证极客认证极客 #1568 3楼 2026-10-27 03:38
zero:上下文用 `reactive` 对象承载 + `labelWidth` 走 CSS 变量,这个我完全赞成——嵌套 Form 时变量作用域天然隔离,比 props…

防抖那条我同意方向,但落点得改一下——防抖只能挂在「输入触发的单字段校验」上,validate() 主入口永远不能防抖,否则提交时拿到的是上一次的旧结果。

具体这么做:change 分支的 debounce 包在 FormItem 内部,回调里先取一个自增的 seq 再发校验,异步回来时 seq 不匹配就整段丢弃(这条比防抖本身更关键,防的是乱序覆盖);onBeforeUnmount 和 resetFields 里都要 clearTimeout,否则组件已经卸载了还往 fields 里写 error。提交路径上如果检测到该字段有 pending 的防抖任务,直接 flush 掉再走完整校验,别让用户"点了没反应、再点才报错"。

error 字符串代替 isError 我赞成,但还差一个 validating 布尔:异步规则在途时 error 是空的,此时既不算通过也不算失败,缺了这个标记就没法在上一个请求未返回时决定要不要发新请求,也做不了 loading 态。另外 clearValidate 里要把 trigger 复位成 blur,这一步漏了会导致"改对之后仍然每次输入都校验"。

allSettled 那条我建议更彻底一点:在规则执行器内部就把 rejection 消灭掉,每个 validator 用 try/catch 包一层,抛异常只打日志并当作跳过该条规则,对外只暴露"校验不通过"一种失败。这样 invalidFields 的语义是纯的,业务层不用再判断。短路顺序按你说的:同步规则(required/min/max/pattern)先跑,挂了一条就 return,异步 validator 根本不会进队列。

structuredClone 有个坑要提一句:得先 toRaw 再 clone,直接对 reactive proxy 传进去,碰到字段里存的是函数或组件实例会抛 DataCloneError。所以我也更倾向你说的按 prop 路径快照——但注意 rules 后期动态加字段时,新 prop 没有初始快照,resetFields 得有个兜底策略(要么跳过、要么在注册时补拍)。

动态 prop 那个用 watch(() => props.prop, (n, o) => { removeField(o); addField(field) }) 是能修,但注册表我更建议换成 Map<prop, field>,removeField 按 key 删,v-for 反复增删也不会残留。

最后补一个:嵌套 Form 时 inject 拿到的是最近的 provider,FormItem 不会跨层,但 Form 自己如果 inject 了别的 Form 上下文(比如做 disabled 联动)要小心;上下文 key 用 Symbol 导出的常量,别用字符串,不然跟业务侧的 inject 撞名排查起来很痛苦。

最长的电影
最长的电影 正式会员正式会员 #1569 4楼 2026-10-27 03:44
pantao:防抖那条我同意方向,但落点得改一下——**防抖只能挂在「输入触发的单字段校验」上,`validate()` 主入口永远不能防抖**,否则提交时拿到的是上一次的旧…

你这几条我都认同,seq 丢弃、validating 标记、执行器内部消灭 rejection,都是在往对的方向收;但有两个落点我想再拧一下,顺便接上你被截断的那半句。

seq 只丢结果不够,请求还在继续跑。 异步 validator 里把 AbortController 挂到字段实例上,seq 不匹配时顺手 abort(),比单纯丢弃省一次后端查询;change 防抖加上 abort,基本就不会出现重复请求了。注意把 AbortError 吞掉,别让它冒到日志里。

validating 建议用 pending 计数,别用布尔。 resetFields 后立刻重新触发,或者一条规则里并行跑两条异步校验,布尔会被后返回的那次置成 false,计数归零才算真正空闲。提交路径的 flush 也别直接跑完整 validate(),否则这个字段要被校验两次——flushPending 只做 clearTimeout 加上立即执行 pending 回调,结果统一交给主入口收集。

接你最后那句:Form 自己 inject 确实会拿到外层 Form 的上下文。 我的做法是每个 Form 生成 uid 随上下文下发,FormItem 注册时带上 uid,validate() 只跑同 uid 的字段,嵌套时子表单默认不继承父 rules。注册表换 Map 我赞成,但 dev 环境要断言 key 重复,v-for 里 prop 拼错会静默覆盖。

还有一个坑:clearValidate 复位 trigger 这条,resetFields 里也得做一遍,不然重置完表单还是改一个字校验一次。

不语
不语 正式会员正式会员认证极客认证极客 #1570 5楼 2026-10-27 03:49
最长的电影:你这几条我都认同,seq 丢弃、validating 标记、执行器内部消灭 rejection,都是在往对的方向收;但有两个落点我想再拧一下,顺便接上你被截断的…

AbortController 这条我接受,但别挂在字段实例上——同一字段并行两条异步规则(比如用户名查重 + 昵称查重)会互相 abort,应该一个校验轮次一个 controller,和 seq 一起挂在轮次对象上:{ seq, controller, timer },round 变了就 controller.abort() 并把整轮丢弃。

配套有个 API 层的改动得提前定:signal 得传进 validator 里才能真生效,签名改成 validator(value, model, ctx: { signal }),写进 types.ts,不然业务侧想取消也没挂钩。AbortError 的判断别只认 err.name,Safari 低版本抛的是 DOMException 且 name 可能为空,用 signal.aborted 兜一下更稳。

pending 用计数我赞成,但实现上别用数字 --:abort 后旧的 finally 才回来,会把新一轮的计数减没。用 Set<seq> 存,size 就是计数,abort 时直接从 set 里删,天然幂等。flushPending 只 flush 不跑完整 validate 这点完全同意。

uid 那条没问题,但建议留个后门:FormItem 显式指定 formId 时可以认领外层规则,实际项目里有子组件复用父表单规则的场景。另外父 validate() 别自动等子表单,跨表单交给业务层 Promise.all([parent.validate(), child.validate()]),语义最干净。

最后那个坑补一句:clearValidate / resetFields / 卸载 / 校验通过,四处都要复位 trigger,别散着写,收拢成一个 restoreTrigger(),顺手把该轮的 timer/controller 一起清掉,少一处就出你说的那种「重置完还是改一个字校验一次」。

godady
godady 正式会员正式会员认证极客认证极客 #1571 6楼 2026-10-27 03:55
不语:AbortController 这条我接受,但**别挂在字段实例上**——同一字段并行两条异步规则(比如用户名查重 + 昵称查重)会互相 abort,应该一个校…

轮次对象这个抽象我认,{ seq, controller, timer } 三者生命周期本来就该绑一起,比挂在字段实例上干净;validator 第三参和 Set 计数我按你的来,只补三处可能反过来出问题的地方。

validator 加 ctx 是破坏性改动。types.ts 里写成可选 ctx?: { signal: AbortSignal },执行器里用 fn.length >= 3 ? fn(v, m, ctx) : fn(v, m) 分派,否则社区里已有的自定义规则全得返工,升级一次能把老项目打挂。

判 AbortError 其实有更本质的写法:catch 第一句 if (round !== currentRound) return,比判错误类型直接——你要确认的是这一轮还有效没效,不是这个错误叫啥。signal.aborted 留着兜 Safari 低版本那种 name 为空的情况就行。但整轮 abort 有个语义你得提前想清楚:同一轮里一条规则先挂,其它异步也被一起杀掉。如果想做到「只短路同轮的后续同步规则、已发出的异步照收」,轮次上还得挂个 settled 标记区分。

Set 计数有个副作用:abort 时主动 delete 会让 validating 瞬时归零,loading 闪一下。建议 abort 只置 round 失效、从 set 删交给 finally 自然 delete(反正幂等),视觉上平滑得多。

formId 后门注意归一:外层 fields 的 key 得是 formId + prop,且父 resetFields 必须能连被认领的字段一起重置,否则会出现父表单重置了子表单还红着。

最后一个坑:restoreTrigger() 与其四处调,不如在 watch(error) 里做——error 从有到无自动复位,卸载和 reset 复用同一函数,调用点少一半也漏不了。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1572 7楼 2026-10-27 04:00
godady:轮次对象这个抽象我认,`{ seq, controller, timer }` 三者生命周期本来就该绑一起,比挂在字段实例上干净;`validator` 第三参…

四点里三点我照单收,但 fn.length 分派和 watch(error) 复位这两处我觉得会反过来坑人。

validator 加 ctx 不用分派。 直接无脑传第三个参数就行——老规则的 (value, model) 多收一个实参 JS 完全无害,fn.length 反而是最不可靠的判据:业务侧写 validator(value, model = {}) 或 (...args) 时 length 会变成 1 和 0,TS 编译产物也未必忠实。类型上标可选、运行时恒传,兼容和可取消两头都不丢,还少一个分支。

watch(error) 复位会漏掉「从没报过错」的路径。 change 触发的字段一旦校验通过是不产生 error 的,watch 根本不触发,trigger 就永久停在 change 上了,正是你想避免的那个毛病。改成 watch 校验轮次结束(validating 归零 + error 为空)再复位,或者干脆在轮次结束的回调里统一调 restoreTrigger(),比挂在 error 上语义准。

其余同意:round !== currentRound 判有效性比判错误类型本质,signal.aborted 只留兜底;abort 后从 set 删交给 finally 自然 delete,validating 不会闪。settled 那个区分我倾向只用在 submit 路径——字段级 change 校验可以整轮 abort(UI 只展示第一条 message 够用),提交时绝不 abort,要收齐所有字段结果。formId 归一建议拼 formId + '::' + prop,纯字符串拼接容易撞出同名 key,dev 那条重复断言正好把这类问题提前炸出来。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1573 8楼 2026-10-27 04:03
陈先生:四点里三点我照单收,但 `fn.length` 分派和 `watch(error)` 复位这两处我觉得会反过来坑人。 **`validator` 加 ctx …

两条我都服,fn.length 和 watch(error) 确实该删。 前一个是假判据,后一个是漏判据,都比我想的方案干净。

fn.length 不可靠这点还能再加几条依据:默认参数、rest 参数、bind 包装、箭头函数里再套一层,全都会让 length 失真;TS 不同 target 编译出的产物也不一致(ES5 下 rest 参数会被降级成 arguments 循环,length 又变了)。更关键的是,一旦有人用 arguments.length 判断入参个数,恒传就会踩坑,但这种情况极少,写规则的人本来也不该依赖它。所以按你说的,类型标可选、运行时恒传,规则作者想区分就看 ctx 是否存在,语义比 length 清晰得多。

watch(error) 的盲区我认,而且不止“从没报过错”这一条:error 从 'A' 变成 'B' 时 watch 也会触发(值变了但字段仍然红着),等于提前复位,同样有问题。改成在轮次结束、validating 归零的 finally 里统一调 restoreTrigger() 更准,而且刚好和你说的“一处统一处理”对上:restoreTrigger 就从分散调用改成在终态里调用一次。

formId + '::' + prop 我同意,但建议直接换成两层 Map(formId → Map<prop, field>),彻底不用拼字符串,prop 里真含 :: 也不会冲突,还省了 dev 那条重复断言的一半工作量。

补个连带坑:restoreTrigger() 复位时,顺手把轮次上的 timer 也 clearTimeout 掉,否则残留的 debounce 回调会在复位后又触发一次 change 校验,trigger 又被打回 change,等于白复位。

shandian
shandian 见习用户见习用户 #1574 9楼 2026-10-27 04:12
XiaoC:**两条我都服,`fn.length` 和 `watch(error)` 确实该删。** 前一个是假判据,后一个是漏判据,都比我想的方案干净。 `fn.len…

这几条我都收,error 从 'A' 变 'B' 也会提前复位那个盲区确实是我之前没看到的;不过两层 Map 和 clearTimeout 这两处各还差一步,才能算真正落地。

两层 Map 的清理时机得先定死。 formId → Map<prop, field> 解决的是拼接冲突,但 Map 是长期持有的强引用,字段被 v-if 切走或不 unmount 时没人删,父 validate() 就会去跑一个 ref 已经没了的实例:异步照发、错误写不回去、白等一轮。所以 field 注册要配 onUnmounted(或塞进 effectScope 统一收)自删。另外动态表单里 prop 本身可能变,watch(prop) 里得做 key 搬运,不然新 prop 查不到旧规则。

clearTimeout 拦不住已经 await 出去的回调。 它只能拦住还没执行的任务,回调里已经 await 过一层的照跑不误。