Vue 表单避坑(一):为什么 v-model 绑定对象属性会偷偷修改父组件数据?

9 阅读3分钟

场景引入

在 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 里,我们定义一个 model,接着直接把 model 的属性绑定到 MyInput 上:

<!-- 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

<!-- 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 事件):

<!-- 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 中会被展开为:

<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 调整为

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 函数,生成一个新对象来赋值。

<!-- 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 事件)来请求变更。这是保证状态可预测、可调试的基石。