场景引入
在 Vue 项目里,表单组件几乎无处不在。为了提高复用性,我们常常会把一堆输入框封装成一个"大表单组件",然后通过 v-model 直接绑定一个对象给外部组件:
html
<!-- 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 里,我们定义一个 model,接着直接把 model 的属性绑定到 MyInput 上:
html
<!-- MyForm.vue -->
<script setup>
import MyInput from './MyInput.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)
})
</script>
<template>
<div>开始:<MyInput v-model="model.start" /></div>
<div>结束:<MyInput v-model="model.end" /></div>
</template>
最后是简单的 MyInput.vue:
html
<!-- MyInput.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: Number
});
const emit = defineEmits(['update:modelValue']);
const value = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
</script>
<template>
<span>
<span>{{ value }}</span>
<button @click="value = Date.now()">更新</button>
</span>
</template>
看起来一气呵成,干净又优雅,不是吗?
然而,这段代码已经违背了单向数据流原则。
先做个实验:把 v-model 换成 :model-value
把 App.vue 里的 v-model 改成 :model-value(也就是只传 prop,不监听 update 事件):
html
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import MyForm from './MyForm.vue'
const data = ref({ })
</script>
<template>
<MyForm :model-value="data" />
</template>
按常理,此时 data 不应该被子组件修改,因为父组件没有监听 update 事件。
但是点击按钮后你会发现------data 还是被改了! (不信可以去 Vue Playground 试试)
这就怪了,明明没有监听 update 事件,数据怎么变的?因为子组件直接修改了同一个对象的属性,绕过了事件机制。
问题的本质:v-model 直接绑定属性值时发生了什么?
在 MyForm.vue 中,我们写了 <MyInput v-model="model.start" />。v-model="model.start" 在 Vue 3 中会被展开为:
html
<MyInput
:model-value="model.start"
@update:model-value="v => model.start = v"
/>
model.start 是什么?是 modelValue 的一个属性,直接指向父组件的 data。所以 v => model.start = v 这一赋值直接修改了父组件的对象属性 ,根本没有触发 MyForm.vue 的 update:model-value 事件。
换句话说,MyForm.vue 没有发出 update:model-value 事件,App.vue 完全不知道自己数据已经被改了。
你还可以把 MyForm.vue 中的 model 调整为
js
const model = computed({
get: () => props.modelValue,
set: (v) => {
console.log('MyForm.vue update:modelValue', v)
emit('update:modelValue', v)
}
})
在控制台里,没有输出内容。console.log('MyForm.vue update:modelValue', v) 完全不会执行到
单向数据流到底是什么?
Vue 的单向数据流规定:
- 父组件通过 props 把数据交给子组件。
- 子组件不能直接修改 props,必须通过 emit 事件 通知父组件,由父组件自己修改数据。
- 数据永远是从父 → 子,事件是从子 → 父。
v-model 本身是符合单向数据流的------前提是你通过事件更新的是整个数据,而不是直接修改对象的属性。
在上面的例子中,虽然我们用了 v-model,但实际更新时是直接改了对象的属性,跳过了通知 App.vue 更新数据的步骤,在 MyForm.vue 中偷偷改了数据,违背了设计原则。
修复方案
既然直接绑定属性会导致"暗箱操作",那我们就改成显式的方式------**每次字段更新都通过一个 update 函数,生成一个新对象来赋值。
html
<!-- MyForm.vue -->
<script setup>
import MyInput from './MyInput.vue'
import { computed } from 'vue'
const props = defineProps({
modelValue: Object
});
const emit = defineEmits(['update:modelValue']);
const model = computed({
get: () => props.modelValue,
set: (v) => {
console.log('MyForm.vue update:modelValue', v)
emit('update:modelValue', v)
}
})
function update(k, v) {
model.value = {
...model.value,
[k]: v
}
}
</script>
<template>
<div>开始:<MyInput
:model-value="model.start"
@update:model-value="v => update('start', v)"
/></div>
<div>结束:<MyInput
:model-value="model.end"
@update:model-value="v => update('end', v)"
/></div>
</template>
此时,console.log('MyForm.vue update:modelValue', v) 代码正常执行。
App.vue 中 <MyForm :model-value="data" /> 时,内层无法更新外层数据。
小结
在组件化设计中,数据的"所有权"必须与"修改权"严格对应。 App.vue 作为数据的拥有者,应该掌握唯一的修改权限;MyForm.vue只能通过"申请-批准"的机制(即 emit 事件)来请求变更。这是保证状态可预测、可调试的基石。