在上篇 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>
- 点击同时更新,只更新了
end的值 - 分别更新时,数据变化是正常的(数据更新链路没有问题)
实际使用
有些同学会说,既然单次更新可以,那就只用单次就行了。比如 MyRange.vue 接收并抛出 [start, end]。
但这种方式并没有解决实际问题,因为:
- 通常在数据库的模型定义里,
start和end会分为两个字段存储。如果设计时因为技术问题合成单个v-model,业务侧在使用时还是需要拆成两个变量,反而把组件的复杂度丢给业务了。 - 在联动表单场景里,多个输入组件可能监听同一个变量,会出现多个输入组件同时修改自身值的场景。也会触发多个
v-model同时更新,此时问题是跨输入组件的。
这个场景在实际应用中是不可避免的场景,必须正面解决。而且问题不在输入组件(如 MyRange.vue),而在聚合层 MyForm.vue。
深入探索(一)
在 MyForm.vue 的 update 里打个日志,点击同时更新
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 }
深入探索(二)
既然 update 触发了两次,那么要么 App.vue 的 data 数据更新不成功,要么 MyForm.vue 的 props.modelValue 接收数据不成功。
再看下 App.vue 的 data 更新,打个日志:
<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}
从日志看,data.value 确实赋值了两次:从开始的 {} 到 {"start":1772763338810} 再到 {"end":1772763338810}
非常 amazing 啊,居然是 MyForm.vue 的 props.modelValue 接收数据存在问题。
可是 props.modelValue 不是直接指向 data.value 的吗?为什么它没有接收到中间的 {"start":1772763338810}?
问题本质
一句话:props 的更新是异步批处理的,而事件处理是同步执行的
在执行 App.vue 的 render 函数渲染 <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.value
- 首次渲染后,
MyForm.vue内的props.modelValue就指向了{}对象 MyForm.vue触发 update,实时更新了data.value的值,指向新的{ start: xxx }- 但是
App.vue的render并没有同步重新执行,因此,MyForm.vue内的props.modelValue还指向原本的{}对象 - 再次触发
update,data.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}
非常完美,update 触发了 2 次,App.vue 的 data 变化了一次:Playground
总结与思考
通过本文的剖析,我们不仅解决了 MyForm.vue 中多个 v-model 同时更新导致的数据丢失问题,更重要的是,我们触及了 Vue 响应式系统的核心运行机制——异步批处理与事件同步执行的矛盾。
这个问题的本质在于:同一 tick 内连续触发的多次更新,父组件的 props 并不会立即反映中间状态,而是在所有同步事件执行完毕后统一进行组件渲染。因此,依赖当前 props 构建新状态时,必须意识到 props 本身是“过时”的,只有通过缓存合并(如 patch 对象)才能保证最终结果的正确性。
技术之路没有银弹,每一次“坑”的填平,都是对框架原理的一次深入理解。希望本文能帮助你建立起对 Vue 异步更新和 v-model 机制的清晰认知,在未来的开发中,不仅能避开类似的坑,更能主动设计出优雅、健壮的组件。如果你在实践中遇到过其他有趣的“坑”,欢迎在评论区分享讨论,我们共同进步。