年初帮人调一个uniapp项目,对方明明把自定义选择器塞进了u-form-item,rules也配了必填,结果选中值之后,下面那条红字死活不出来。我第一反应是“数据没绑上”,结果form.gender打印出来是有值的;又想是不是rules写错了,检查一圈也没问题。折腾到最后才反应过来:uview的表单验证根本不是靠数据变化自驱动的,它靠的是事件通知。自定义组件没有参与uview那套事件协议,就永远是个“局外人”。
这篇文章就把这个问题的机制、排查链路和几种修复方案完整写清楚,顺便把隐藏表单、rules不生效这些连带坑也一起讲了。对于在HBuilderX里用uview或uview-plus做项目的朋友,应该能少走不少弯路。
1. 现象背后的真相:验证不是“自动”的,而是“事件驱动”的
1.1 一个完整校验动作需要哪三样东西
先明确一个基本认知:在uview里,“表单验证”不是一个全局监听form对象并实时响应的机制。要让某个字段在值改变时立即校验,必须同时满足三个条件:
u-form上绑定了model,也就是你要校验的数据对象;u-form-item上的prop属性,指向model里的某个字段名;u-form的rules里,配置了与prop同名的校验规则。
这三样缺了任何一样,校验都不会按预期触发。比如prop="gender",但rules里写的是{ sex: [...] },uview运行时不会报错,它只是默默找不到对应规则,然后什么都不做。这种静默失败是排查时最容易忽略的点,后面我会专门展开。
但三样都齐了,也只是“具备校验条件”,不等于“值一变就自动校验”。真正触发校验的,是u-form-item收到了“这个字段的值变了”的通知。这个通知从哪来?这是整个问题的核心。
1.2 内置组件和自定义组件的本质差异
拿uview内置的u-input来说,放到u-form-item里之后,它并不是靠v-model的数据变化去驱动校验的。内置组件内部实现了和u-form-item之间的通信:值变化时,它会主动向对应的u-form-item广播事件,u-form-item收到通知后,先检查prop指向的model字段,再按rules执行校验,最终决定要不要显示错误信息。
所以你可以理解为:u-input自带了一根“信号线”,直接接到了u-form-item上。数据变了,信号就传过去,校验立刻触发。
自定义扩展的表单组件(常见的比如自封装的picker选择器、部门树选择框、评分组件、签名板),默认没有这根“信号线”。它只是一个普通组件,虽然通过v-model把值同步给了父页面的form.gender,但u-form-item根本没有感知到这次变化。按钮点下去,数据变了,校验还是纹丝不动。
打个比方,内置组件像是一台带遥控器的电视,按遥控器(值变化)电视(u-form-item)就能响应;自定义组件像是手动机顶盒,哪怕画面已经在变了(model值更新),电视也没有收到遥控器信号,自然不会有校验动作。
理解了这一点,后面所有修复方案的本质就一句话:想办法让u-form-item在自定义组件值变化时收到通知。
2. 三个最容易断的链路点:一步步排查到底哪里断了
遇到“自定义表单不触发验证”,我建议按照下面三个点依次排查,每关都确认,基本能定位到问题在哪一环。
2.1 断点一:数据源根本没进model
先别管校验,确保值真的进了u-form的model里。很多自定义组件写了v-model,但组件的props和$emit没写对,实际值根本没传回父页面。
v-model本质上是一个语法糖,在Vue 2里等价于:
<custom-picker :value="form.gender" @input="form.gender = $event" />所以自定义组件内部必须做到两点:
- 用
props.value接收外部传进来的值,用于回显; - 内部值变化时执行
this.$emit('input', newValue)。
一个容易被忽略的细节:如果你的自定义组件内部使用了data里的副本,比如把props.value拷贝到innerValue来展示,然后在确认按钮里只改了innerValue没有把它emit出去,那么父页面的form.gender就永远是旧值。校验自然无从谈起。
排查方法很简单:在自定义组件的change回调里打印this.form,看看对应字段有没有更新。没更新,先修数据通道,修完再谈校验。
提示:在Vue 3 + uview-plus的环境下,
v-model对应的不再是input事件,而是update:modelValue。如果项目从Vue 2迁移到Vue 3,这种兼容问题也会导致类似现象,后面版本差异章节再细说。
2.2 断点二:prop、rules、model三者的字段名没对齐
数据没问题之后,检查这三个地方的字段名:
u-form-item上的prop;u-form的rules对象里的键;u-form的model对象里的键。
这三处必须完全一致。实际项目里我看到最多的是prop和rules不一致,比如页面上prop="confirmPassword",rules里写的却是{ pwd2: [...] }。更隐蔽的情况是:同一个页面有两套表单,一套是查询条件、一套是提交数据,复制粘贴时把字段名带串了。
把三个地方用一张表列出来对照,比盯着屏幕找半天快得多:
| 检查位置 | 示例值 | 常见错误 |
|---|---|---|
u-form-itemprop | gender | sex |
u-formrules键 | gender | gendar(拼写错误) |
u-formmodel键 | gender | 绑定的对象整体是错误的 |
还有一个新手容易犯的错:u-form的model绑的是.sync或者一个普通对象的一部分,导致u-form-item通过prop去读的时候读不到值。uview的u-form-item是要从form.model[prop]读取当前值的,如果model本身绑错对象,校验逻辑拿到undefined,规则自然跑不通。
2.3 断点三:值变了,但没有触发任何“通知”
这是自定义组件不触发校验的最典型原因,也是本文的核心断点。
uview的u-form-item默认通过事件机制接收子组件的变更通知。内置组件内部已经写好了这套广播逻辑,所以开箱即用。自定义组件没有这套逻辑,就算你把值同步到了model,u-form-item也感知不到,校验不会触发。
怎么确认是这一环断了?两种方法:
第一种,在自定义组件的值变化回调里加打印,确认事件有没有触发:
onChange(val) { console.log('自定义组件变化了', val) this.$emit('input', val) }如果日志打印了但校验还是不触发,而前两个断点又都排除了,那基本就是“通知没到达u-form-item”。
第二种,更直接:在父页面里手动调用一次校验:
this.$refs.form.validateField('gender')如果手动调用后错误提示能正常出现,说明规则和prop都配好了,剩下的问题就是“没有人去调用它”——这也印证了断点三的判断。
如果你在控制台看到类似“u-form-item未注册”或“can't find field”之类的提示,多半也是prop和rules没对上,或者u-form-item没写在u-form内部。
3. 修复实操:三个方案从最省事到最彻底
3.1 方案A:在父页面用change事件手工更新并调validateField
这是最直接、改动最小的方案,适用于不想动自定义组件源码的场景。
在自定义组件抛出的change事件里,手动更新form中的字段值,然后调用this.$refs.form.validateField('gender')触发指定字段的校验。
完整示例:
<template> <view> <u-form :model="form" ref="form" :rules="rules"> <u-form-item prop="gender" label="性别"> <custom-picker v-model="form.gender" @change="handleGenderChange" /> </u-form-item> <u-button @click="submit">提交</u-button> </u-form> </view> </template> <script> import CustomPicker from '@/components/custom-picker.vue' export default { components: { CustomPicker }, data() { return { form: { gender: '' }, rules: { gender: [ { required: true, message: '请选择性别', trigger: ['change'] } ] } } }, methods: { handleGenderChange(val) { this.form.gender = val // 确保数据更新完成后再出发校验 this.$nextTick(() => { this.$refs.form.validateField('gender') }) }, submit() { this.$refs.form.validate().then(() => { // 通过校验后的提交逻辑 }) } } } </script>关键细节是this.$nextTick。因为form.gender = val是同步操作,但uview内部的校验逻辑读取的是更新后的model,如果需要确保数据已经刷新到DOM和组件内部,nextTick里调用最稳妥。实测中即使不加nextTick在大多数场景也能工作,但加上了可以避免个别环境下读到旧值的偶发问题。
这个方案的优点是不碰自定义组件,适用于组件是第三方插件或者改动成本高的情况。缺点是每个字段都要手写一个处理函数,字段多的时候会很啰嗦。
3.2 方案B:给自定义组件加一层事件桥接,让它自动通知form-item
如果这个自定义组件要在多个页面复用,方案A的手写函数写一遍还行,写十遍就很容易漏。更优雅的做法是:在自定义组件内部,主动去调父页面的校验方法,让“通知”这一步被组件自己完成。
推荐方式是:在自定义组件里显式接收一个回调,或者在组件内部直接向上调用校验方法。后者实现简单,直接在组件内触发input的同时就调用校验,比如把自定义picker的确认逻辑写成这样:
methods: { confirm(value) { // 更新外部 v-model this.$emit('input', value) // 同步触发父页面的校验 this.$emit('change', value) } }然后在父页面统一处理:
handleFieldChange(field, val) { this.form[field] = val this.$nextTick(() => { this.$refs.form.validateField(field) }) }页面里的用法:
<u-form-item prop="gender" label="性别"> <custom-picker v-model="form.gender" @change="handleFieldChange('gender', $event)" /> </u-form-item>这样字段多起来也好维护,每个自定义组件都遵循同一个约定:值变化时抛出input和change两个事件。父页面用统一函数接住。核心逻辑集中在handleFieldChange里,以后就算加十个自定义字段,也只是复制一行模板代码的事。
这种方法已经能解决绝大多数场景,而且不像直接依赖uview内部事件名那样脆弱,版本升级后依然能工作。
3.3 方案C:用uview内置组件组合出“自定义效果”
有些“自定义表单组件”其实没必要自定义到那种程度。比如常见的部门树选择器、日期范围选择器,完全可以用uview的u-input加上只读属性、单击事件组合出来:
<u-form-item prop="department" label="所属部门"> <u-input v-model="form.department" readonly @click="openDeptPicker" placeholder="请选择部门" /> </u-form-item>这样做的核心好处是:u-input本身就实现了和u-form-item的通信,当form.department变化时(通过v-model更新后),u-form-item能感知到并触发校验。你只需要在弹窗确认选中的时候更新form.department即可。
confirmDept(dept) { this.form.department = dept.name }不需要validateField,不需要$nextTick,因为内置组件已经把整套事件协议做完了。对于样式上只需要简单自定义的场景,这个方案成本最低、最不容易出错。
如果样式要求高,也可以在u-input外面包一层view来做视觉定制,但让u-input保留在u-form-item内部承担“值同步和验证信号”的职责。
3.4 三个方案的适用场景对比
| 方案 | 改动成本 | 适合场景 | 缺点 |
|---|---|---|---|
| A:父页面手动validateField | 低 | 单个页面、第三方组件 | 字段多时代码重复 |
| B:组件事件桥接 + 统一处理函数 | 中 | 复用组件、多页面 | 需要维护组件约定 |
| C:内置组件组合 | 最低 | 选择器、只读输入等 | 复杂交互样式受限 |
实际项目里我建议:能用C就用C,因为内置组件最乖;需要复杂自定义交互的用B,把约定定好;A只作为应急手段,别整个页面几十个字段全靠它。
4. 更进一步:封装一个“可验证”的自定义表单组件
4.1 组件内部应该做什么(约定事件协议)
如果项目里自定义表单组件很多,建议在团队内部定一个组件规范,让所有自定义表单组件遵循统一协议:
- 接收
valueprop作为外部值,用于回显; - 内部值变化时依次抛t出
input和change两个事件; - 只负责“向外广播变化”,不直接操作u-form内部实例。
协议的核心思想是把“更新model”和“触发校验”两件事分开:input事件负责更新model,change事件负责告诉父页面去触发校验。父页面用统一函数接住,既不影响组件复用,也不绑定uview内部实现。
一个规范的自定义选择器组件示例:
<template> <view class="custom-select" @click="openPicker"> <text>{{ displayText }}</text> </view> </template> <script> export default { name: 'CustomSelect', props: { value: { type: [String, Number], default: '' }, options: { type: Array, default: () => [] } }, computed: { displayText() { const item = this.options.find(o => o.value === this.value) return item ? item.label : '请选择' } }, methods: { openPicker() { // 假设这里用 picker 弹出选择 this.$emit('picker', true) }, confirmSelect(item) { this.$emit('input', item.value) this.$emit('change', item.value) } } } </script>父页面的handleFieldChange统一函数同前面方案B,不再重复。
这里要克制一个诱惑:不要尝试在自定义组件里通过uni.$emit去模拟uview内置组件的内部广播。因为uview的事件名里包含formKey这类内部标识,不同版本实现有差异,写死事件名等于给自己埋雷。通过change事件向上层抛,再由父页面统一调用validateField,是兼容性最好的做法。
4.2 父页面的统一处理函数
我实际项目里习惯把这个处理函数放在mixin里,页面引入即用:
export default { methods: { // 自定义表单组件统一change处理 handleFieldChange(field, value) { this.form[field] = value this.$nextTick(() => { if (this.$refs.form) { this.$refs.form.validateField(field) } }) } } }配合模板:
<custom-picker @change="handleFieldChange('gender', $event)" />这样一个函数打天下,无论页面有多少自定义组件,模板写法都长一个样子,新同事接手也不用猜。
4.3 自定义错误样式与提示
u-form-item在校验不通过时,错误信息默认显示在表单项下方。如果自定义组件和内置组件视觉风格差异大,还可以通过u-form-item提供的一些属性自定义错误样式。
比如通过error-message属性手动覆盖错误文案,通过border属性控制边框高亮。需要自定义错误icon的时候,可以用u-form-item的插槽配合状态变量:
<u-form-item prop="gender" label="性别" :error-message="genderError" :border="genderError ? 'error' : 'normal'" > <custom-picker v-model="form.gender" @change="handleFieldChange('gender', $event)" /> </u-form-item>如果校验逻辑比较复杂,比如接口返回的错误要在特定时机展示,我建议在handleFieldChange里除了走uview的rules校验,还可以加一段业务判断来手动给genderError赋值。注意别把uview的rules校验和手动error信息混在一起,否则会出现“错误信息重复显示”的情况。
5. 顺带治一治那些“验证莫名不生效”的连带坑
5.1 隐藏表单校验:v-if和v-show的区别
自定义表单不触发验证还有一种变体:组件被放在了隐藏区域里。u-view的选择是高警:v-show是“只在DOM层面隐藏”,组件仍然渲染,u-form-item仍然存在,校验能正常执行;v-if是“条件渲染”,条件为false时整个u-form-item会被销毁,销毁期间校验方法根本找不到这个字段,错误提示也不会出现。
所以如果页面里有“条件展示”的表单项,建议用v-show保持u-form-item常驻。如果必须用v-if,提交时对隐藏字段要么跳过校验,要么在校验前保证条件为true。否则你会在提交时遇到“validate不通过但又看不到错误提示”的尴尬情况。
5.2 rules改了不生效与resetFields的坑
另一种常见的“校验不生效”是:数据里已经有rules,运行时修改rules后页面毫无反应。这是因为uview会对rules做响应式包装,直接修改某个规则数组的某一项,往往不会触发内部更新。需要整体重新赋值:
this.rules = { ...this.rules, gender: [{ required: true, message: '请重新选择性别', trigger: 'change' }] }另外,resetFields()这个方法的坑也不少。它会将form.model字段重置为初始值,同时清除校验状态。如果初始form.gender是'',但是自定义组件内部有自己的状态副本,调用resetFields后自定义组件的显示可能不会恢复。这也是为什么自定义组件内部回显要用props.value派生出来的computed,而不是自己维护一个innerValue。
5.3 uview和uview-plus在版本上的行为差异
项目如果用的是uview 2.x的维护版uview-plus,上面所有方案依然适用。但有几个版本相关差异需要注意:
- uview-plus面向Vue 3,
v-model的默认事件从input变成了update:modelValue,自定义组件如果要保持双向绑定,需要确认你写的是哪个事件名; - uview-plus官方在维护,一些问题可能已经做了兼容处理,建议遇到异常时先查插件的更新日志,很多“不触发校验”的个案恰恰是旧版本bug,升级就修好了;
- 在HBuilderX插件市场安装uview-plus时,注意安装后要重启服务,不然组件注册不完整,也会出现u-form-item行为异常的情况。
排查时先确认自己用的uview版本,再看问题是否能在新版本上复现。我之前就碰到过一个“u-input自带校验正常、u-grid里的自定义按钮不触发校验”的案例,结果升级uview-plus后问题自动消失,因为新版修复了事件销毁时未解除监听导致的广播失效bug。
回到最初那个带自定义选择器的项目,最后我用了方案B,把组件的事件约定和父页面的统一handleFieldChange函数搭好,一趟下来全部字段都正常出红字了。这个问题的核心还是搞清楚uview表单验证的事件驱动本质:让u-form-item知道“你的值变了”,比单纯更新数据重要得多。以后你遇到任何“自定义组件不触发验证”的情况,直接按数据是否进model、字段名是否对齐、通知是否送达这三步排查,比反复猜和试要高效得多。