博客 / 詳情

返回

Vue 表單避坑:為什麼 v-model 綁定對象屬性會偷偷修改父組件數據?

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

場景引入

在 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 事件)來請求變更。這是保證狀態可預測、可調試的基石。

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文檔,大家一起討論學習,一起進步。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.