Vue 3.4 重磅升級:defineModel 宏如何徹底改變前端狀態(tài)管理!
理解狀態(tài)的位置和組件邊界仍然是現(xiàn)代前端開發(fā)中主要挑戰(zhàn)之一,也是團(tuán)隊(duì)在應(yīng)用規(guī)模增加時(shí)做出的最重要的決定之一,可能會加速開發(fā),也可能成為最大的摩擦源。
如果做得好,構(gòu)建、組合、重構(gòu)和測試前端組件會變得輕而易舉;如果做得不好,則會成為難以追蹤的幽靈錯(cuò)誤的無盡源泉,使代碼庫變得脆弱。
Vue 3.4 版本中從實(shí)驗(yàn)狀態(tài)發(fā)布的 defineModel 宏,可能是關(guān)于組件間復(fù)雜狀態(tài)交互的實(shí)現(xiàn)方式最具變革性的特性之一。
描述看似很無害:
defineModel 是一個(gè)新的 <script setup> 宏,旨在簡化支持 v-model 的組件的實(shí)現(xiàn)
表面上看,這個(gè)宏的實(shí)用性似乎微不足道,但它對團(tuán)隊(duì)如何處理狀態(tài)和管理組件邊界有深遠(yuǎn)影響。我們看看 defineModel 是做什么的,為什么它的添加在 Vue 3.4 中感覺像是一種范式轉(zhuǎn)變——盡管它只是一個(gè)簡單的宏。
狀態(tài)的概念模型
通常,現(xiàn)代前端應(yīng)用程序有三種狀態(tài)范圍(不包括全局窗口級別的狀態(tài))。
圖片
- 全局共享狀態(tài)。這是整個(gè)層次結(jié)構(gòu)中不同組件可訪問的狀態(tài),例如登錄用戶的賬戶信息和路由間共享的信息。
- 分層組件狀態(tài)。這是在層次結(jié)構(gòu)的子樹中不同組件可訪問的狀態(tài),例如列表-詳情編輯視圖。
- 單個(gè)組件級別的狀態(tài)。這是僅在層次結(jié)構(gòu)中的單個(gè)組件中可訪問的狀態(tài),狀態(tài)交互不需要在樹中上下傳遞。
在全局級別,有許多庫和解決方案可以解決這個(gè)問題。例如,React 的 Zustand、Jotai、Recoil、Redux 等,Vue 的 Pinia 可以將狀態(tài)從組件樹中提取出來放入全局范圍,以跨越樹。它旨在保存真正的全局狀態(tài),如淺色/深色模式或租戶 ID。
第二層狀態(tài)是團(tuán)隊(duì)遇到“屬性傳遞”摩擦的地方——無論是在 React、Vue 還是其他庫或框架中。部分原因是管理狀態(tài)在組件之間的上下移動是繁重的。
在這種情況下,團(tuán)隊(duì)的自然決定是將狀態(tài)移動到全局存儲中,或者進(jìn)入第三個(gè)組件狀態(tài)范圍,僅僅是為了避免這種摩擦,而不斷堆積到一個(gè)巨大的組件中——這會產(chǎn)生另一種痛苦。
如果能在保持 Vue 的雙向綁定的同時(shí),輕松地將狀態(tài)分離開來,而不需要屬性傳遞的摩擦,那該多好。這正是 defineModel 的作用所在,它大大減少了在樹中移動狀態(tài)的摩擦,同時(shí)保持 Vue 的雙向綁定。
defineModel 是什么?
重要的是首先了解它是什么以及它的功能。對于那些不熟悉 Vue 的人來說,組件間上下移動狀態(tài)的慣用模式一直是使用 props 和 emits。
在 defineModel 之前 - props 和 emits
例如,考慮這個(gè)父子組件:
圖片
外部組件定義了 ref 并將其作為 prop 傳遞給子組件。更新通過子組件向父組件的 emit 事件來完成。
為了獲得雙向綁定,我們需要內(nèi)部的 NameInput.vue 組件如下:
<!-- NameInput.vue -->
<template>
<LabeledContainer label="NameInput.vue">
<input v-model="name"/>
</LabeledContainer>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emits = defineEmits<{
'update:modelValue': [string]
}>()
const name = computed({
get() {
return props.modelValue
},
set(val) {
emits('update:modelValue', val)
}
})
</script>
外部的 Example1.vue 組件如下:
<!-- Example1.vue -->
<template>
<LabeledContainer label="Example1.vue">
<h1>Example 1</h1>
<p>Hello, {{ name.length === 0 ? "(enter your name below)" : name }}</p>
<NameInput v-model="name"/>
</LabeledContainer>
</template>
<script setup lang="ts">
const name = ref('')
</script>
現(xiàn)在,當(dāng)我們在文本框中輸入值時(shí),它會自動更新 prop 的值:
圖片
我們的非常簡單的組件具有父子組件之間的雙向綁定。
可以很容易地看到,對于如此簡單的事情,這種樣板代碼會變得多么繁瑣!
在 defineModel 之后 ?
隨著 Vue 3.4 中 defineModel 的發(fā)布,我們看看它如何簡化 NameInput.vue:
<!-- NameInput.vue -->
<template>
<LabeledContainer label="NameInput.vue">
<input v-model="name"/>
</LabeledContainer>
</template>
<script setup lang="ts">
const name = defineModel<string>({ required: true })
</script>
父組件保持不變,但大量的樣板代碼被刪除了!這個(gè)小小的宏完全改變了管理狀態(tài)的體驗(yàn)。
一個(gè)實(shí)際的例子
表面上看,這似乎是一個(gè)微不足道的變化。當(dāng)然,獲得了一些便利,但這對開發(fā)人員管理狀態(tài)有多大影響?僅僅是一個(gè)簡單的宏,聲稱會有這么大的影響豈不是荒謬?
事實(shí)上,開發(fā)人員往往會選擇阻力最小的路徑,如果阻力最小的路徑是壞習(xí)慣,那么,開發(fā)人員將創(chuàng)建一個(gè)充滿許多壞習(xí)慣的代碼庫——即“技術(shù)債務(wù)”。如果你見過 1000 多行的 React 或 Vue 組件(我們中誰沒見過?),那么很可能的原因是將狀態(tài)以可管理的方式分散出來的摩擦太大;隨著組件的有機(jī)增長,將狀態(tài)保持在一個(gè)巨大的組件中比分解出新組件更容易。
defineModel 的實(shí)現(xiàn)是,它創(chuàng)建了一條最小阻力路徑,同時(shí)有助于改善團(tuán)隊(duì)對狀態(tài)的思考方式。突然之間,管理分層組件狀態(tài)變得微不足道,并消除了將狀態(tài)移入全局范圍或在大型組件中進(jìn)行松散操作的誘惑(通常是 1000 多行組件的來源)。
使用 defineModel 簡化分層狀態(tài)
考慮以下簡單的聯(lián)系人管理應(yīng)用程序:
圖片
注意這個(gè)示例中的層次結(jié)構(gòu)。當(dāng)用戶從 Listing.vue 中選擇聯(lián)系人時(shí),應(yīng)用程序應(yīng)在 Details.vue 中顯示詳細(xì)信息。當(dāng)用戶編輯詳細(xì)信息并在 Details.vue 中保存更改時(shí),應(yīng)用程序應(yīng)更新 Listing.vue 中的條目。
如果我們想在 Listing.vue 和 Details.vue 之間共享狀態(tài),它必須是全局狀態(tài)或從公共父級 Example3.vue 開始的分層狀態(tài)——否則,很容易看到將所有內(nèi)容放入一個(gè)巨大的組件中的誘惑!
在這種情況下,這就是我們的分層狀態(tài)的樣子:
圖片
狀態(tài)通過 prop 從列表組件傳遞到聯(lián)系人組件。
我們從外到內(nèi)檢查代碼。
這是我們的父 Example3.vue 組件:
<template>
<LabeledContainer label="Example3.vue">
<h1>Example 3</h1>
<p v-if="!!selectedContact">
Selected: {{ selectedContact.name }} ({{ selectedContact.handle }})
</p>
<div class="parent">
<Listing
v-model="contacts"
v-model:selected="selectedContact"/>
<Details v-model="selectedContact"/>
</div>
</LabeledContainer>
</template>
<script setup lang="ts">
const selectedContact = ref<Contact>()
const contacts = ref<Contact[]>([{
name: 'Charles',
handle: '@chrlschn'
}])
</script>
這是我們的狀態(tài)所在根,并通過綁定將其傳遞給 Listing 和 Details 組件:
<!-- Snippet from Example3.vue-->
<Listing
v-model="contacts"
v-model:selected="selectedContact"/>
<Details v-model="selectedContact"/>
我們先看看 Details.vue :
<!-- Details.vue, the right side form inputs -->
<template>
<LabeledContainer label="Details.vue">
<div v-if="!!selected">
<label>
Name
<input v-model="name"/>
</label>
<label>
Handle
<input v-model="handle"/>
</label>
<div>
<button @click="handleCancel">Done</button>
<button @click="handleDone">Save</button>
</div>
</div>
<p v-else>
Select a contact
</p>
</LabeledContainer>
</template>
<script setup lang="ts">
const selected = defineModel<Contact|undefined>({
required: true
})
const name = ref('')
const handle = ref('')
watch (selected, (contact) => {
if (!contact) {
return
}
name.value = contact.name,
handle.value = contact.handle
})
function handleCancel() {
selected.value = undefined
}
function handleDone() {
if (!selected.value) {
return
}
selected.value.name = name.value;
selected.value.handle = handle.value;
}
</script>
這個(gè)組件的目的是擁有一組狀態(tài)副本,當(dāng)選中的聯(lián)系人更改時(shí),組件將值復(fù)制到本地狀態(tài),以便在不影響原始狀態(tài)的情況下(直到用戶保存),更改名稱和 handle。這也允許用戶取消任何編輯。
對于更大的屬性集,可以考慮創(chuàng)建對象的完整響應(yīng)式副本并直接綁定到它。
在左側(cè),Listing.vue 組件包含聯(lián)系人列表,并有添加新聯(lián)系人的選項(xiàng)。
<!-- Listing.vue -->
<template>
<LabeledContainer label="Listing.vue">
<div class="container">
<ContactItem
v-for="contact in contacts"
:cnotallow="contact"
:selected="selected == contact"
@click="selected = contact">
</ContactItem>
</div>
<div>
<button @click="handleAddContact"> Add contact </button>
</div>
</LabeledContainer>
</template>
<script setup lang="ts">
const contacts = defineModel<Contact[]>({
required: true
})
const selected = defineModel<Contact|undefined>('selected', {
required: true
})
function handleAddContact() {
contacts.value.push({
name: 'Name',
handle: 'Handle'
})
}
</script>
然后在 ContactItem.vue 中,Listing.vue 通過普通的 props 傳遞顯示值,因?yàn)檫@里不需要變更(也不需要雙向綁定):
<template>
<LabeledContainer
label="Contact.vue"
class="contact"
:class="{
'selected': !!selected
}">
<p class="name">{{ contact.name }}</p>
<p class="handle">{{ contact.handle }}</p>
</LabeledContainer>
</template>
<script setup lang="ts">
defineProps<{
contact: Contact,
selected?: boolean
}>()
</script>
我們看看這整個(gè)事情是如何結(jié)合在一起的:
圖片
我們的組件在示例組件樹的層次結(jié)構(gòu)中共享狀態(tài)。
如果沒有 defineModel 來幫助簡化這種交互,很容易看到本能是采取捷徑或?qū)顟B(tài)移入全局狀態(tài),因?yàn)榫帉懜鞣N emits 和 computed 會產(chǎn)生相當(dāng)大的摩擦,即使在這個(gè)小示例中也是如此!
正如 Billy Mays 可能會說:“但等一下!還有更多!”讓我們看看如何通過使用可組合組件進(jìn)一步簡化代碼。
使用 defineModel 和可組合組件
利用可組合組件可以將其提升到一個(gè)新的水平,并通過將狀態(tài)從組件中提取出來進(jìn)一步簡化我們的代碼。當(dāng)組件變得更大時(shí),這特別有用。
在 Vue 中,這很容易實(shí)現(xiàn),并使重構(gòu)和重新組織復(fù)雜性變得輕而易舉。
我們只需將我們的狀態(tài)和函數(shù)向上提取到另一個(gè)函數(shù)中:
// useContacts composable
export function useContacts() {
const selectedContact = ref<Contact>()
const contacts = ref<Contact[]>([{
name: 'Charles',
handle: '@chrlschn'
}])
function addContact() {
contacts.value.push({
name: 'Name',
handle: 'Handle'
})
}
return {
selectedContact,
contacts,
addContact
}
}
很容易看出,如果我們想從 Details.vue 中移動更多的邏輯和狀態(tài),將 name 和 handle refs 以及 handleCancel() 和 handleDone() 函數(shù)移動到另一個(gè)可組合組件中并共享它們是非常低摩擦的:
// useDetailsEditor.ts
// ?? 注意我們在這里接收響應(yīng)式 selectedContact
export function useDetailsEditor(
selectedContact: Ref<Contact|undefined>
) {
const name = ref('')
const handle = ref('')
// ?? 我們在 selectedContact 上添加一個(gè)監(jiān)聽器
// 以便更新封裝的狀態(tài)。
watch (selectedContact, (contact) => {
if (!contact) {
return
}
name.value = contact.name,
handle.value = contact.handle
})
function cancel() {
selectedContact.value = undefined
}
function done() {
if (!selectedContact.value) {
return
}
selectedContact.value.name = name.value;
selectedContact.value.handle = handle.value;
}
return {
name,
handle,
cancel,
done
}
}
然后我們更新 Details.vue:
<template>
<LabeledContainer label="Details.vue">
<div v-if="!!selected">
<div>
<label>
Name
<input v-model="name"/>
</label>
</div>
<div>
<label>
Handle
<input v-model="handle"/>
</label>
</div>
<div>
<button @click="cancel">Done</button>
<button @click="done">Save</button>
</div>
</div>
<p v-else>
Select a contact
</p>
</LabeledContainer>
</template>
<script setup lang="ts">
const selected = defineModel<Contact|undefined>({
required: true
})
const {
name,
handle,
cancel,
done
} = useDetailsEditor(selected)
// ?? 我們將選中的聯(lián)系人傳遞給可組合組件以便監(jiān)聽
</script>
使用 Vue 3 可組合組件和 Vue 3.4 的 defineModel 宏,這種方式可以很好地將相關(guān)狀態(tài)和邏輯干凈地分離和封裝。我希望清楚的是,通過簡單地復(fù)制狀態(tài)、函數(shù)、監(jiān)聽器和計(jì)算值并將它們粘貼到可組合組件中,可以輕松地重構(gòu)代碼。
這種模式可以使即使是大型子組件樹也變得易于管理、重構(gòu)和測試。
結(jié)論
Vue 3.4 中引入的 defineModel 實(shí)際上是一個(gè)深遠(yuǎn)的變化,它將幫助團(tuán)隊(duì)遵循最佳實(shí)踐并構(gòu)建更好、更易管理的組件。通過消除構(gòu)建分層狀態(tài)時(shí)的許多摩擦,它使得團(tuán)隊(duì)不太可能立即求助于全局狀態(tài)或倒退到松散的做法。
通過將 defineModel 與 Vue 可組合組件結(jié)合使用,團(tuán)隊(duì)可以通過組織和封裝相關(guān)狀態(tài)和邏輯來創(chuàng)建更干凈的組件,這些組件易于閱讀和理解。
當(dāng)尤雨溪首次提出 Vue 3 的 Composition API 時(shí),社區(qū)中有很多反對聲音,認(rèn)為它應(yīng)該保留 Options API 的簡單性和易用性。事后看來,很明顯,尤雨溪幫助 Vue 更好地?cái)U(kuò)展以應(yīng)對構(gòu)建大型項(xiàng)目的團(tuán)隊(duì)的路徑是正確的選擇。
隨著 3.4 的發(fā)布,由于它使?fàn)顟B(tài)管理變得更加簡潔和直觀,這種愿景現(xiàn)在感覺更加完整。在某種程度上,它有助于澄清狀態(tài)放置位置的復(fù)雜決策過程,使簡單而明顯的選擇成為正確的選擇。defineModel 就像是一顆投向海洋的小石子,其漣漪可能有一天會成為洶涌的波浪!