写点什么

让我看看有多少人不知道 Vue3 中也能实现高阶组件 HOC

  • 2025-04-17
    福建
  • 本文字数:9279 字

    阅读完需:约 30 分钟

前言


高阶组件HOC在 React 社区是非常常见的概念,但是在 Vue 社区中却是很少人使用。主要原因有两个:1、Vue 中一般都是使用 SFC,实现 HOC 比较困难。2、HOC 能够实现的东西,在 Vue2 时代mixins能够实现,在 Vue3 时代Composition API能够实现。如果你不知道 HOC,那么你平时绝对没有场景需要他。但是如果你知道 HOC,那么在一些特殊的场景使用他就可以很优雅的解决一些问题。


什么是高阶组件 HOC


HOC 使用场景就是加强原组件


HOC 实际就是一个函数,这个函数接收的参数就是一个组件,并且返回一个组件,返回的就是加强后组件。如下图:



Composition API出现之前 HOC 还有一个常见的使用场景就是提取公共逻辑,但是有了Composition API后这种场景就无需使用 HOC 了。


高阶组件 HOC 使用场景


很多同学觉得有了Composition API后,直接无脑使用他就完了,无需费时费力的去搞什么 HOC。那如果是下面这个场景呢?


有一天产品找到你,说要给我们的系统增加会员功能,需要让系统中的几十个功能块增加会员可见功能。如果不是会员这几十个功能块都显示成引导用户开通会员的 UI,并且这些功能块涉及到几十个组件,分布在系统的各个页面中。


如果不知道 HOC 的同学一般都会这样做,将会员相关的功能抽取成一个名为useVip.ts的 hooks。代码如下:


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。


上面的这一场景使用 hooks 去实现,虽然能够完成,但是因为入侵了这几十个模块的业务逻辑。所以容易出错,也改起来比较麻烦,代码也不优雅。


那么有没有一种更好的解决方案,让我们可以不入侵这几十个模块的业务逻辑的实现方式呢?


答案是:高阶组件HOC


HOC 的一个用途就是对组件进行增强,并且不会入侵原有组件的业务逻辑,在这里就是使用 HOC 判断会员相关的逻辑。如果是会员那么就渲染原本的模块组件,否则就渲染引导开通 VIP 的 UI


实现一个简单的 HOC


首先我们要明白 Vue 的组件经过编译后就是一个对象,对象中的props属性对应的就是我们写的defineProps。对象中的 setup 方法,对应的就是我们熟知的<script setup>语法糖。


比如我使用console.log(Block1)将上面的import Block1 from "./block1.vue";给打印出来,如下图:



这个就是我们引入的 Vue 组件对象。


还有一个冷知识,大家可能不知道。如果在 setup 方法中返回一个函数,那么在 Vue 内部就会认为这个函数就是实际的 render 函数,并且在 setup 方法中我们天然的就可以访问定义的变量。


