Vue 中的受控與非受控元件
Vue 中的受控與非受控元件
熟悉 React 的開發者應該對“受控元件”的概念並不陌生,實際上對於任何元件化開發框架而言,都可以實現所謂的受控與非受控,Vue 當然也不例外。並且理解受控與非受控對應的需求場景,可以讓我們在設計一些基礎元件時思路更加清晰,暴露出來的元件 API 也更加合理、統一。
需求
許多 UI 元件都是有狀態(stateful)的,而這個狀態是由元件外部控制還是元件內部維護,也就對應了受控與非受控兩種模式。
例如 Tabs 元件是很常見的一種 UI 元件,它的核心狀態就是記錄當前 active 的 Tab,並且允許使用者切換。
很多時候我們只希望 Tabs 可以正確的展示 active 的內容、並在使用者操作時正常切換,不需要進行任何干預,那麼就希望 只需要傳入所有的 Tab 內容,不需要再做額外的配置。
但有的時候我們又希望對 Tabs 的狀態有很強的控制能力,例如多個關聯的 Tabs,子級 Tabs 的內容需要根據父級 Tabs 的 active Tab 動態切換,這時候就會希望 Tabs 元件可以暴露足夠充分的 API,來實現業務的需求。
因此我們可以用一種通用的模式,來讓任意元件的任意狀態同時相容受控與非受控兩種模式,讓不同需求場景下都可以使用最合理的 API。
簡化示例
我們用一個簡單的 Tabs 實現來演示這種通用的元件 API 設計模式,簡化的部分包括:
- 用 index 來作為 Tab 的唯一標識
- Tab content 只支援字串
可以開啟
API 設計
對於 Vue 元件而言,API 設計主要指的是內部的 data, computed, methods 以及對外的 props, events。在這個示例中,我們會用 activeIdx
作為核心狀態,所有的 API 也都會圍繞這個狀態命名。
非受控模式
如上文所說,非受控模式指的是使用者不需要關心控制組件的狀體,完全交由元件內部維護。
因此我們的 API 會包括:
{ props: { defaultActiveIdx: { type: Number, default: 0 } }, data() { return { localActiveIdx: this.defaultActiveIdx } }, methods: { handleActiveIdxChange(idx) { this.localActiveIdx = idx; this.$emit("active-idx-change", idx); } } }
localActiveIdx
是我們用來存放 active index 的元件內 data,對於非受控模式而言,雖然不希望在外部維護狀態,但是仍有可能希望在外部決定初始狀態,所以我們用 defaultActiveIdx
這個 props 決定 localActiveIdx
的初始值。
之後當我們用 v-for="(tab, idx) in tabs"
指令生成所有的 Tab 時,就可以通過 idx === localActiveIdx
的方式判斷當前 Tab 是否 active,再通過 @click="handleActiveIdxChange(idx)"
就可以實現對 localActiveIdx
的更新。
同樣的,我們也可以通過 {{ tabs[localActiveIdx].content }}
展示 active Tab 的內容。
需要注意的是在 handleActiveIdxChange
的事件處理中,我們也 emit 了 active-idx-change
這一事件,這樣可以方便外部在不需要管理元件狀態的同時也可以與元件狀態保持同步。例如我們希望將 active Tab 反映在 URL 中,就可以在外部監聽 active-idx-change
這一事件,並將當前 index 同步到路由中,在將路由中獲取到的 index 作為 defaultActiveIdx
傳入,就可以實現 URL 和 Tabs 的同步。
受控模式
對於受控模式來說,我們可以理解為 active index 是外部傳入的 props,由外部自行維護其狀態。
因此我們只需要新增如下 props:
props: {
activeIdx: Number
}
由於我們已經有對外 emit 的事件 active-idx-change
,所以外部用以下方式就可以用一個 data 屬性 externalActiveIdx
維護對應狀態:
當然由於在這種模式下外部對狀態有完全的控制權,所以在 active-idx-change
的事件處理中也可以做更為複雜的判斷,例如是否允許啟用目標 Tab 之類的校驗。
而在 Tabs 元件內部,我們還需要做一些小的修改。在受控模式中,我們所有狀態相關的處理都是直接使用 localActiveIdx
,而現在我們的邏輯應該變為“如果存在 activeIdx
props,則使用,否則使用 localActiveIdx
”。
為了保證以上邏輯不會讓我們的元件內部實現變得複雜、易錯,我們引入一個 computed 屬性:
computed: {
_activeIdx() {
return this.activeIdx || this.localActiveIdx;
}
}
這樣我們就可以把狀態相關的判斷改為通過 idx === _activeIdx
判斷一個 Tab 是否為啟用狀態,也通過 {{ tabs[_activeIdx].content }}
展示 active Tab 的內容。
同樣,我們在 handleActiveIdxChange
的方法內部也可以增加一個判斷,如果存在 props aciveIdx
則不更新 localActiveIdx
:
handleActiveIdxChange(idx) {
if (this.activeIdx === undefined) {
this.localActiveIdx = idx;
}
this.$emit("active-idx-change", idx);
}
在一些更復雜的元件中,可能會頻繁判斷是否為受控模式並做不同的處理,這時候通過 this.activeIdx
這樣的核心狀態 props 是否傳入來判斷是否為受控模式是一個不錯的實踐。
總結
最終我們為 active index 設計的完整 API 如下:
{
props: {
activeIdx: Number,
defaultActiveIdx: {
type: Number,
default: 0
}
},
data() {
return {
localActiveIdx: this.defaultActiveIdx
};
},
computed: {
_activeIdx() {
return this.activeIdx || this.localActiveIdx;
}
},
methods: {
handleActiveIdxChange(idx) {
if (this.activeIdx === undefined) {
this.localActiveIdx = idx;
}
this.$emit("active-idx-change", idx);
}
}
}
通過這種 API 設計方式,可以讓我們設計的基礎元件使用方式更一致,拓展性更強,不論是開發還是使用時思路也會更加簡潔清晰。