軟件編程有一個重要的原則是 D.R.Y(Don't Repeat Yourself),講的是盡量復用代碼和邏輯,減少重復。組件擴展可以避免重復代碼,更易於快速開發和維護。那么,擴展 Vue 組件的最佳方法是什么?
Vue 提供了不少 API 和模式來支持組件復用和擴展,你可以根據自己的目的和偏好來選擇。
本文介紹幾種比較常見的方法和模式,希望對你有所幫助。
擴展組件是否必要
要知道,所有的組件擴展方法都會增加復雜性和額外代碼,有時候還會增加性能消耗。
因此,在決定擴展組件之前,最好先看看有沒有其他更簡單的設計模式能完成目標。
下面幾種組件設計模式通常足夠替代擴展組件了:
props
配合模板邏輯- slot 插槽
- JavaScript 工具函數
props
配合模板邏輯
最簡單的方法是通過props
結合模板條件渲染,來實現組件的多功能。
比如通過 type
屬性:
MyVersatileComponent.vue
<template>
<div class="wrapper">
<div v-if="type === 'a'">...</div>
<div v-else-if="type === 'b'">...</div>
<!--etc etc-->
</div>
</template>
<script>
export default {
props: { type: String },
...
}
</script>
使用組件的時候傳不同的type
值就能實現不同的結果。
// *ParentComponent.vue*
<template>
<MyVersatileComponent type="a" />
<MyVersatileComponent type="b" />
</template>
如果出現下面兩種情況,就說明這種模式不適用了,或者用法不對:
- 組件組合模式把狀態和邏輯分解成原子部分,從而讓應用具備可擴展性。如果組件內存在大量條件判斷,可讀性和可維護性就會變差。
- props 和模板邏輯的本意是讓組件動態化,但是也存在運行時資源消耗。如果你利用這種機制在運行時解決代碼組合問題,那是一種反模式。
slot(插槽)
另一種可避免組件擴展的方式是利用 slots(插槽),就是讓父組件在子組件內設置自定義內容。
// *MyVersatileComponent.vue*
<template>
<div class="wrapper">
<h3>Common markup</div>
<slot />
</div>
</template>
// *ParentComponent.vue*
<template>
<MyVersatileComponent>
<h4>Inserting into the slot</h4>
</MyVersatileComponent>
</template>
渲染結果:
<div class="wrapper">
<h3>Common markup</div>
<h4>Inserting into the slot</h4>
</div>
這種模式有一個潛在約束, slot 內的元素從屬於父組件的上下文,在拆分邏輯和狀態時可能不太自然。scoped slot
會更靈活,后面會在無渲染組件一節里提到。
JavaScript 工具函數
如果只需要在各組件之間復用獨立的函數,那么只需要抽取這些 JavaScript 模塊就行了,根本不需要用到組件擴展模式。
JavaScript 的模塊系統是一種非常靈活和健壯的代碼共享方式,所以你應該盡可能地依靠它。
MyUtilityFunction.js
export default function () {
...
}
MyComponent.vue
import MyUtilityFunction from "./MyUtilityFunction";
export default {
methods: {
MyUtilityFunction
}
}
擴展組件的幾種模式
如果你已經考慮過以上幾種簡單的模式,但這些模式還不夠靈活,無法滿足需求。那么就可以考慮擴展組件了。
擴展 Vue 組件最流行的方法有以下四種:
每一種方法都有其優缺點,根據使用場景,或多或少都有適用的部分。
Composition API
組件之間共享狀態和邏輯的最新方案是 Composition API。這是 Vue 3 推出的 API,也可以在 Vue 2 里當插件使用。
跟之前在組件定義配置對象里聲明data
,computed
,methods
等屬性的方式不同,Composition API 通過一個 setup
函數聲明和返回這些配置。
比如,用 Vue 2 配置屬性的方式聲明 Counter 組件是這樣的:
Counter.vue
<template>
<button @click="increment">
Count is: {{ count }}, double is: {{ double }}
</button>
<template>
<script>
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
}
}
</script>
用 Composition API 重構這個組件,功能完全一樣:
Counter.vue
<template><!--as above--><template>
<script>
import { reactive, computed } from "vue";
export default {
setup() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
});
function increment() {
state.count++
}
return {
count,
double,
increment
}
}
}
</script>
用 Composition API 聲明組件的主要好處之一是,邏輯復用和抽取變得非常輕松。
進一步重構,把計數器的功能移到 JavaScript 模塊 useCounter.js
中:
useCounter.js
import { reactive, computed } from "vue";
export default function {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
});
function increment() {
state.count++
}
return {
count,
double,
increment
}
}
現在,計數器功能可以通過setup
函數無縫引入到任意 Vue 組件中:
MyComponent.vue
<template><!--as above--></template>
<script>
import useCounter from "./useCounter";
export default {
setup() {
const { count, double, increment } = useCounter();
return {
count,
double,
increment
}
}
}
</script>
Composition 函數讓功能模塊化、可重用,是擴展組件最直接和低成本的方式。
Composition API 的缺點
Composition API 的缺點其實不算什么——可能就是看起來有點啰嗦,並且新的用法對一些 Vue 開發者來說有點陌生。
關於 Composition API 優缺點的討論,推薦閱讀:When To Use The New Vue Composition API (And When Not To)
mixin
如果你還在用 Vue 2,或者只是喜歡用配置對象的方式定義組件功能,可以用 mixin 模式。mixin 把公共邏輯和狀態抽取到單獨的對象,跟使用 mixin 的組件內部定義對象合並。
我們繼續用之前的Counter組件例子,把公共邏輯和狀態放到CounterMixin.js模塊中。
CounterMixin.js
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
}
}
使用 mixin 也很簡單,只要導入對應模塊並在mixins
數組里加上變量就行。組件初始化時會把 mixin 對象與組件內部定義對象合並。
MyComponent.vue
import CounterMixin from "./CounterMixin";
export default {
mixins: [CounterMixin],
methods: {
decrement() {
this.count--;
}
}
}
選項合並
如果組件內的選項跟 mixin 沖突怎么辦?
比如,給組件定義一個自帶的increment
方法,哪個優先級更高呢?
MyComponent.vue
import CounterMixin from "./CounterMixin";
export default {
mixins: [CounterMixin],
methods: {
// 自帶的 `increment`` 方法會覆蓋 mixin 的`increment` 嗎?
increment() { ... }
}
}
這個時候就要說到 Vue 的合並策略了。Vue 有一系列的規則,決定了如何處理同名選項。
通常,組件自帶的選項會覆蓋來自 mixin 的選項。但也有例外,比如同類型的生命周期鈎子,不是直接覆蓋,而是都放進數組,按順序執行。
你也可以通過 自定義合並策略 改變默認行為。
mixin 的缺點
作為擴展組件的一種模式,mixin 對於簡單的場景還算好用,一旦規模擴大,問題就來了。不僅需要注意命名沖突問題(尤其是第三方 mixin),使用了多個 mixin 的組件,很難搞清楚某個功能到底來自於哪里,定位問題也比較困難。
高階組件
高階組件(HOC)是從 React 借用的概念,Vue 也能使用。
為了理解這個概念,我們先拋開組件,看看兩個簡單的 JavaScript 函數,increment
和 double
。
function increment(x) {
return x++;
}
function double(x) {
return x * 2;
}
假設我們想給這兩個函數都加一個功能:在控制台輸出結果。
為此,我們可以用高階函數模式,新建一個 addLogging
函數,接受函數作為參數,並返回一個帶有新增功能的函數。
function addLogging(fn) {
return function(x) {
const result = fn(x);
console.log("The result is: ", result);
return result;
};
}
const incrementWithLogging = addLogging(increment);
const doubleWithLogging = addLogging(double);
組件如何利用這種模式呢?類似地,我們創建一個高階組件來渲染Counter組件,同時添加一個decrement
方法作為實例屬性。
實際代碼比較復雜,這里只給出偽代碼作為示意:
import Counter from "./Counter";
// 偽代碼
const CounterWithDecrement => ({
render(createElement) {
const options = {
decrement() {
this.count--;
}
}
return createElement(Counter, options);
}
});
HOC 模式比 mixin 更簡潔,擴展性更好,但是代價是增加了一個包裹組件,實現起來也需要技巧。
無渲染組件
如果需要在多個組件上使用相同的邏輯和狀態,只是展示方式不同,那么就可以考慮無渲染組件模式。
該模式需要用到兩類組件:邏輯組件用於聲明邏輯和狀態,展示組件用於展示數據。
邏輯組件
還是回到Counter的例子,假設我們需要在多個地方重用這個組件,但是展示方式不同。
創建一個CounterRenderless.js 用於定義邏輯組件,包含邏輯和狀態,但是不包含模板,而是通過 render
函數聲明 scoped slot
。
scoped slot
暴露三個屬性給父組件使用:狀態count
,方法increment
和計算屬性 double
。
CounterRenderless.js
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
},
render() {
return this.$scopedSlots.default({
count: this.count,
double: this.double,
increment: this.toggleState,
})
}
}
這里的scoped slot
是這種模式里邏輯組件的關鍵所在。
展示組件
接下來是展示組件,作為無渲染組件的使用方,提供具體的展示方式。
所有的元素標簽都包含在scoped slot
里。可以看到,這些屬性在使用上跟模板直接放在邏輯組件里沒什么兩樣。
CounterWithButton.vue
<template>
<counter-renderless slot-scope="{ count, double, increment }">
<div>Count is: {{ count }}</div>
<div>Double is: {{ double }}</div>
<button @click="increment">Increment</button>
</counter-renderless>
</template>
<script>
import CounterRenderless from "./CountRenderless";
export default {
components: {
CounterRenderless
}
}
</script>
無渲染組件模式非常靈活,也容易理解。但是,它沒有前面那幾種方法那么通用,可能只有一種應用場景,那就是用於開發組件庫。
模板擴展
上面的 API 也好,設計模式也罷,都有一種局限性,就是無法擴展組件的模板。Vue 在邏輯和狀態方面有辦法重用,但是對於模板標簽就無能為力了。
有一種比較 hack 的方式,就是利用 HTML 預處理器,比如 Pug,來處理模板擴展。
第一步是創建一個基礎模板.pug文件,包含公共的頁面元素。還要包含一個 block input
,作為模板擴展的占位符。
BaseTemplate.pug
div.wrapper
h3 {{ myCommonProp }} <!--common markup-->
block input <!--extended markup outlet -->
為了能擴展這個模板,需要安裝 Vue Loader 的 Pug 插件。然后就可以引入基礎模板並利用block input
語法替換占位部分了:
MyComponent.vue
<template lang="pug">
extends BaseTemplate.pug
block input
h4 {{ myLocalProp }} <!--gets included in the base template-->
</template>
一開始你可能會認為它跟 slot 的概念是一樣的,但是有個區別,這里的基礎模板不屬於任何單獨的組件。它在編譯時跟當前組件合並,而不是像 slot 那樣是在運行時替換。
參考資料:
看到這個頗有氣質的 logo,不來關注下嗎?