专栏名称: 前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
目录
相关文章推荐
厦门日报  ·  突发!美国两架飞机相撞 ·  15 小时前  
厦门日报  ·  突发!巴西一小飞机坠毁,砸向一辆公交车 ·  4 天前  
厦门日报  ·  159978000+!四连冠! ·  4 天前  
厦门日报  ·  韩某某,投敌叛变!48小时内被抓捕归案! ·  4 天前  
51好读  ›  专栏  ›  前端从进阶到入院

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

前端从进阶到入院  · 公众号  ·  · 2025-01-06 11:47

正文

前言

高阶组件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。代码如下:


  <Block1
    v-if="showVipContent"
    :name="name1"
    @changeName="(value) => (name1 = value)"
  />

  <OpenVipTip v-else />
</template>


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");

我们系统中有几十个这样的组件,那么我们就需要这样去改几十次。非常麻烦,如果有些模块是其他同事写的代码还很容易改错!!!

而且现在流行搞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方法,对应的就是我们熟知的 语法糖。

比如我使用 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";

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的组件。

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


  <EnhancedBlock1 />
</template>


import Block1 from "./
block1.vue";
import WithVip from "
./with-vip.tsx";

const EnhancedBlock1 = WithVip(Block1);

这个代码相比前面的hooks的实现就简单很多了,只需要使用高阶组件 WithVip 对原来的 Block1 组件包一层,然后将原本使用 Block1 的地方改为使用 EnhancedBlock1 。对原本的代码基本没有入侵。

上面的例子只是一个简单的demo,他是不满足我们实际的业务场景。比如子组件有 props emit 插槽 。还有我们在父组件中可能会直接调用子组件expose暴露的方法。

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

高阶组件HOC实现props和emit

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

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

所以为了能够支持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({
        // 一些业务逻辑判断是否是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,

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

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

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

高阶组件实现插槽

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


  <div class="divider">
    <h1>{{ name }}h1>
    <button @click="handleClick">change namebutton>
    <slot />
    这里是block1的一些业务代码
    <slot name="footer" />
  div>

</template>


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 插槽对象,如下图:

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

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

export function h<P>(
  type: Component

,


  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";

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 方法。

那么上面的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";

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} {...attrsref={innerRef}>
            {slots}
          BaseComponent>

        ) : (
          <OpenVipTip />
        );
      };
    },
  };
}

一般情况下h函数能够实现的,使用 jsx 或者 tsx 都能实现(除非你需要操作虚拟DOM)。

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

compose函数

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

同样我们使用HOC去实现,新增一个 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{
        // 根据传入的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} {...attrsref={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";

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} {...attrsref={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);

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

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







请到「今天看啥」查看全文