您现在的位置是:首页 > 编程 > 

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

2025-07-24 08:23:25
让我看看有多少人不知道Vue中也能实现高阶组件HOC 大家好,我是欧阳,又跟大家见面啦!前言高阶组件HOC在React社区是非常常见的概念,但是在Vue社区中却是很少人使用。主要原因有两个:1、Vue中一般都是使用SFC,实现HOC比较困难。2、HOC能够实现的东西,在Vue2时代mixins能够实现,在Vue时代Composition API能够实现。如果你不知道HOC,那么你平时绝对没有场

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

大家好,我是欧阳,又跟大家见面啦!

前言

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

什么是高阶组件HOC

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

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

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

高阶组件HOC使用场景

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

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

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

代码语言:javascript代码运行次数:0运行复制
export function useVip() {
  function getShowVipContent() {
    // 一些业务逻辑判断是否是VIP
    return false;
  }

  return {
    showVipContent: getShowVipContent(),
  };
}

然后再去每个具体的业务模块中去使用showVipContent变量判断,v-if="showVipContent"显示原模块,v-else显示引导开通会员UI。代码如下:

代码语言:javascript代码运行次数:0运行复制
<template>
  <Block1
    v-if="showVipContent"
    :name="name1"
    @changeame="(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";

ct { showVipContent } = useVip();
ct 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>语法糖。

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

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

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

利用这一点我们就可以在Vue中实现一个简单的高阶组件HOC,代码如下:

代码语言:javascript代码运行次数:0运行复制
import { h } from"vue";
import OpenVipTip from"./open-vip-tip.vue";

exportdefaultfunction WithVip(BaseComponent: any) {
return {
    setup() {
      ct 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的组件。

此时我们的父组件就应该是下面这样的:

代码语言:javascript代码运行次数:0运行复制
<template>
  <EnhancedBlock1 />
</template>

<script setup lang="ts">
import Block1 from "./block1.vue";
import WithVip from "./";

ct 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将会变成下面这样:

代码语言:javascript代码运行次数:0运行复制
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) {  // 新增代码
      ct showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        returntrue;
      }

      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触发事件没有看见你处理呢?

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

高阶组件实现插槽

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

代码语言:javascript代码运行次数:0运行复制
<template>
  <div class="divider">
    <h1>{{ name }}</h1>
    <button @click="handleClick">change name</button>
    <slot />
    这里是block1的一些业务代码
    <slot name="footer" />
  </div>
</template>

<script setup lang="ts">
ct emit = defineEmits<{
  changeame: [name: string];
}>();

ct props = defineProps<{
  name: string;
}>();

ct handleClick = () => {
  emit("changeame", `hello ${}`);
};

defineExpose({
  handleClick,
});
</script>

在上面的例子中,子组件有个默认插槽和name为footer的插槽。此时我们来看看高阶组件中如何处理插槽呢?

直接看代码:

代码语言:javascript代码运行次数:0运行复制
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) {
      ct showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        returntrue;
      }

      return() => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
              },
              slots // 新增代码
            )
          : h(OpenVipTip);
      };
    },
  };
}

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

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

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

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

代码语言:javascript代码运行次数:0运行复制
export function h<P>(
  type: Component<P>,
  props?: (RawProps & P) | null,
  children?: RawChildren | RawSlots,
): Vode

export type RawSlots = {
  [name: string]: unknown
  // ...省略
}

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

父组件调用子组件的方法

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

但是使用了HOC后,中间层多了一个高阶组件,所以我们不能直接访问到子组件expose的方法。

怎么做呢?答案很简单,直接上代码:

代码语言:javascript代码运行次数:0运行复制
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) {
      ct showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        returntrue;
      }

      // 新增代码start
      ct 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方法。

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

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

代码语言:javascript代码运行次数:0运行复制
if (key in target) {
  return target[key];
}

很明显我们这里的Proxy代理的原始对象里面什么都没有,执行key in target肯定就是false了。所以我们可以使用has去拦截key in target,意思是只要访问的方法或者属性是子组件中expose暴露的就返回true。

至此,我们已经在HOC中覆盖了Vue中的所有场景。但是有的同学觉得h函数写着比较麻烦,不好维护,我们还可以将上面的高阶组件改为tsx的写法,文件代码如下:

代码语言:javascript代码运行次数:0运行复制
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) {
      ct showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        returntrue;
      }

      ct 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)。

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

compose函数

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

同样我们使用HOC去实现,新增一个WithDiscount高阶组件,代码如下:

代码语言:javascript代码运行次数:0运行复制
import { SetupContext, onMounted, ref } from"vue";

