讓我看看有多少人不知道Vue3中也能實(shí)現(xiàn)高階組件HOC
前言
高階組件HOC在React社區(qū)是非常常見的概念,但是在Vue社區(qū)中卻是很少人使用。主要原因有兩個:1、Vue中一般都是使用SFC,實(shí)現(xiàn)HOC比較困難。2、HOC能夠?qū)崿F(xiàn)的東西,在Vue2時代mixins能夠?qū)崿F(xiàn),在Vue3時代Composition API能夠?qū)崿F(xiàn)。如果你不知道HOC,那么你平時絕對沒有場景需要他。但是如果你知道HOC,那么在一些特殊的場景使用他就可以很優(yōu)雅的解決一些問題。
什么是高階組件HOC
HOC使用場景就是加強(qiáng)原組件。
HOC實(shí)際就是一個函數(shù),這個函數(shù)接收的參數(shù)就是一個組件,并且返回一個組件,返回的就是加強(qiáng)后組件。如下圖:
圖片
在Composition API出現(xiàn)之前HOC還有一個常見的使用場景就是提取公共邏輯,但是有了Composition API后這種場景就無需使用HOC了。
高階組件HOC使用場景
很多同學(xué)覺得有了Composition API后,直接無腦使用他就完了,無需費(fèi)時費(fèi)力的去搞什么HOC。那如果是下面這個場景呢?
有一天產(chǎn)品找到你,說要給我們的系統(tǒng)增加會員功能,需要讓系統(tǒng)中的幾十個功能塊增加會員可見功能。如果不是會員這幾十個功能塊都顯示成引導(dǎo)用戶開通會員的UI,并且這些功能塊涉及到幾十個組件,分布在系統(tǒng)的各個頁面中。
如果不知道HOC的同學(xué)一般都會這樣做,將會員相關(guān)的功能抽取成一個名為useVip.ts的hooks。代碼如下:
export function useVip() {
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
return false;
}
return {
showVipContent: getShowVipContent(),
};
}
然后再去每個具體的業(yè)務(wù)模塊中去使用showVipContent變量判斷,v-if="showVipContent"顯示原模塊,v-else顯示引導(dǎo)開通會員UI。代碼如下:
<template>
<Block1
v-if="showVipContent"
:name="name1"
@changeName="(value) => (name1 = value)"
/>
<OpenVipTip v-else />
</template>
<script setup lang="ts">
import { ref } from "vue";
import Block1 from "./block1.vue";
import OpenVipTip from "./open-vip-tip.vue";
import { useVip } from "./useVip";
const { showVipContent } = useVip();
const name1 = ref("block1");
</script>
我們系統(tǒng)中有幾十個這樣的組件,那么我們就需要這樣去改幾十次。非常麻煩,如果有些模塊是其他同事寫的代碼還很容易改錯?。?!
而且現(xiàn)在流行搞SVIP,也就是光開通VIP還不夠,需要再開通一個SVIP。當(dāng)你后續(xù)接到SVIP需求時,你又需要去改這幾十個模塊。v-if="SVIP"顯示某些內(nèi)容,v-else-if="VIP"顯示提示開通SVIP,v-else顯示提示開通VIP。
上面的這一場景使用hooks去實(shí)現(xiàn),雖然能夠完成,但是因?yàn)槿肭至诉@幾十個模塊的業(yè)務(wù)邏輯。所以容易出錯,也改起來比較麻煩,代碼也不優(yōu)雅。
那么有沒有一種更好的解決方案,讓我們可以不入侵這幾十個模塊的業(yè)務(wù)邏輯的實(shí)現(xiàn)方式呢?
答案是:高階組件HOC。
HOC的一個用途就是對組件進(jìn)行增強(qiáng),并且不會入侵原有組件的業(yè)務(wù)邏輯,在這里就是使用HOC判斷會員相關(guān)的邏輯。如果是會員那么就渲染原本的模塊組件,否則就渲染引導(dǎo)開通VIP的UI
實(shí)現(xiàn)一個簡單的HOC
首先我們要明白Vue的組件經(jīng)過編譯后就是一個對象,對象中的props屬性對應(yīng)的就是我們寫的defineProps。對象中的setup方法,對應(yīng)的就是我們熟知的<script setup>語法糖。
比如我使用console.log(Block1)將上面的import Block1 from "./block1.vue";給打印出來,如下圖:
圖片
這個就是我們引入的Vue組件對象。
還有一個冷知識,大家可能不知道。如果在setup方法中返回一個函數(shù),那么在Vue內(nèi)部就會認(rèn)為這個函數(shù)就是實(shí)際的render函數(shù),并且在setup方法中我們天然的就可以訪問定義的變量。
利用這一點(diǎn)我們就可以在Vue3中實(shí)現(xiàn)一個簡單的高階組件HOC,代碼如下:
import { h } from"vue";
import OpenVipTip from"./open-vip-tip.vue";
exportdefaultfunction WithVip(BaseComponent: any) {
return {
setup() {
const showVipContent = getShowVipContent();
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
returntrue;
}
return() => {
return showVipContent ? h(BaseComponent) : h(OpenVipTip);
};
},
};
}
在上面的代碼中我們將會員相關(guān)的邏輯全部放在了WithVip函數(shù)中,這個函數(shù)接收一個參數(shù)BaseComponent,他是一個Vue組件對象。
在setup方法中我們return了一個箭頭函數(shù),他會被當(dāng)作render函數(shù)處理。
如果showVipContent為true,就表明當(dāng)前用戶開通了VIP,就使用h函數(shù)渲染傳入的組件。
否則就渲染OpenVipTip組件,他是引導(dǎo)用戶開通VIP的組件。
此時我們的父組件就應(yīng)該是下面這樣的:
<template>
<EnhancedBlock1 />
</template>
<script setup lang="ts">
import Block1 from "./block1.vue";
import WithVip from "./with-vip.tsx";
const EnhancedBlock1 = WithVip(Block1);
</script>
這個代碼相比前面的hooks的實(shí)現(xiàn)就簡單很多了,只需要使用高階組件WithVip對原來的Block1組件包一層,然后將原本使用Block1的地方改為使用EnhancedBlock1。對原本的代碼基本沒有入侵。
上面的例子只是一個簡單的demo,他是不滿足我們實(shí)際的業(yè)務(wù)場景。比如子組件有props、emit、插槽。還有我們在父組件中可能會直接調(diào)用子組件expose暴露的方法。
因?yàn)槲覀兪褂昧薍OC對原本的組件進(jìn)行了一層封裝,那么上面這些場景HOC都是不支持的,我們需要添加一些額外的代碼去支持。
高階組件HOC實(shí)現(xiàn)props和emit
在Vue中屬性分為兩種,一種是使用props和emit聲明接收的屬性。第二種是未聲明的屬性attrs,比如class、style、id等。
在setup函數(shù)中props是作為第一個參數(shù)返回,attrs是第二個參數(shù)中返回。
所以為了能夠支持props和emit,我們的高階組件WithVip將會變成下面這樣:
import { SetupContext, h } from"vue";
import OpenVipTip from"./open-vip-tip.vue";
exportdefaultfunction WithVip(BaseComponent: any) {
return {
props: BaseComponent.props, // 新增代碼
setup(props, { attrs, slots, expose }: SetupContext) { // 新增代碼
const showVipContent = getShowVipContent();
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
returntrue;
}
return() => {
return showVipContent
? h(BaseComponent, {
...props, // 新增代碼
...attrs, // 新增代碼
})
: h(OpenVipTip);
};
},
};
}
在setup方法中接收的第一個參數(shù)就是props,沒有在props中定義的屬性就會出現(xiàn)在attrs對象中。
所以我們調(diào)用h函數(shù)時分別將props和attrs透傳給子組件。
同時我們還需要一個地方去定義props,props的值就是直接讀取子組件對象中的BaseComponent.props。所以我們給高階組件聲明一個props屬性:props: BaseComponent.props,。
這樣props就會被透傳給子組件了。
看到這里有的小伙伴可能會問,那emit觸發(fā)事件沒有看見你處理呢?
答案是:我們無需去處理,因?yàn)楦附M件上面的@changeName="(value) => (name1 = value)"經(jīng)過編譯后就會變成屬性::notallow="(value) => (name1 = value)"。而這個屬性由于我們沒有在props中聲明,所以他會作為attrs直接透傳給子組件。
高階組件實(shí)現(xiàn)插槽
我們的正常子組件一般還有插槽,比如下面這樣:
<template>
<div class="divider">
<h1>{{ name }}</h1>
<button @click="handleClick">change name</button>
<slot />
這里是block1的一些業(yè)務(wù)代碼
<slot name="footer" />
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
changeName: [name: string];
}>();
const props = defineProps<{
name: string;
}>();
const handleClick = () => {
emit("changeName", `hello ${props.name}`);
};
defineExpose({
handleClick,
});
</script>
在上面的例子中,子組件有個默認(rèn)插槽和name為footer的插槽。此時我們來看看高階組件中如何處理插槽呢?
直接看代碼:
import { SetupContext, h } from"vue";
import OpenVipTip from"./open-vip-tip.vue";
exportdefaultfunction WithVip(BaseComponent: any) {
return {
props: BaseComponent.props,
setup(props, { attrs, slots, expose }: SetupContext) {
const showVipContent = getShowVipContent();
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
returntrue;
}
return() => {
return showVipContent
? h(
BaseComponent,
{
...props,
...attrs,
},
slots // 新增代碼
)
: h(OpenVipTip);
};
},
};
}
插槽的本質(zhì)就是一個對象里面擁有多個方法,這些方法的名稱就是每個具名插槽,每個方法的參數(shù)就是插槽傳遞的變量。這里我們只需要執(zhí)行h函數(shù)時將slots對象傳給h函數(shù),就能實(shí)現(xiàn)插槽的透傳(如果你看不懂這句話,那就等歐陽下篇插槽的文章寫好后再來看這段話你就懂了)。
我們在控制臺中來看看傳入的slots插槽對象,如下圖:
圖片
從上面可以看到插槽對象中有兩個方法,分別是default和footer,對應(yīng)的就是默認(rèn)插槽和footer插槽。
大家熟知h函數(shù)接收的第三個參數(shù)是children數(shù)組,也就是有哪些子元素。但是他其實(shí)還支持直接傳入slots對象,下面這個是他的一種定義:
export function h<P>(
type: Component<P>,
props?: (RawProps & P) | null,
children?: RawChildren | RawSlots,
): VNode
export type RawSlots = {
[name: string]: unknown
// ...省略
}
所以我們可以直接把slots對象直接丟給h函數(shù),就可以實(shí)現(xiàn)插槽的透傳。
父組件調(diào)用子組件的方法
有的場景中我們需要在父組件中直接調(diào)用子組件的方法,按照以前的場景,我們只需要在子組件中expose暴露出去方法,然后在父組件中使用ref訪問到子組件,這樣就可以調(diào)用了。
但是使用了HOC后,中間層多了一個高階組件,所以我們不能直接訪問到子組件expose的方法。
怎么做呢?答案很簡單,直接上代碼:
import { SetupContext, h, ref } from"vue";
import OpenVipTip from"./open-vip-tip.vue";
exportdefaultfunction WithVip(BaseComponent: any) {
return {
props: BaseComponent.props,
setup(props, { attrs, slots, expose }: SetupContext) {
const showVipContent = getShowVipContent();
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
returntrue;
}
// 新增代碼start
const innerRef = ref();
expose(
newProxy(
{},
{
get(_target, key) {
return innerRef.value?.[key];
},
has(_target, key) {
return innerRef.value?.[key];
},
}
)
);
// 新增代碼end
return() => {
return showVipContent
? h(
BaseComponent,
{
...props,
...attrs,
ref: innerRef, // 新增代碼
},
slots
)
: h(OpenVipTip);
};
},
};
}
在高階組件中使用ref訪問到子組件賦值給innerRef變量。然后expose一個Proxy的對象,在get攔截中讓其直接去執(zhí)行子組件中的對應(yīng)的方法。
比如在父組件中使用block1Ref.value.handleClick()去調(diào)用handleClick方法,由于使用了HOC,所以這里讀取的handleClick方法其實(shí)是讀取的是HOC中expose暴露的方法。所以就會走到Proxy的get攔截中,從而可以訪問到真正子組件中expose暴露的handleClick方法。
那么上面的Proxy為什么要使用has攔截呢?
答案是在Vue源碼中父組件在執(zhí)行子組件中暴露的方法之前會執(zhí)行這樣一個判斷:
if (key in target) {
return target[key];
}
很明顯我們這里的Proxy代理的原始對象里面什么都沒有,執(zhí)行key in target肯定就是false了。所以我們可以使用has去攔截key in target,意思是只要訪問的方法或者屬性是子組件中expose暴露的就返回true。
至此,我們已經(jīng)在HOC中覆蓋了Vue中的所有場景。但是有的同學(xué)覺得h函數(shù)寫著比較麻煩,不好維護(hù),我們還可以將上面的高階組件改為tsx的寫法,with-vip.tsx文件代碼如下:
import { SetupContext, ref } from"vue";
import OpenVipTip from"./open-vip-tip.vue";
exportdefaultfunction WithVip(BaseComponent: any) {
return {
props: BaseComponent.props,
setup(props, { attrs, slots, expose }: SetupContext) {
const showVipContent = getShowVipContent();
function getShowVipContent() {
// 一些業(yè)務(wù)邏輯判斷是否是VIP
returntrue;
}
const innerRef = ref();
expose(
newProxy(
{},
{
get(_target, key) {
return innerRef.value?.[key];
},
has(_target, key) {
return innerRef.value?.[key];
},
}
)
);
return() => {
return showVipContent ? (
<BaseComponent {...props} {...attrs} ref={innerRef}>
{slots}
</BaseComponent>
) : (
<OpenVipTip />
);
};
},
};
}
一般情況下h函數(shù)能夠?qū)崿F(xiàn)的,使用jsx或者tsx都能實(shí)現(xiàn)(除非你需要操作虛擬DOM)。
注意上面的代碼是使用ref={innerRef},而不是我們熟悉的ref="innerRef",這里很容易搞錯!!
compose函數(shù)
此時你可能有個新需求,需要給某些模塊顯示不同的折扣信息,這些模塊可能會和上一個會員需求的模塊有重疊。此時就涉及到多個高階組件之間的組合情況。
同樣我們使用HOC去實(shí)現(xiàn),新增一個WithDiscount高階組件,代碼如下:
import { SetupContext, onMounted, ref } from"vue";
exportdefaultfunction WithDiscount(BaseComponent: any, item: string) {
return {
props: BaseComponent.props,
setup(props, { attrs, slots, expose }: SetupContext) {
const discountInfo = ref("");
onMounted(async () => {
const res = await getDiscountInfo(item);
discountInfo.value = res;
});
function getDiscountInfo(item: any): Promise<string> {
// 根據(jù)傳入的item獲取折扣信息
returnnewPromise((resolve) => {
setTimeout(() => {
resolve("我是折扣信息1");
}, 1000);
});
}
const innerRef = ref();
expose(
newProxy(
{},
{
get(_target, key) {
return innerRef.value?.[key];
},
has(_target, key) {
return innerRef.value?.[key];
},
}
)
);
return() => {
return (
<div class="with-discount">
<BaseComponent {...props} {...attrs} ref={innerRef}>
{slots}
</BaseComponent>
{discountInfo.value ? (
<div class="discount-info">{discountInfo.value}</div>
) : null}
</div>
);
};
},
};
}
那么我們的父組件如果需要同時用VIP功能和折扣信息功能需要怎么辦呢?代碼如下:
const EnhancedBlock1 = WithVip(WithDiscount(Block1, "item1"));
如果不是VIP,那么這個模塊的折扣信息也不需要顯示了。
因?yàn)楦唠A組件接收一個組件,然后返回一個加強(qiáng)的組件。利用這個特性,我們可以使用上面的這種代碼將其組合起來。
但是上面這種寫法大家覺得是不是看著很難受,一層套一層。如果這里同時使用5個高階組件,這里就會套5層了,那這個代碼的維護(hù)難度就是地獄難度了。
所以這個時候就需要compose函數(shù)了,這個是React社區(qū)中常見的概念。它的核心思想是將多個函數(shù)從右到左依次組合起來執(zhí)行,前一個函數(shù)的輸出作為下一個函數(shù)的輸入。
我們這里有多個HOC(也就是有多個函數(shù)),我們期望執(zhí)行完第一個HOC得到一個加強(qiáng)的組件,然后以這個加強(qiáng)的組件為參數(shù)去執(zhí)行第二個HOC,最后得到由多個HOC加強(qiáng)的組件。
compose函數(shù)就剛好符合我們的需求,這個是使用compose函數(shù)后的代碼,如下:
const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);
這樣就舒服多了,所有的高階組件都放在第一個括弧里面,并且由右向左去依次執(zhí)行每個高階組件HOC。如果某個高階組件HOC需要除了組件之外的額外參數(shù),像WithDiscount這樣處理就可以了。
很明顯,我們的WithDiscount高階組件的代碼需要修改才能滿足compose函數(shù)的需求,這個是修改后的代碼:
import { SetupContext, onMounted, ref } from"vue";
exportdefaultfunction WithDiscount(item: string) {
return(BaseComponent: any) => {
return {
props: BaseComponent.props,
setup(props, { attrs, slots, expose }: SetupContext) {
const discountInfo = ref("");
onMounted(async () => {
const res = await getDiscountInfo(item);
discountInfo.value = res;
});
function getDiscountInfo(item: any): Promise<string> {
// 根據(jù)傳入的item獲取折扣信息
returnnewPromise((resolve) => {
setTimeout(() => {
resolve("我是折扣信息1");
}, 1000);
});
}
const innerRef = ref();
expose(
newProxy(
{},
{
get(_target, key) {
return innerRef.value?.[key];
},
has(_target, key) {
return innerRef.value?.[key];
},
}
)
);
return() => {
return (
<div class="with-discount">
<BaseComponent {...props} {...attrs} ref={innerRef}>
{slots}
</BaseComponent>
{discountInfo.value ? (
<div class="discount-info">{discountInfo.value}</div>
) : null}
</div>
);
};
},
};
};
}
注意看,WithDiscount此時只接收一個參數(shù)item,不再接收BaseComponent組件對象了,然后直接return出去一個回調(diào)函數(shù)。
準(zhǔn)確的來說此時的WithDiscount函數(shù)已經(jīng)不是高階組件HOC了,他return出去的回調(diào)函數(shù)才是真正的高階組件HOC。在回調(diào)函數(shù)中去接收BaseComponent組件對象,然后返回一個增強(qiáng)后的Vue組件對象。
至于參數(shù)item,因?yàn)殚]包所以在里層的回調(diào)函數(shù)中還是能夠訪問的。這里比較繞,可能需要多理解一下。
前面的理解完了后,我們可以再上一點(diǎn)強(qiáng)度了。來看看compose函數(shù)是如何實(shí)現(xiàn)的,代碼如下:
function compose(...funcs) {
return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));
}
這個函數(shù)雖然只有一行代碼,但是乍一看,怎么看怎么懵逼,歐陽也是?。∥覀冞€是結(jié)合demo來看:
const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);
假如我們這里有WithA、WithB、 WithC、 WithD四個高階組件,都是用于增強(qiáng)組件View。
compose中使用的是...funcs將調(diào)用compose函數(shù)接收到的四個高階組件都存到了funcs數(shù)組中。
然后使用reduce去遍歷這些高階組件,注意看執(zhí)行reduce時沒有傳入第二個參數(shù)。
所以第一次執(zhí)行reduce時,acc的值為WithA,cur的值為WithB。返回結(jié)果也是一個回調(diào)函數(shù),將這兩個值填充進(jìn)去就是(...args) => WithA(WithB(...args)),我們將第一次的執(zhí)行結(jié)果命名為r1。
我們知道reduce會將上一次的執(zhí)行結(jié)果賦值為acc,所以第二次執(zhí)行reduce時,acc的值為r1,cur的值為WithC。返回結(jié)果也是一個回調(diào)函數(shù),同樣將這兩個值填充進(jìn)行就是(...args) => r1(WithC(...args))。同樣我們將第二次的執(zhí)行結(jié)果命名為r2。
第三次執(zhí)行reduce時,此時的acc的值為r2,cur的值為WithD。返回結(jié)果也是一個回調(diào)函數(shù),同樣將這兩個值填充進(jìn)行就是(...args) => r2(WithD(...args))。同樣我們將第三次的執(zhí)行結(jié)果命名為r3,由于已經(jīng)將數(shù)組遍歷完了,最終reduce的返回值就是r3,他是一個回調(diào)函數(shù)。
由于compose(WithA, WithB, WithC, WithD)的執(zhí)行結(jié)果為r3,那么compose(WithA, WithB, WithC, WithD)(View)就等價于r3(View)。
前面我們知道r3是一個回調(diào)函數(shù):(...args) => r2(WithD(...args)),這個回調(diào)函數(shù)接收的參數(shù)args,就是需要增強(qiáng)的基礎(chǔ)組件View。所以執(zhí)行這個回調(diào)函數(shù)就是先執(zhí)行WithD對組件進(jìn)行增強(qiáng),然后將增強(qiáng)后的組件作為參數(shù)去執(zhí)行r2。
同樣r2也是一個回調(diào)函數(shù):(...args) => r1(WithC(...args)),接收上一次WithD增強(qiáng)后的組件為參數(shù)執(zhí)行WithC對組件再次進(jìn)行增強(qiáng),然后將增強(qiáng)后的組件作為參數(shù)去執(zhí)行r1。
同樣r1也是一個回調(diào)函數(shù):(...args) => WithA(WithB(...args)),將WithC增強(qiáng)后的組件丟給WithB去執(zhí)行,得到增強(qiáng)的組件再丟給WithA去執(zhí)行,最終就拿到了最后增強(qiáng)的組件。
執(zhí)行順序就是從右向左去依次執(zhí)行高階組件對基礎(chǔ)組件進(jìn)行增強(qiáng)。
至此,關(guān)于compose函數(shù)已經(jīng)講完了,這里對于Vue的同學(xué)可能比較難理解,建議多看兩遍。
總結(jié)
這篇文章我們講了在Vue3中如何實(shí)現(xiàn)一個高階組件HOC,但是里面涉及到了很多源碼知識,所以這是一篇運(yùn)用源碼的實(shí)戰(zhàn)文章。如果你理解了文章中涉及到的知識,那么就會覺得Vue中實(shí)現(xiàn)HOC還是很簡單的,反之就像是在看天書。
還有最重要的一點(diǎn)就是Composition API已經(jīng)能夠解決絕大部分的問題,只有少部分的場景才需要使用高階組件HOC,切勿強(qiáng)行使用HOC,那樣可能會有炫技的嫌疑。如果是防御性編程,那么就當(dāng)我沒說。
最后就是我們實(shí)現(xiàn)的每個高階組件HOC都有很多重復(fù)的代碼,而且實(shí)現(xiàn)起來很麻煩,心智負(fù)擔(dān)也很高。那么我們是不是可以抽取一個createHOC函數(shù)去批量生成高階組件呢?這個就留給各位自己去思考了。
還有一個問題,我們這種實(shí)現(xiàn)的高階組件叫做正向?qū)傩源?,弊端是每代理一層就會增加一層組件的嵌套。那么有沒有方法可以解決嵌套的問題呢?
答案是反向繼承,但是這種也有弊端如果業(yè)務(wù)是setup中返回的render函數(shù),那么就沒法重寫了render函數(shù)了。