点一下表单的「重置」:输入框清空了,下拉回到占位符,但滑块验证码还是绿色「验证通过」,拖不动;点选验证码也停在通过态。
父组件明明把
v-model改回了false。这不是验证码的问题,是 Vue 受控组件最常见的三种「假同步」。这个问题在演示里看不出来:默认值就是
false,一切正常;只有第一次通过之后点重置,才会露馅。下面按「复现 → 三种写法 → 正确模板 → 测试与扫描」展开。
最小复现
vue
<SliderCaptcha v-model="captchaPassed" />
<button @click="captchaPassed = false">重置</button>
点重置后,captchaPassed 已经是 false,但组件里仍然是「验证通过」。点选验证码同理。用两个构建跑同一套操作,实测:
| 操作 | 0.2.28 组件状态 | 0.2.29 组件状态 |
|---|---|---|
初始 modelValue=true |
滑块 false / 点选 false | true / true |
| 改成 false(重置) | false / false | false / false |
| 再改成 true | 仍然 false / false | true / true |
0.2.28 里,父组件的值从头到尾都没进过组件------三个状态里有两个是错的。两个组件、同一套操作,结果完全一致,说明这不是某个组件的偶发问题,而是模式问题。
业务里更容易踩的版本
真实表单很少只有一个验证码:
vue
<Form ref="formRef" :model="form">
<FormItem label="手机号" prop="phone"><Input v-model="form.phone" /></FormItem>
<FormItem label="验证码" prop="captcha">
<SliderCaptcha v-model="form.captchaPassed" />
</FormItem>
<button @click="formRef.resetFields()">重置</button>
</Form>
resetFields() 会把 form 里的字段逐项还原成初始值,captchaPassed 从 true 变回 false。输入框、下拉都跟着变,只有验证码不动------因为验证码组件没监听这个字段。业务同学通常先怀疑表单库,最后才怀疑组件,这个 bug 能活很久。
假同步一:只 emit,不声明 prop
ClickCaptcha 的 meta 描述写着「v-model 置 true」,事件里也有 update:modelValue:
ts
const emit = defineEmits<{
"update:modelValue": [value: boolean];
success: [];
fail: [];
}>();
const passed = ref(false); // 从来没有读过 props.modelValue
但 props 里根本没有 modelValue。v-model 展开是「一个 prop + 一个事件」:
vue
<ClickCaptcha :model-value="passed" @update:model-value="passed = $event" />
组件不声明 prop,Vue 会把它当普通 attr 透传,passed 永远从 false 开始;父组件之后写 true 或 false,组件都收不到。它只完成了「往外发」,没完成「往里收」。
这种写法在文档里最容易被写成「支持 v-model」,因为事件确实发了;但类型定义里没有 prop,IDE 和测试都不会报错。
修法是补上 prop 和初始化:
ts
interface ClickCaptchaProps {
modelValue?: boolean;
}
const props = withDefaults(defineProps<ClickCaptchaProps>(), { modelValue: false });
const passed = ref(props.modelValue);
假同步二:初始化快照 + 挂载时洗掉
SliderCaptcha 看起来读了这个 prop:
ts
const passed = ref(props.modelValue);
但它挂载时调用了 onMounted(reset),而 reset 的第一行是 passed.value = false。于是外部传 true 也会被洗回 false。
更麻烦的是:它没有 watch。外部后续把值改成 false,组件不知道;改成 true,组件也不知道。所谓「受控」只在初始化那一瞬间成立,之后就是单向的。
快照式受控在「默认值即终值」的组件里很常见:只要父组件不重置,就永远看不出问题。
修法是把「重置视觉」和「重置状态」拆开:
ts
function resetVisual() {
pieceX.value = 0;
gapX.value = randomGap();
bgUrl.value = makeBackground();
}
onMounted(() => {
resetVisual(); // 只初始化视觉
if (passed.value) pieceX.value = gapX.value; // 外部 true 时对齐
});
watch(() => props.modelValue, (value) => {
if (value === passed.value) return;
passed.value = value;
if (value) pieceX.value = gapX.value;
else resetVisual();
});
这里有个细节:watch 里不要调用会 emit 的 reset() 。reset() 会 emit("update:modelValue", false),父组件再把值写回来,轻则多余事件,重则循环。视觉重置用一个不发事件的 resetVisual()。
假同步三:watch 只认 truthy
第三种最隐蔽,代码通常长这样:
ts
watch(() => props.modelValue, (value) => {
if (value) inner.value = value;
});
它处理了 true,把 false、0、'' 当成「没有新值」。表单重置恰好就是把值改成 false,于是永远不生效。这个模式在不少组件里都出现过------我们自己的 Tabs 之前就是这样,0.2.28 才改成「值不匹配就回落到首个可用项」。
判断标准很简单:受控字段的每一个合法值都要能写进来,包括假值。
为什么 v-model 会「假」
v-model 在模板里会被编译成一对绑定:
vue
<SliderCaptcha :model-value="passed" @update:model-value="passed = $event" />
props 只向下、事件只向上,这是 Vue 的单向数据流。组件要「受控」,必须两边都接:读 prop 决定当前状态,用户操作后 emit 通知父组件。只 emit 的组件其实是一个「非受控组件 + 通知」------父组件能听到变化,但改不动它。
判断一个组件是不是真受控,有个简单的实验:在父组件里把值改成另一个合法值,看组件有没有变。变了才是受控;只在用户点击时同步,叫「半受控」。
正确的受控写法
把上面三种合成一个模板:
ts
const props = withDefaults(defineProps<{ modelValue?: boolean }>(), {
modelValue: false,
});
const emit = defineEmits<{ "update:modelValue": [v: boolean] }>();
const inner = ref(props.modelValue);
watch(() => props.modelValue, (value) => {
if (value === inner.value) return; // 避免重复重置
inner.value = value;
if (!value) resetVisual(); // 外部重置 -> 只重置视觉
});
function reset() { // 内部主动重置 -> 通知父组件
inner.value = false;
resetVisual();
emit("update:modelValue", false);
}
三条约束:
- prop 必须真的声明,只 emit 不算受控;
- watch 必须覆盖所有合法值,不能只判断 truthy;
- 外部同步与内部通知分开:watch 里不 emit,reset 里才 emit。
边界:假值、引用与异步
- 假值 :
false、0、''都是合法值。if (value)会把它们当成「没有新值」,这是第三种假同步的根源。 - 对象/数组 :
modelValue是对象时,外部改的是引用还是内容?如果是同一个引用,watch默认浅比较不会触发;需要deep: true,或者让父组件换一个新对象。 - 异步重置 :接口返回后才重置时,组件可能正在拖动。watch 里重置视觉要取消进行中的拖动(把
dragging置false),否则松手还会走一次提交。 - 多个实例共享一个值 :两个验证码绑同一个
modelValue时,一个通过另一个也会显示通过------这是业务层的问题,但组件文档应该写清楚「一个实例一个值」。 - SSR 首屏 :服务端渲染的初始 prop 与客户端 hydration 后的值不一致时,
watch不会触发,需要靠key重建或显式的挂载同步。
为什么原来的单测没发现
SliderCaptcha 本来就有一条「外部传入 modelValue=true 时显示通过态」的用例,而且一直是绿的:
ts
const wrapper = mount(SliderCaptcha, { props: { modelValue: true } });
expect(wrapper.classes()).toContain("rn-slider-captcha--passed");
onMounted(reset) 把状态改成了 false,但这次响应式更新只是进了队列,DOM 还是挂载时那份「通过态」。断言在 nextTick 之前跑,读到的是旧 DOM------测试通过,组件其实是坏的。
把测试改成先等一帧,它立刻变红:
ts
await nextTick();
expect(wrapper.classes()).toContain("rn-slider-captcha--passed");
补完后的三条用例覆盖了初始值、外部重置、重置后可再次拖动:
ts
it("父组件改回 false 后恢复为未通过", async () => {
const wrapper = mount(SliderCaptcha, { props: { modelValue: true } });
await nextTick();
expect(wrapper.classes()).toContain("rn-slider-captcha--passed");
await wrapper.setProps({ modelValue: false });
await nextTick();
expect(wrapper.classes()).not.toContain("rn-slider-captcha--passed");
await wrapper.find(".rn-slider-captcha__track")
.trigger("pointerdown", { pointerId: 1, clientX: 10 });
expect(wrapper.find(".rn-slider-captcha__track").classes())
.toContain("rn-slider-captcha__track--dragging");
});
测试矩阵:只测 emit 不够
受控组件至少要覆盖四类用例:
| 用例 | 断言 |
|---|---|
| 初始值 | 外部传 true/false/0/'',组件首次渲染就是对应状态 |
| 外部写入 | setProps 改值后,组件状态跟着变(要 await nextTick()) |
| 内部操作 | 点击/拖动后 emit 了正确的值,组件自身也更新 |
| 重置 | 外部改回 false 后,视觉、进度、可交互状态全部还原 |
原来的 SliderCaptcha 只有第一类的一条,而且断言在 nextTick 之前;第二类和第四类完全缺失。补完之后,这三类问题才第一次被锁住。四类用例的公共前提是:断言前先 await nextTick(),否则读到的还是旧 DOM。
一个可复制的扫描脚本
这类问题可以静态扫出来。把下面存成 scan-controlled.mjs,传组件目录即可:
js
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const dir = process.argv[2] ?? "src/components";
for (const name of readdirSync(dir)) {
const file = join(dir, name, name + ".vue");
let src = "";
try { src = readFileSync(file, "utf8"); } catch { continue; }
const emitsMV = src.includes('"update:modelValue"');
const hasProp = /modelValue\??\s*:/.test(src);
const snap = /ref(?:<[^>]*>)?\(props\.modelValue\)/.test(src);
const watches = /watch\(\s*\(\)\s*=>\s*props\.modelValue/.test(src);
const truthyOnly = /watch\(\s*\(\)\s*=>\s*props\.modelValue[\s\S]{0,120}?if\s*\(\s*(val|value|v)\s*\)/.test(src);
if (emitsMV && !hasProp) console.log(name, "emit 了 update:modelValue,但没有 modelValue prop");
else if (snap && !watches) console.log(name, "读了初始 modelValue,但没有 watch");
else if (truthyOnly) console.log(name, "watch 只处理 truthy,false/0/'' 会被吞");
}
我们的 97 个组件扫出来 5 条:2 条是真 bug(ClickCaptcha 没 prop、SliderCaptcha 没 watch),3 条是误报(Dialog/Drawer/MessageBox 的 watch 里是 if/else,假值有分支)。扫描只负责把候选缩到 5 个,真假还要逐个人工确认。
把它挂到 CI 的 lint 步骤里,新组件只要出现「只 emit 不声明 prop」就会在合并前暴露;误报用注释白名单处理,不要为了 0 报警把规则删掉。脚本只做正则匹配,不理解 AST;命名不同(如 checked、visible)的受控字段需要换规则。
换成 defineModel 会怎样
Vue 3.4+ 可以写成:
ts
const model = defineModel<boolean>({ default: false });
watch(model, (value) => {
if (!value) resetVisual();
});
少了一个 prop 声明和一对 emit,v-model 自动成立。但它只解决「第一种假同步」,后两种仍然要自己写 :挂载时不能洗掉外部值,watch 不能只判断 truthy。defineModel 是语法糖,不是受控语义的保险。
另外,defineModel 在 watch 里赋值会触发 update:modelValue,不比手写 emit 更安全;「外部同步不走 emit」这条规则仍然成立。
| 写法 | 少写什么 | 仍要自己做什么 |
|---|---|---|
| props + emit | 无 | prop 声明、watch、emit 分离 |
defineModel |
prop 与 emit | watch 所有合法值、外部同步不 emit |
非受控 + key |
不需要同步 | 父组件改 key 强制重建,状态全丢 |
第三种在「重置」场景其实可用:<SliderCaptcha :key="resetKey" />,重置时 resetKey++,组件整个重建。代价是重建期间画布重新生成、动画重来;验证码这种轻组件可以,复杂表单控件会闪。defineModel 少写样板,但不改变语义------该写的 watch 一行都少不了。
结果
0.2.29 发布:
ClickCaptcha补modelValueprop,外部 true/false 都能写进来,重置时清空点选进度;SliderCaptcha挂载不再洗掉外部值,补 watch 同步重置与通过态;- 单测 933 → 936,新增「初始 true」「外部重置」「重置后可拖动」三条;
- 真机断言 6/6:0.2.28 外部设 true 组件仍是 false;0.2.29 初始 / 重置 / 再设 true 三态一致;
- 文档新增「表单重置」示例,重置按钮旁边直接显示状态文案;
- 共 10 张对照图,覆盖三个状态与真实文档页。










结论
v-model 不是一个事件,是一对契约:prop 负责把值送进来,emit 负责把值送出去。只做一半,就是假受控。受控组件的 bug 大多不是逻辑写错,而是契约只写了一半。
三条自查,按顺序检查,前一条不成立时后一条没有意义:
- 组件里出现
update:modelValue,就必须有同名 prop;没有 prop 的 v-model 只是「能收到通知」,不是「能接受控制」。 - 只要组件把 prop 拷进本地 ref,就必须有
watch,而且要覆盖false / 0 / ''。 - 外部同步和内部通知要分开:watch 里只改本地状态,用户操作才 emit。
第三条是这次最容易踩的:在 watch 里复用 reset() 看起来最省事,但它会把父组件的值再打回去;拆出一个不发事件的 resetVisual(),逻辑才闭环。
写完组件后可以做一次 30 秒实验:父组件放两个按钮,一个把值设成 true,一个设成 false,各点一次。组件不跟着变,就还没受控。
组件解剖 · 系列目录
- Table:虚拟滚动的三个硬约束
- Transfer:三态复选框的三个 bug
- Select:可搜索下拉的键盘、ARIA 与滚动
- Carousel:自动播放的暂停机制与播报时机
- Tabs:roving tabindex 的隐藏前提
- 验证码(本篇):v-model 的三种假同步
在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...