Vue 表单避坑(二):多个 v-model 同时更新,为什么数据丢了?

0 阅读5分钟

在上篇 Vue 表单避坑(一):为什么 v-model 绑定对象属性会偷偷修改父组件数据? 中,通过对 MyForm.vue 组件的重构,我们保持了 MyForm.vue 的单向数据流。

本篇里,MyForm.vue 内出现了一个新的输入控件 MyRange.vue,它支持两个 v-model,此时,原有的处理方式会遇到新的问题。

场景引入

App.vue 看进去

<script setup>
  import { ref } from 'vue'
  import MyForm from './MyForm.vue'

  const data = ref({  })
</script>

<template>
  <MyForm v-model="data" />
</template>

MyForm.vue

<script setup>
  import MyRange from './MyRange.vue'
  import { computed } from 'vue'
  const props = defineProps({
    modelValue: Object
  });
  const emit = defineEmits(['update:modelValue']);
  const model = computed({ 
    get: () => props.modelValue, 
    set: (v) => emit('update:modelValue', v) 
  })
  function update(k, v) {
    model.value = {
      ...model.value,
      [k]: v
    }
  }
</script>

<template>
  <MyRange 
    :start="model.start" 
    @update:start="v => update('start', v)" 
    :end="model.end" 
    @update:end="v => update('end', v)" 
  />
</template>

MyRange.vue

<script setup>
  import { computed } from 'vue'
  const props = defineProps({ start: Number, end: Number });
  const emit = defineEmits(['update:start', 'update:end']);
  const start = computed({ 
    get: () => props.start, 
    set: (v) => emit('update:start', v)  
  })
  const end = computed({ 
    get: () => props.end, 
    set: (v) => emit('update:end', v)  
  })
</script>

<template>
  <div>开始:
    <span>
      <span>{{ start }}</span>
      <button @click="start = Date.now()">更新</button>
    </span>
  </div> 
  <div>结束:
    <span>
      <span>{{ end }}</span>
      <button @click="end = Date.now()">更新</button>
    </span>
  </div>
  <button @click="() => {
    const now = Date.now()
    start = now
    end = now
  }">同时更新</button>
</template>

Mar-05-2026 21-12-36.gif

  • 点击同时更新,只更新了 end 的值
  • 分别更新时,数据变化是正常的(数据更新链路没有问题)

实际使用

有些同学会说,既然单次更新可以,那就只用单次就行了。比如 MyRange.vue 接收并抛出 [start, end]

但这种方式并没有解决实际问题,因为:

  • 通常在数据库的模型定义里,startend 会分为两个字段存储。如果设计时因为技术问题合成单个 v-model,业务侧在使用时还是需要拆成两个变量,反而把组件的复杂度丢给业务了。
  • 在联动表单场景里,多个输入组件可能监听同一个变量,会出现多个输入组件同时修改自身值的场景。也会触发多个 v-model 同时更新,此时问题是跨输入组件的。

这个场景在实际应用中是不可避免的场景,必须正面解决。而且问题不在输入组件(如 MyRange.vue),而在聚合层 MyForm.vue

深入探索(一)

MyForm.vueupdate 里打个日志,点击同时更新

function update(k, v) {
  console.log('update', k, v, JSON.stringify(model.value))
  model.value = {
    ...model.value,
    [k]: v
  }
}

结果是:

update start 1772761042296 {}
update end 1772761042296 {}

update 确实被调用了两次,但是两次 model.value 的值都是空对象,为什么?

为什么更新 end 时,model.value 的值不是 { start: xxx }

image.png

深入探索(二)

既然 update 触发了两次,那么要么 App.vuedata 数据更新不成功,要么 MyForm.vueprops.modelValue 接收数据不成功。

再看下 App.vuedata 更新,打个日志:

<script setup>
  import { ref, watch } from 'vue'
  import MyForm from './MyForm.vue'

  const data = ref({  })

  watch(data, v => console.log('data', JSON.stringify(v)), {
    flush: 'sync' // 注意,这里的 sync 很关键
  })
</script>

