🧑💻 寫在開頭
點贊 + 收藏 === 學會🤣🤣🤣
場景引入
在 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 事件)來請求變更。這是保證狀態可預測、可調試的基石。