各位使用 Vue.js 開發的小夥伴們,你們都怎麼實作父子層組件資料的雙向綁定呢?如果你還在寫 prop + emit 的話,不妨進來看看吧。
在這篇文章我們並不會探討 Vue 2 與 Vue 3 是如何實現雙向綁定,單向資料流等等的知識 (或許未來有一天可以來聊聊)。本篇我們著重於從 Vue 2 到 Vue 3 一路走來,實作雙向綁定的開發體驗歷程,以及 Vue 3.4 版本後新增的重磅巨集 defineModel()
。
首先,來回顧一下雙向綁定資料在 Vue 2 時的開發體驗為何?眾所周知,Vue 採用單向資料流作為組件間的資料傳遞方式。
當我們希望一筆資料從父層組件傳遞給子層組件後,子層異動該筆資料時能夠實時地將異動內容更新回父層。這時我們就會分別建立父傳子與子傳父兩個資料傳遞動作,藉由分別建立兩個單向資料流,組合成能夠即時更新的跨組件雙向綁定。
// 父層組件
<template>
<div>
<ChildComponent v-model="foo" />
</div>
</template>
<script>
export default {
data() {
return {
foo: ''
}
}
}
</script>
// 子層組件
<template>
<div>
<input :value="value" @input="updateValue" />
</div>
</template>
<script>
export default {
props: ['value'],
methods: {
updateValue(event) {
this.$emit('input', event.target.value)
},
},
}
</script>
這也是過往我在 Vue 2 專案中的主要寫法。
// 父層組件
<template>
<div>
<ChildComponent :foo.sync="bar" />
</div>
</template>
<script>
export default {
data() {
return {
bar: '',
}
},
}
</script>
// 子層組件
<template>
<div>
<input :value="_foo" />
</div>
</template>
<script>
export default {
props: ['foo'],
computed() {
_foo {
get() {
return this.foo
},
set(newVal) {
this.$emit("update:foo", newVal)
},
},
},
}
</script>
這邊僅提出兩個較常見的寫法,其他還有很多寫法,但我們就不多贅述,趕緊把時間交棒給 Vue 3 吧。
到了 Vue 3,從 Vue 2 遷移指南 當中官方很明確地告知我們使用 v-model 取代 .sync。除此之外,Vue 3 在 Composition API 也提供了 defineProps() 和 defineEmits() 這兩個巨集來取代 Options API 當中的 props 和 emit。那我們來看看 Vue 3 剛發佈時期我們怎麼做雙向綁定吧。
首先我們用官方提供的這兩個巨集來改寫一下先前的 .sync 寫法。
// 父層組件
<template>
<div>
<ChildComponent v-model:foo="bar" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const bar = ref('')
</script>
// 子層組件
<template>
<div>
<input :value="_foo" />
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{ foo: string }>()
const emit = defineEmits<{ 'update:foo': [id: string] }>()
const _foo = computed({
get() {
return props.foo
},
set(value) {
emit('update:foo', value)
},
})
</script>
眼尖的小夥伴有沒有發現,使用兩個單向資料流來組合父子層資料雙向綁定的整體概念與過去並沒有什麼差異,講白了在開發體驗上就是換湯不換藥。這時我們的 Vue 開發好麻吉-VueUse 聽到了你各位懶惰的心聲。
在 VueUse 中提供了 useVModel 與 useVModels 兩個函式。兩者的差異,白話點講就是綁定一個或多個 v-model (或許未來有一天可以來聊聊 again)。
父層組件的部分與前例一致,接下來我們就專注在子層組件上。
// 子層組件
// 使用 useVModel 綁定一個欄位
<template>
<input v-model="_foo" />
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
const props = defineProps<{ foo: string }>()
const emit = defineEmits(['update:foo'])
const _foo = useVModel(props, 'foo', emit)
</script>
// 子層組件
// 使用 useVModels 綁定多個欄位
<template>
<input v-model="data.foo" />
<input v-model="data.bar" />
</template>
<script lang="ts" setup>
import { useVModels } from '@vueuse/core'
const props = defineProps<{
foo: string
bar: string
}>()
const emit = defineEmits<{
'update:foo': [id: string]
'update:bar': [id: string]
}>()
const data = useVModels(props, emit)
</script>
好的,看起來我們只要把宣告好的 props 和 emit 交給 useVModel / useVModels,接著 VueUse 就會貼心地幫我們完成雙向綁定。文件上官方也講得很明,它其實就是 props + emit 並產出 ref 的語法糖。
Shorthand for v-model binding, props + emit -> ref
BUT!!!
不是欸,props 和 emit 我還是得自己寫呀!怎麼有種做事做一半的感覺,能不能拜託行行好,直接好人做到底啊。
此時時間來到 Vue 3.4 版本發佈,我們終於迎來了開發體驗的曙光。
// 子層組件
<template>
<input v-model="foo" />
</template>
<script lang="ts" setup>
const foo = defineModel<string>('foo', { required: true })
</script>
OK,一行寫完。就是這麼簡單明瞭又暴力,堪稱工程師福音。現在就讓我們好好認識一下這個相見恨晚的巨集。(這麼多年了,你怎就不早點出現啊)
// 父層使用 v-model
const modelValue = defineModel() // 宣告即完成綁定 modelValue
modelValue.value = 'foo' // 修改即自動觸發 update:modelValue
// 父層使用 v-model:foo
const foo = defineModel('foo') // 宣告即完成綁定 foo
foo.value = 'bar' // 修改即自動觸發 update:foo
// 父層使用 v-model
const modelValue = defineModel({ type: String }) // JS
// or
const modelValue = defineModel<string>() // TS
// 父層使用 v-model:foo
const foo = defineModel('foo', { type: String }) // JS
// or
const foo = defineModel<string>('foo') // TS
// 父層使用 v-model
const modelValue = defineModel({ type: String, default: '我是預設值' }) // JS
// or
const modelValue = defineModel<string>({ default: '我是預設值' }) // TS
// 父層使用 v-model:foo
const foo = defineModel('foo', { type: String, default: '我是預設值' }) // JS
// or
const foo = defineModel<string>('foo', { default: '我是預設值' }) // TS
// 父層使用 v-model
const modelValue = defineModel({ type: String, required: true }) // JS
// or
const modelValue = defineModel<string>({ required: true }) // TS
// 父層使用 v-model:foo
const foo = defineModel('foo', { type: String, required: true }) // JS
// or
const foo = defineModel<string>('foo', { required: true }) // TS
這邊有個小技巧,當我們設定 required 時,該值的型別將自動排除掉 undefined。這在撰寫 TS 時,開發體驗真的大大加分。
const modelValue = defineModel<string>()
// modelValue 型別為 Ref<string | undefined>
const modelValue = defineModel<string>({ required: true })
// modelValue 型別為 Ref<string>
熟悉的修飾符也是可以用的。透過解構 defineModel 的回傳值來獲得 modelValue 和 modelModifiers,並且如過往操作 computed 一樣能夠使用 get 與 set,依照所需情境對資料進行加工處理。
// 父層使用 v-model.trim="foo"
const [modelValue, modelModifiers] = defineModel<string>({
// get() 省略了,因為這裡不需要它
set(value) {
// 如果使用了 .trim 修飾符,則回傳剪裁過後的值
if (modelModifiers.trim) {
return value.trim()
}
// 否則,就直接回傳
return value;
},
})
最後我想說的是,不管你是剛開始寫 Vue 的新手或是老鳥,從你認識 defineModel() 的那刻起,別再把生命浪費在瑣碎的事情上了。
一句話,真香。
Cheng's murmur
颱風假可以帶走你的上班日,
但帶不走你手頭上專案的 deadline 啊!