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