高阶组件HOC在React社区是非常常见的概念,但是在Vue社区中却是很少人使用。主要原因有两个:
1、Vue中一般都是使用SFC,实现HOC比较困难。
2、HOC能够实现的东西,在Vue2时代mixins能够实现,在Vue3时代Composition API能够实现。如果你不知道HOC,那么你平时绝对没有场景需要他。但是如果你知道HOC,那么在一些特殊的场景使用他就可以很优雅的解决一些问题。
HOC使用场景就是加强原组件。HOC实际就是一个函数,这个函数接收的参数就是一个组件,并且返回一个组件,返回的就是加强后组件。如下图:
export function useVip() { function getShowVipContent() { // 一些业务逻辑判断是否是VIP return false; } return { showVipContent: getShowVipContent(), }; }然后再去每个具体的业务模块中去使用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>我们系统中有几十个这样的组件,那么我们就需要这样去改几十次。非常麻烦,如果有些模块是其他同事写的代码还很容易改错!!!而且现在流行搞SVIP,也就是光开通VIP还不够,需要再开通一个SVIP。当你后续接到SVIP需求时,你又需要去改这几十个模块。v-if="SVIP"显示某些内容,v-else-if="VIP"显示提示开通SVIP,v-else显示提示开通VIP。
首先我们要明白Vue的组件经过编译后就是一个对象,对象中的props属性对应的就是我们写的defineProps。对象中的setup方法,对应的就是我们熟知的<script setup>语法糖。比如我使用console.log(Block1)将上面的import Block1 from "./block1.vue";给打印出来,如下图:
import { h } from"vue"; import OpenVipTip from"./open-vip-tip.vue"; exportdefaultfunction WithVip(BaseComponent: any) { return { setup() { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些业务逻辑判断是否是VIP returntrue; } return() => { return showVipContent ? h(BaseComponent) : h(OpenVipTip); }; }, }; }在上面的代码中我们将会员相关的逻辑全部放在了WithVip函数中,这个函数接收一个参数BaseComponent,他是一个Vue组件对象。在setup方法中我们return了一个箭头函数,他会被当作render函数处理。如果showVipContent为true,就表明当前用户开通了VIP,就使用h函数渲染传入的组件。否则就渲染OpenVipTip组件,他是引导用户开通VIP的组件。
<template> <EnhancedBlock1 /> </template> <script setup lang="ts"> import Block1 from "./block1.vue"; import WithVip from "./with-vip.tsx"; const EnhancedBlock1 = WithVip(Block1); </script>这个代码相比前面的hooks的实现就简单很多了,只需要使用高阶组件WithVip对原来的Block1组件包一层,然后将原本使用Block1的地方改为使用EnhancedBlock1。对原本的代码基本没有入侵。上面的例子只是一个简单的demo,他是不满足我们实际的业务场景。比如子组件有props、emit、插槽。还有我们在父组件中可能会直接调用子组件expose暴露的方法。
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() { // 堆代码 duidaima.com // 一些业务逻辑判断是否是VIP returntrue; } return() => { return showVipContent ? h(BaseComponent, { ...props, // 新增代码 ...attrs, // 新增代码 }) : h(OpenVipTip); }; }, }; }在setup方法中接收的第一个参数就是props,没有在props中定义的属性就会出现在attrs对象中。所以我们调用h函数时分别将props和attrs透传给子组件。同时我们还需要一个地方去定义props,props的值就是直接读取子组件对象中的BaseComponent.props。所以我们给高阶组件声明一个props属性:props: BaseComponent.props,。
<template> <div class="divider"> <h1>{{ name }}</h1> <button @click="handleClick">change name</button> <slot /> 这里是block1的一些业务代码 <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"; exportdefaultfunction WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些业务逻辑判断是否是VIP returntrue; } return() => { return showVipContent ? h( BaseComponent, { ...props, ...attrs, }, slots // 新增代码 ) : h(OpenVipTip); }; }, }; }插槽的本质就是一个对象里面拥有多个方法,这些方法的名称就是每个具名插槽,每个方法的参数就是插槽传递的变量。这里我们只需要执行h函数时将slots对象传给h函数,就能实现插槽的透传(如果你看不懂这句话,那就等欧阳下篇插槽的文章写好后再来看这段话你就懂了)。我们在控制台中来看看传入的slots插槽对象,如下图:
export function h<P>( type: Component<P>, props?: (RawProps & P) | null, children?: RawChildren | RawSlots, ): VNode export type RawSlots = { [name: string]: unknown // ...省略 }所以我们可以直接把slots对象直接丢给h函数,就可以实现插槽的透传。
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() { // 一些业务逻辑判断是否是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拦截中让其直接去执行子组件中的对应的方法。比如在父组件中使用block1Ref.value.handleClick()去调用handleClick方法,由于使用了HOC,所以这里读取的handleClick方法其实是读取的是HOC中expose暴露的方法。所以就会走到Proxy的get拦截中,从而可以访问到真正子组件中expose暴露的handleClick方法。
if (key in target) { return target[key]; }很明显我们这里的Proxy代理的原始对象里面什么都没有,执行key in target肯定就是false了。所以我们可以使用has去拦截key in target,意思是只要访问的方法或者属性是子组件中expose暴露的就返回true。至此,我们已经在HOC中覆盖了Vue中的所有场景。但是有的同学觉得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() { // 一些业务逻辑判断是否是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函数能够实现的,使用jsx或者tsx都能实现(除非你需要操作虚拟DOM)。
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> { // 根据传入的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,那么这个模块的折扣信息也不需要显示了。因为高阶组件接收一个组件,然后返回一个加强的组件。利用这个特性,我们可以使用上面的这种代码将其组合起来。但是上面这种写法大家觉得是不是看着很难受,一层套一层。如果这里同时使用5个高阶组件,这里就会套5层了,那这个代码的维护难度就是地狱难度了。
const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);这样就舒服多了,所有的高阶组件都放在第一个括弧里面,并且由右向左去依次执行每个高阶组件HOC。如果某个高阶组件HOC需要除了组件之外的额外参数,像WithDiscount这样处理就可以了。很明显,我们的WithDiscount高阶组件的代码需要修改才能满足compose函数的需求,这个是修改后的代码:
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> { // 根据传入的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此时只接收一个参数item,不再接收BaseComponent组件对象了,然后直接return出去一个回调函数。准确的来说此时的WithDiscount函数已经不是高阶组件HOC了,他return出去的回调函数才是真正的高阶组件HOC。在回调函数中去接收BaseComponent组件对象,然后返回一个增强后的Vue组件对象。
function compose(...funcs) { return funcs.reduce((acc, cur) => (...args) => acc(cur(...args))); }这个函数虽然只有一行代码,但是乍一看,怎么看怎么懵逼,欧阳也是!!我们还是结合demo来看:
const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);假如我们这里有WithA、WithB、 WithC、 WithD四个高阶组件,都是用于增强组件View。compose中使用的是...funcs将调用compose函数接收到的四个高阶组件都存到了funcs数组中。然后使用reduce去遍历这些高阶组件,注意看执行reduce时没有传入第二个参数。