表单重置后,验证码还显示已通过:Vue 受控组件的 3 种假同步

点一下表单的「重置」:输入框清空了,下拉回到占位符,但滑块验证码还是绿色「验证通过」,拖不动;点选验证码也停在通过态。

父组件明明把 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);
}

三条约束:

  1. prop 必须真的声明,只 emit 不算受控;
  2. watch 必须覆盖所有合法值,不能只判断 truthy;
  3. 外部同步与内部通知分开: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 补 modelValue prop,外部 true/false 都能写进来,重置时清空点选进度;
  • SliderCaptcha 挂载不再洗掉外部值,补 watch 同步重置与通过态;
  • 单测 933 → 936,新增「初始 true」「外部重置」「重置后可拖动」三条;
  • 真机断言 6/6:0.2.28 外部设 true 组件仍是 false;0.2.29 初始 / 重置 / 再设 true 三态一致;
  • 文档新增「表单重置」示例,重置按钮旁边直接显示状态文案;
  • 共 10 张对照图,覆盖三个状态与真实文档页。

结论

v-model 不是一个事件,是一对契约:prop 负责把值送进来,emit 负责把值送出去。只做一半,就是假受控。受控组件的 bug 大多不是逻辑写错,而是契约只写了一半。

三条自查,按顺序检查,前一条不成立时后一条没有意义:

  1. 组件里出现 update:modelValue,就必须有同名 prop;没有 prop 的 v-model 只是「能收到通知」,不是「能接受控制」。
  2. 只要组件把 prop 拷进本地 ref,就必须有 watch,而且要覆盖 false / 0 / ''。
  3. 外部同步和内部通知要分开: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/...

相关推荐
ruanno_top4 小时前
给标签页加 roving tabindex,整组反而键盘进不去了
vue.js
仨孩子的爹5 小时前
CSS打印样式:字体族与行高在纸上的对照实测
vue.js
OpenTiny社区8 小时前
AI Agent场景组件库 TinyRobot v1.0 正式发布:一站式打造您的AI助手
vue.js·github
ruanno_top1 天前
轮播每 3 秒自己翻页:键盘停不下来,读屏也不知道换了页
vue.js
1-1=01 天前
Vue 3 模板语
前端·javascript·vue.js
Java小卷1 天前
面向 Schema 编程,Vue 3 页面级渲染引擎!轻松玩转各场景 Validation!
vue.js·低代码
FungLeo1 天前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
FungLeo1 天前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限