<template>
  <MyForm v-model="data" />
</template>

结果是:

update start 1772763338810 {}
data {"start":1772763338810}
update end 1772763338810 {}
data {"end":1772763338810}

image.png

从日志看,data.value 确实赋值了两次:从开始的 {}{"start":1772763338810} 再到 {"end":1772763338810}

非常 amazing 啊,居然是 MyForm.vueprops.modelValue 接收数据存在问题。

可是 props.modelValue 不是直接指向 data.value 的吗?为什么它没有接收到中间的 {"start":1772763338810}

问题本质

一句话:props 的更新是异步批处理的,而事件处理是同步执行的

在执行 App.vuerender 函数渲染 <MyForm v-model="data" /> 时,会转为 <MyForm :model-value="data.value" @update:model-value="v => data.value = v" />,也就是:

function render() {
  h(MyForm, {
    modelValue: data.value,
    'onUpdate:modelValue': (v) => data.value = v
  })
}

这里是简化代码,Vue 实际代码更复杂:会对 setup 的结果用 proxyRefs 处理,使用 shallowUnwrapHandlers 在 setup.data时拆成setup.data 时拆成 setup.data.value

  • 首次渲染后,MyForm.vue 内的 props.modelValue 就指向了 {} 对象
  • MyForm.vue 触发 update,实时更新了 data.value 的值,指向新的 { start: xxx }
  • 但是 App.vuerender 并没有同步重新执行,因此,MyForm.vue 内的 props.modelValue 还指向原本的 {} 对象
  • 再次触发 updatedata.value 更新为 { end: xxx }{ start: xxx } 没有任何使用即被丢弃。
  • 本轮赋值结束,Vue 发现 App.vue 内的 data.value 数据发生变化,重新执行 render
  • 之后,MyForm.vue 内的 props.modelValue 才指向新的 { end: xxx } 对象

修复方案

既然一个 tick 内的更新并不会立马被 MyForm 接收,那么,我们可以合并一个 tick 内的更新,等到 nextTick 时统一抛出

<script setup>
  import MyRange from './MyRange.vue'
  import { computed, nextTick } from 'vue'
  const props = defineProps({
    modelValue: Object
  });
  const emit = defineEmits(['update:modelValue']);
  const model = computed({ 
    get: () => props.modelValue, 
    set: (v) => emit('update:modelValue', v) 
  })
  let patch = null
  function update(k, v) {
    console.log('update', k, v, JSON.stringify(model.value))
    if (!patch) {
      patch = {}
      nextTick(() => {
        model.value = {
          ...model.value,
          ...patch
        }
        patch = null    
      })
    }
    patch[k] = v
  }
</script>

<template>
  <MyRange 
    :start="model.start" 
    @update:start="v => update('start', v)" 
    :end="model.end" 
    @update:end="v => update('end', v)" 
  />
</template>

结果是

update start 1772777859462 {}
update end 1772777859462 {}
data {"start":1772777859462,"end":1772777859462}

image.png

非常完美,update 触发了 2 次,App.vue 的 data 变化了一次:Playground

总结与思考

通过本文的剖析,我们不仅解决了 MyForm.vue 中多个 v-model 同时更新导致的数据丢失问题,更重要的是,我们触及了 Vue 响应式系统的核心运行机制——异步批处理与事件同步执行的矛盾

这个问题的本质在于:同一 tick 内连续触发的多次更新,父组件的 props 并不会立即反映中间状态,而是在所有同步事件执行完毕后统一进行组件渲染。因此,依赖当前 props 构建新状态时,必须意识到 props 本身是“过时”的,只有通过缓存合并(如 patch 对象)才能保证最终结果的正确性。

技术之路没有银弹,每一次“坑”的填平,都是对框架原理的一次深入理解。希望本文能帮助你建立起对 Vue 异步更新和 v-model 机制的清晰认知,在未来的开发中,不仅能避开类似的坑,更能主动设计出优雅、健壮的组件。如果你在实践中遇到过其他有趣的“坑”,欢迎在评论区分享讨论,我们共同进步。