利用这一点我们就可以在 Vue3 中实现一个简单的高阶组件 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() { // 一些业务逻辑判断是否是VIP return true; }
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,他是不满足我们实际的业务场景。比如子组件有propsemit插槽。还有我们在父组件中可能会直接调用子组件 expose 暴露的方法。


因为我们使用了 HOC 对原本的组件进行了一层封装,那么上面这些场景 HOC 都是不支持的,我们需要添加一些额外的代码去支持。


高阶组件 HOC 实现 props 和 emit


在 Vue 中属性分为两种,一种是使用propsemit声明接收的属性。第二种是未声明的属性attrs,比如 class、style、id 等。


在 setup 函数中 props 是作为第一个参数返回,attrs是第二个参数中返回。


所以为了能够支持 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() { // 一些业务逻辑判断是否是VIP return true; }
return () => { return showVipContent ? h(BaseComponent, { ...props, // 新增代码 ...attrs, // 新增代码 }) : h(OpenVipTip); }; }, };}
复制代码


setup方法中接收的第一个参数就是props,没有在 props 中定义的属性就会出现在attrs对象中。

所以我们调用 h 函数时分别将propsattrs透传给子组件。


同时我们还需要一个地方去定义 props,props 的值就是直接读取子组件对象中的BaseComponent.props。所以我们给高阶组件声明一个 props 属性:props: BaseComponent.props,


这样 props 就会被透传给子组件了。


看到这里有的小伙伴可能会问,那 emit 触发事件没有看见你处理呢?


答案是:我们无需去处理,因为父组件上面的@changeName="(value) => (name1 = value)"经过编译后就会变成属性::onChangeName="(value) => (name1 = value)"。而这个属性由于我们没有在 props 中声明,所以他会作为attrs直接透传给子组件。


高阶组件实现插槽


我们的正常子组件一般还有插槽,比如下面这样:


<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";
export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些业务逻辑判断是否是VIP return true; }
return () => { return showVipContent ? h( BaseComponent, { ...props, ...attrs, }, slots // 新增代码 ) : h(OpenVipTip); }; }, };}
复制代码


插槽的本质就是一个对象里面拥有多个方法,这些方法的名称就是每个具名插槽,每个方法的参数就是插槽传递的变量。这里我们只需要执行h函数时将slots对象传给 h 函数,就能实现插槽的透传(如果你看不懂这句话,那就等欧阳下篇插槽的文章写好后再来看这段话你就懂了)。


我们在控制台中来看看传入的slots插槽对象,如下图:



从上面可以看到插槽对象中有两个方法,分别是defaultfooter,对应的就是默认插槽和 footer 插槽。


大家熟知 h 函数接收的第三个参数是 children 数组,也就是有哪些子元素。但是他其实还支持直接传入slots对象,下面这个是他的一种定义:


export function h<P>(  type: Component<P>,  props?: (RawProps & P) | null,  children?: RawChildren | RawSlots,): VNode
export type RawSlots = { [name: string]: unknown // ...省略}
复制代码


所以我们可以直接把 slots 对象直接丢给 h 函数,就可以实现插槽的透传。


父组件调用子组件的方法


有的场景中我们需要在父组件中直接调用子组件的方法,按照以前的场景,我们只需要在子组件中 expose 暴露出去方法,然后在父组件中使用 ref 访问到子组件,这样就可以调用了。


但是使用了 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() { // 一些业务逻辑判断是否是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 拦截中让其直接去执行子组件中的对应的方法。


比如在父组件中使用block1Ref.value.handleClick()去调用handleClick方法,由于使用了 HOC,所以这里读取的handleClick方法其实是读取的是 HOC 中 expose 暴露的方法。所以就会走到Proxy的 get 拦截中,从而可以访问到真正子组件中 expose 暴露的handleClick方法。


那么上面的 Proxy 为什么要使用has拦截呢?


答案是在 Vue 源码中父组件在执行子组件中暴露的方法之前会执行这样一个判断:


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";
export default function WithVip(BaseComponent: any) { return { props: BaseComponent.props, setup(props, { attrs, slots, expose }: SetupContext) { const showVipContent = getShowVipContent(); function getShowVipContent() { // 一些业务逻辑判断是否是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 函数能够实现的,使用jsx或者tsx都能实现(除非你需要操作虚拟 DOM)。

注意上面的代码是使用ref={innerRef},而不是我们熟悉的ref="innerRef",这里很容易搞错!!


compose 函数


此时你可能有个新需求,需要给某些模块显示不同的折扣信息,这些模块可能会和上一个会员需求的模块有重叠。此时就涉及到多个高阶组件之间的组合情况。


同样我们使用 HOC 去实现,新增一个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> { // 根据传入的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函数了,这个是 React 社区中常见的概念。它的核心思想是将多个函数从右到左依次组合起来执行,前一个函数的输出作为下一个函数的输入。


我们这里有多个 HOC(也就是有多个函数),我们期望执行完第一个 HOC 得到一个加强的组件,然后以这个加强的组件为参数去执行第二个 HOC,最后得到由多个 HOC 加强的组件。


compose函数就刚好符合我们的需求,这个是使用compose函数后的代码,如下:


const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);
复制代码


这样就舒服多了,所有的高阶组件都放在第一个括弧里面,并且由右向左去依次执行每个高阶组件 HOC。如果某个高阶组件 HOC 需要除了组件之外的额外参数,像WithDiscount这样处理就可以了。


很明显,我们的WithDiscount高阶组件的代码需要修改才能满足compose函数的需求,这个是修改后的代码:


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> { // 根据传入的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此时只接收一个参数item,不再接收BaseComponent组件对象了,然后直接 return 出去一个回调函数。


准确的来说此时的WithDiscount函数已经不是高阶组件 HOC 了,他return出去的回调函数才是真正的高阶组件HOC。在回调函数中去接收BaseComponent组件对象,然后返回一个增强后的 Vue 组件对象。


至于参数item,因为闭包所以在里层的回调函数中还是能够访问的。这里比较绕,可能需要多理解一下。


前面的理解完了后,我们可以再上一点强度了。来看看compose函数是如何实现的,代码如下:


function compose(...funcs) {  return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));}
复制代码


这个函数虽然只有一行代码,但是乍一看,怎么看怎么懵逼,欧阳也是!!我们还是结合 demo 来看:


const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);
复制代码


假如我们这里有WithAWithB、 WithC、 WithD四个高阶组件,都是用于增强组件View


compose 中使用的是...funcs将调用compose函数接收到的四个高阶组件都存到了funcs数组中。


然后使用 reduce 去遍历这些高阶组件,注意看执行reduce时没有传入第二个参数。


所以第一次执行 reduce 时,acc的值为WithAcur的值为WithB。返回结果也是一个回调函数,将这两个值填充进去就是(...args) => WithA(WithB(...args)),我们将第一次的执行结果命名为r1

我们知道 reduce 会将上一次的执行结果赋值为 acc,所以第二次执行 reduce 时,acc的值为r1cur的值为WithC。返回结果也是一个回调函数,同样将这两个值填充进行就是(...args) => r1(WithC(...args))。同样我们将第二次的执行结果命名为r2


第三次执行 reduce 时,此时的acc的值为r2cur的值为WithD。返回结果也是一个回调函数,同样将这两个值填充进行就是(...args) => r2(WithD(...args))。同样我们将第三次的执行结果命名为r3,由于已经将数组遍历完了,最终 reduce 的返回值就是r3,他是一个回调函数。


由于compose(WithA, WithB, WithC, WithD)的执行结果为r3,那么compose(WithA, WithB, WithC, WithD)(View)就等价于r3(View)


前面我们知道r3是一个回调函数:(...args) => r2(WithD(...args)),这个回调函数接收的参数args,就是需要增强的基础组件View。所以执行这个回调函数就是先执行WithD对组件进行增强,然后将增强后的组件作为参数去执行r2


同样r2也是一个回调函数:(...args) => r1(WithC(...args)),接收上一次WithD增强后的组件为参数执行WithC对组件再次进行增强,然后将增强后的组件作为参数去执行r1


同样r1也是一个回调函数:(...args) => WithA(WithB(...args)),将WithC增强后的组件丢给WithB去执行,得到增强的组件再丢给WithA去执行,最终就拿到了最后增强的组件。


执行顺序就是从右向左去依次执行高阶组件对基础组件进行增强。


至此,关于compose函数已经讲完了,这里对于 Vue 的同学可能比较难理解,建议多看两遍。


总结


这篇文章我们讲了在 Vue3 中如何实现一个高阶组件 HOC,但是里面涉及到了很多源码知识,所以这是一篇运用源码的实战文章。如果你理解了文章中涉及到的知识,那么就会觉得 Vue 中实现 HOC 还是很简单的,反之就像是在看天书。


还有最重要的一点就是Composition API已经能够解决绝大部分的问题,只有少部分的场景才需要使用高阶组件 HOC,切勿强行使用HOC,那样可能会有炫技的嫌疑。如果是防御性编程,那么就当我没说。


最后就是我们实现的每个高阶组件 HOC 都有很多重复的代码,而且实现起来很麻烦,心智负担也很高。那么我们是不是可以抽取一个createHOC函数去批量生成高阶组件呢?这个就留给各位自己去思考了。


还有一个问题,我们这种实现的高阶组件叫做正向属性代理,弊端是每代理一层就会增加一层组件的嵌套。那么有没有方法可以解决嵌套的问题呢?


答案是反向继承,但是这种也有弊端如果业务是 setup 中返回的 render 函数,那么就没法重写了 render 函数了。


文章转载自:前端欧阳

原文链接:https://www.cnblogs.com/heavenYJJ/p/18653798

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
让我看看有多少人不知道Vue3中也能实现高阶组件HOC_Java_不在线第一只蜗牛_InfoQ写作社区