exportdefaultfunction WithDiscount(BaseComponent: any, item: string) {
return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      ct discountInfo = ref("");

      onMounted(async () => {
        ct res = await getDiscountInfo(item);
        discountInfo.value = res;
      });

      function getDiscountInfo(item: any): Promise<string> {
        // 根据传入的item获取折扣信息
        returnnewPromise((resolve) => {
          setTimeout(() => {
            resolve("我是折扣信息1");
          }, 1000);
        });
      }

      ct 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功能和折扣信息功能需要怎么办呢?代码如下:

代码语言:javascript代码运行次数:0运行复制
ct EnhancedBlock1 = WithVip(WithDiscount(Block1, "item1"));

如果不是VIP,那么这个模块的折扣信息也不需要显示了。

因为高阶组件接收一个组件,然后返回一个加强的组件。利用这个特性,我们可以使用上面的这种代码将其组合起来。

但是上面这种写法大家觉得是不是看着很难受,一层套一层。如果这里同时使用5个高阶组件,这里就会套5层了,那这个代码的维护难度就是地狱难度了。

所以这个时候就需要compose函数了,这个是React社区中常见的概念。它的核心思想是将多个函数从右到左依次组合起来执行,前一个函数的输出作为下一个函数的输入。

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

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

代码语言:javascript代码运行次数:0运行复制
ct EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);

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

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

代码语言:javascript代码运行次数:0运行复制
import { SetupContext, onMounted, ref } from"vue";

exportdefaultfunction WithDiscount(item: string) {
return(BaseComponent: any) => {
    return {
      props: BaseComponent.props,
      setup(props, { attrs, slots, expose }: SetupContext) {
        ct discountInfo = ref("");

        onMounted(async () => {
          ct res = await getDiscountInfo(item);
          discountInfo.value = res;
        });

        function getDiscountInfo(item: any): Promise<string> {
          // 根据传入的item获取折扣信息
          returnnewPromise((resolve) => {
            setTimeout(() => {
              resolve("我是折扣信息1");
            }, 1000);
          });
        }

        ct 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组件对象。

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

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

代码语言:javascript代码运行次数:0运行复制
function compose(...funcs) {
  return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));
}

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

代码语言:javascript代码运行次数:0运行复制
ct EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);

假如我们这里有WithAWithBWithCWithD四个高阶组件,都是用于增强组件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))。同样我们将第三次的执行结果命名为r,由于已经将数组遍历完了,最终reduce的返回值就是r,他是一个回调函数。

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

前面我们知道r是一个回调函数:(...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的同学可能比较难理解,建议多看两遍。

总结

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

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

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

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

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

本文参与 腾讯云自媒体同步曝光计划,分享自。原始发表:2025-01-05,如有侵权请联系 cloudcommunity@tencent 删除函数渲染returnvue对象

#感谢您对电脑配置推荐网 - 最新i3 i5 i7组装电脑配置单推荐报价格的认可,转载请说明来源于"电脑配置推荐网 - 最新i3 i5 i7组装电脑配置单推荐报价格

本文地址:http://www.dnpztj.cn/biancheng/1192883.html

相关标签:无
上传时间: 2025-07-23 01:31:42
留言与评论(共有 17 条评论)
本站网友 ai市场
2分钟前 发表
h
本站网友 百岁鱼团购
10分钟前 发表
就使用h函数渲染传入的组件
本站网友 酷车
10分钟前 发表
如下:代码语言:javascript代码运行次数:0运行复制ct EnhancedBlock1 = compose(WithVip
本站网友 企业发展战略规划
27分钟前 发表
所以就会走到Proxy的get拦截中
本站网友 万厚医院
20分钟前 发表
WithC
本站网友 新学网
24分钟前 发表
所以容易出错
本站网友 金象大药房
30分钟前 发表
因为我们使用了HOC对原本的组件进行了一层封装
本站网友 红谷十二庭
13分钟前 发表
并且返回一个组件
本站网友 新安二手房出售
18分钟前 发表
新增一个WithDiscount高阶组件
本站网友 慢慢说
16分钟前 发表
any) { return { props
本站网友 米乐星ktv
4分钟前 发表
1000); }); } ct innerRef = ref(); expose( newProxy( {}
本站网友 黑马兽药网
23分钟前 发表
让我们可以不入侵这几十个模块的业务逻辑的实现方式呢?答案是:高阶组件HOC
本站网友 4个月的宝宝吃什么辅食
14分钟前 发表
ref } from"vue"; import OpenVipTip from"./open-vip-tip.vue"; exportdefaultfunction WithVip(BaseComponent
本站网友 手术瘦脸价格
6分钟前 发表
同样我们将第三次的执行结果命名为r
本站网友 蒋大为儿子
10分钟前 发表
compose函数就刚好符合我们的需求
本站网友 有点小黄的小说
20分钟前 发表
string) { return { props