Vue3實現(xiàn)高階組件HOC的示例詳解
前言
高階組件HOC
在React社區(qū)是非常常見的概念,但是在Vue社區(qū)中卻是很少人使用。主要原因有兩個:1、Vue中一般都是使用SFC,實現(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使用場景就是加強原組件
。
HOC實際就是一個函數(shù),這個函數(shù)接收的參數(shù)就是一個組件,并且返回一個組件,返回的就是加強后組件。如下圖:
在Composition API
出現(xiàn)之前HOC還有一個常見的使用場景就是提取公共邏輯,但是有了Composition API
后這種場景就無需使用HOC了。
高階組件HOC使用場景
很多同學覺得有了Composition API
后,直接無腦使用他就完了,無需費時費力的去搞什么HOC。那如果是下面這個場景呢?
有一天產(chǎn)品找到你,說要給我們的系統(tǒng)增加會員功能,需要讓系統(tǒng)中的幾十個功能塊增加會員可見功能。如果不是會員這幾十個功能塊都顯示成引導用戶開通會員的UI,并且這些功能塊涉及到幾十個組件,分布在系統(tǒng)的各個頁面中。
如果不知道HOC的同學一般都會這樣做,將會員相關(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
顯示引導開通會員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。當你后續(xù)接到SVIP需求時,你又需要去改這幾十個模塊。v-if="SVIP"
顯示某些內(nèi)容,v-else-if="VIP"
顯示提示開通SVIP,v-else
顯示提示開通VIP。
上面的這一場景使用hooks去實現(xiàn),雖然能夠完成,但是因為入侵了這幾十個模塊的業(yè)務(wù)邏輯。所以容易出錯,也改起來比較麻煩,代碼也不優(yōu)雅。
那么有沒有一種更好的解決方案,讓我們可以不入侵這幾十個模塊的業(yè)務(wù)邏輯的實現(xiàn)方式呢?
答案是:高階組件HOC
。
HOC的一個用途就是對組件進行增強,并且不會入侵原有組件的業(yè)務(wù)邏輯,在這里就是使用HOC判斷會員相關(guān)的邏輯。如果是會員那么就渲染原本的模塊組件,否則就渲染引導開通VIP的UI
實現(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)部就會認為這個函數(shù)就是實際的render函數(shù),并且在setup方法中我們天然的就可以訪問定義的變量。
利用這一點我們就可以在Vue3中實現(xiàn)一個簡單的高階組件HOC,代碼如下:
import { h } from "vue"; import OpenVipTip from "./open-vip-tip.vue"; export default function WithVip(BaseComponent: any) { return { setup() { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些業(yè)務(wù)邏輯判斷是否是VIP return true; } return () => { return showVipContent ? h(BaseComponent) : h(OpenVipTip); }; }, }; }
在上面的代碼中我們將會員相關(guān)的邏輯全部放在了WithVip
函數(shù)中,這個函數(shù)接收一個參數(shù)BaseComponent
,他是一個Vue組件對象。
在setup
方法中我們return了一個箭頭函數(shù),他會被當作render函數(shù)處理。
如果showVipContent
為true,就表明當前用戶開通了VIP,就使用h
函數(shù)渲染傳入的組件。
否則就渲染OpenVipTip
組件,他是引導用戶開通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的實現(xiàn)就簡單很多了,只需要使用高階組件WithVip
對原來的Block1
組件包一層,然后將原本使用Block1
的地方改為使用EnhancedBlock1
。對原本的代碼基本沒有入侵。
上面的例子只是一個簡單的demo,他是不滿足我們實際的業(yè)務(wù)場景。比如子組件有props
、emit
、插槽
。還有我們在父組件中可能會直接調(diào)用子組件expose暴露的方法。
因為我們使用了HOC對原本的組件進行了一層封裝,那么上面這些場景HOC都是不支持的,我們需要添加一些額外的代碼去支持。
高階組件HOC實現(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"; export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, // 新增代碼 setup(props, { attrs, slots, expose }: SetupContext) { // 新增代碼 const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些業(yè)務(wù)邏輯判斷是否是VIP return true; } 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ā)事件沒有看見你處理呢?
答案是:我們無需去處理,因為父組件上面的@changeName="(value) => (name1 = value)"
經(jīng)過編譯后就會變成屬性::onChangeName="(value) => (name1 = value)"
。而這個屬性由于我們沒有在props中聲明,所以他會作為attrs
直接透傳給子組件。
高階組件實現(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>
在上面的例子中,子組件有個默認插槽和name為footer
的插槽。此時我們來看看高階組件中如何處理插槽呢?
直接看代碼:
import { SetupContext, h } from "vue"; import OpenVipTip from "./open-vip-tip.vue"; export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些業(yè)務(wù)邏輯判斷是否是VIP return true; } return () => { return showVipContent ? h( BaseComponent, { ...props, ...attrs, }, slots // 新增代碼 ) : h(OpenVipTip); }; }, }; }
插槽的本質(zhì)就是一個對象里面擁有多個方法,這些方法的名稱就是每個具名插槽,每個方法的參數(shù)就是插槽傳遞的變量。這里我們只需要執(zhí)行h
函數(shù)時將slots
對象傳給h函數(shù),就能實現(xiàn)插槽的透傳(如果你看不懂這句話,那就等歐陽下篇插槽的文章寫好后再來看這段話你就懂了)。
我們在控制臺中來看看傳入的slots
插槽對象,如下圖:
從上面可以看到插槽對象中有兩個方法,分別是default
和footer
,對應(yīng)的就是默認插槽和footer插槽。
大家熟知h函數(shù)接收的第三個參數(shù)是children數(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ù),就可以實現(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"; export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些業(yè)務(wù)邏輯判斷是否是VIP return true; } // 新增代碼start const innerRef = ref(); expose( new Proxy( {}, { 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
方法其實是讀取的是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中的所有場景。但是有的同學覺得h
函數(shù)寫著比較麻煩,不好維護,我們還可以將上面的高階組件改為tsx的寫法,with-vip.tsx
文件代碼如下:
import { SetupContext, ref } from "vue"; import OpenVipTip from "./open-vip-tip.vue"; export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些業(yè)務(wù)邏輯判斷是否是VIP return true; } const innerRef = ref(); expose( new Proxy( {}, { 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
都能實現(xiàn)(除非你需要操作虛擬DOM)。
注意上面的代碼是使用ref={innerRef}
,而不是我們熟悉的ref="innerRef"
,這里很容易搞錯??!
compose函數(shù)
此時你可能有個新需求,需要給某些模塊顯示不同的折扣信息,這些模塊可能會和上一個會員需求的模塊有重疊。此時就涉及到多個高階組件之間的組合情況。
同樣我們使用HOC去實現(xiàn),新增一個WithDiscount
高階組件,代碼如下:
import { SetupContext, onMounted, ref } from "vue"; export default function 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獲取折扣信息 return new Promise((resolve) => { setTimeout(() => { resolve("我是折扣信息1"); }, 1000); }); } const innerRef = ref(); expose( new Proxy( {}, { 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,那么這個模塊的折扣信息也不需要顯示了。
因為高階組件接收一個組件,然后返回一個加強的組件。利用這個特性,我們可以使用上面的這種代碼將其組合起來。
但是上面這種寫法大家覺得是不是看著很難受,一層套一層。如果這里同時使用5個高階組件,這里就會套5層了,那這個代碼的維護難度就是地獄難度了。
所以這個時候就需要compose
函數(shù)了,這個是React社區(qū)中常見的概念。它的核心思想是將多個函數(shù)從右到左依次組合起來執(zhí)行,前一個函數(shù)的輸出作為下一個函數(shù)的輸入。
我們這里有多個HOC(也就是有多個函數(shù)),我們期望執(zhí)行完第一個HOC得到一個加強的組件,然后以這個加強的組件為參數(shù)去執(zhí)行第二個HOC,最后得到由多個HOC加強的組件。
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"; export default function 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獲取折扣信息 return new Promise((resolve) => { setTimeout(() => { resolve("我是折扣信息1"); }, 1000); }); } const innerRef = ref(); expose( new Proxy( {}, { 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ù)。
準確的來說此時的WithDiscount
函數(shù)已經(jīng)不是高階組件HOC了,他return出去的回調(diào)函數(shù)才是真正的高階組件HOC
。在回調(diào)函數(shù)中去接收BaseComponent
組件對象,然后返回一個增強后的Vue組件對象。
至于參數(shù)item
,因為閉包所以在里層的回調(diào)函數(shù)中還是能夠訪問的。這里比較繞,可能需要多理解一下。
前面的理解完了后,我們可以再上一點強度了。來看看compose
函數(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
四個高階組件,都是用于增強組件View
。
compose中使用的是...funcs
將調(diào)用compose
函數(shù)接收到的四個高階組件都存到了funcs
數(shù)組中。
然后使用reduce去遍歷這些高階組件,注意看執(zhí)行reduce
時沒有傳入第二個參數(shù)。
所以第一次執(zhí)行reduce時,acc
的值為WithA
,cur
的值為WithB
。返回結(jié)果也是一個回調(diào)函數(shù),將這兩個值填充進去就是(...args) => WithA(WithB(...args))
,我們將第一次的執(zhí)行結(jié)果命名為r1
。
我們知道reduce會將上一次的執(zhí)行結(jié)果賦值為acc,所以第二次執(zhí)行reduce時,acc
的值為r1
,cur
的值為WithC
。返回結(jié)果也是一個回調(diào)函數(shù),同樣將這兩個值填充進行就是(...args) => r1(WithC(...args))
。同樣我們將第二次的執(zhí)行結(jié)果命名為r2
。
第三次執(zhí)行reduce時,此時的acc
的值為r2
,cur
的值為WithD
。返回結(jié)果也是一個回調(diào)函數(shù),同樣將這兩個值填充進行就是(...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
,就是需要增強的基礎(chǔ)組件View
。所以執(zhí)行這個回調(diào)函數(shù)就是先執(zhí)行WithD
對組件進行增強,然后將增強后的組件作為參數(shù)去執(zhí)行r2
。
同樣r2
也是一個回調(diào)函數(shù):(...args) => r1(WithC(...args))
,接收上一次WithD
增強后的組件為參數(shù)執(zhí)行WithC
對組件再次進行增強,然后將增強后的組件作為參數(shù)去執(zhí)行r1
。
同樣r1
也是一個回調(diào)函數(shù):(...args) => WithA(WithB(...args))
,將WithC
增強后的組件丟給WithB
去執(zhí)行,得到增強的組件再丟給WithA
去執(zhí)行,最終就拿到了最后增強的組件。
執(zhí)行順序就是從右向左
去依次執(zhí)行高階組件對基礎(chǔ)組件進行增強。
至此,關(guān)于compose
函數(shù)已經(jīng)講完了,這里對于Vue的同學可能比較難理解,建議多看兩遍。
總結(jié)
這篇文章我們講了在Vue3中如何實現(xiàn)一個高階組件HOC,但是里面涉及到了很多源碼知識,所以這是一篇運用源碼的實戰(zhàn)文章。如果你理解了文章中涉及到的知識,那么就會覺得Vue中實現(xiàn)HOC還是很簡單的,反之就像是在看天書。
還有最重要的一點就是Composition API
已經(jīng)能夠解決絕大部分的問題,只有少部分的場景才需要使用高階組件HOC,切勿強行使用HOC
,那樣可能會有炫技的嫌疑。如果是防御性編程,那么就當我沒說。
最后就是我們實現(xiàn)的每個高階組件HOC都有很多重復的代碼,而且實現(xiàn)起來很麻煩,心智負擔也很高。那么我們是不是可以抽取一個createHOC
函數(shù)去批量生成高階組件呢?這個就留給各位自己去思考了。
還有一個問題,我們這種實現(xiàn)的高階組件叫做正向?qū)傩源?/code>,弊端是每代理一層就會增加一層組件的嵌套。那么有沒有方法可以解決嵌套的問題呢?
答案是反向繼承
,但是這種也有弊端如果業(yè)務(wù)是setup中返回的render函數(shù),那么就沒法重寫了render函數(shù)了。
到此這篇關(guān)于Vue3實現(xiàn)高階組件HOC的示例詳解的文章就介紹到這了,更多相關(guān)Vue3高階組件HOC內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一文了解Vue 3 的 generate 是這樣生成 render&n
本文介紹generate階段是如何根據(jù)javascript AST抽象語法樹生成render函數(shù)字符串的,本文中使用的vue版本為3.4.19,感興趣的朋友跟隨小編一起看看吧2024-06-06Vue3組件中g(shù)etCurrentInstance()獲取App實例,但是返回null的解決方案
這篇文章主要介紹了Vue3組件中g(shù)etCurrentInstance()獲取App實例,但是返回null的解決方案,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2025-04-04