前言

高阶组件在React社区中十分火热,但是在Vue中热度并不高,本文就来了解一下Vue中的高阶组件。

一、什么是高阶组件(HOC)

一个函数接受一个组件为参数,返回一个包装后的组件.

ReactVue都证明了一件事儿:一个函数就是一个组件。所以组件是函数这个命题成立了,那高阶组件很自然的就是高阶函数,即一个返回函数的函数。

高阶组件应该是有以下特征

  • 高阶组件应该是无副作用的纯函数,且不应该修改原组件,即原组件不能有变动
  • 高阶组件不关心你传递的数据(props)是什么,并且新生成组件不关心数据来源
  • 高阶组件接收到的 props 应该透传给被包装组件即直接将原组件prop传给包装组件
  • 高阶组件完全可以添加、删除、修改 props

二、Vue高阶组件实例

Base.vue

<template>
  <div>
    <el-button @click="handleClick">props:{{ test }}</el-button>
  </div>
</template>

<script lang="">
import { defineComponent } from "@vue/composition-api";

export default defineComponent({
  name: "Base",
  props: {
    test: {
      type: Number,
      default: 0,
    },
  },
  setup(props, { emit }) {
    const handleClick = () => {
      emit("base-click");
    };

    return {
      handleClick,
    };
  },
});
</script>

Vue 组件主要就是三点:props、event 以及 slots。对于 Base组件 组件而言,它接收一个数字类型的 props 即 test,并触发一个自定义事件,事件的名称是:Base-click,没有 slots。我们会这样使用该组件:

<base @base-click="handleCustClick" :test="100" />

现在我们需要 base组件每次挂载完成的时候都打印一句话:I have already mounted,同时这也许是很多组件的需求,所以按照mixins的方式,我们可以这样做,首先定义个 mixins

export default consoleMixins {
 mounted() {
	console.log('I have already mounted');	
 }
}

然后在base组件中将consoleMixins混入:

export default defineComponent({
  name: "Base",
  props: {
    test: {
      type: Number,
      default: 0,
    },
  },
  mixins:[consoleMixins],
  setup(props, { emit }) {
    const handleClick = () => {
      emit("base-click");
    };

    return {
      handleClick,
    };
  },
});

混入之后,每次挂载组件都会打印I have already mounted

现在我们来使用高阶组件的方式实现同样的功能。
回忆一下高阶组件的定义:接受一个组件作为参数,返回一个新的组件
那么此时我们需要思考的是,在 Vue 中组件是什么?有的同学可能会有疑问,难道不是函数吗?对,Vue中组件是函数没有问题,不过那是最终结果,比如我们在单文件组件中的组件定义其实就是一个普通的选项对象,如下:

export default {
  name: 'Base',
  props: {...},
  mixins: [...]
  methods: {...}
}

这不就是一个纯对象吗?所以当我们从单文件中导入一个组件的时候:

import Base from './Base.vue'

思考一下,这里的 Base 是什么?它是函数吗?不是,虽然单文件组件会被vue-loader 处理,但处理后的结果,也就是我们这里的 Base 仍然还是一个普通的 JSON 对象,只不过当你把这个对象注册为组件(components 选项)之后,Vue 最终会以该对象为参数创建一个构造函数,该构造函数就是生产组件实例的构造函数,所以在 Vue 中组件确实是函数,只不过那是最终结果罢了,在这之前我们完全可以说在 Vue 中组件也可以是一个普通对象,就像单文件组件中所导出的对象一样。

基于此,我们知道在 Vue 中一个组件可以以纯对象的形式存在,所以 Vue 中的高阶组件可以这样定义:接收一个纯对象,并返回一个新的纯对象,如下代码:

// hoc.js
export default function WithConsole (WrappedComponent) {
  return {
    template: '<wrapped v-on="$listeners" v-bind="$attrs"/>',
    components: {
      wrapped: WrappedComponent
    },
    mounted () {
      console.log('I have already mounted')
    }
  }
}

WithConsole就是一个高阶组件,它接收一个组件作为参数:WrappedComponent,并返回一个新的组件。在新的组件定义中,我们将WrappedComponent 注册为wrapped组件,并在 template中将其渲染出来,同时添加 mounted钩子,打印 I have already mounted

以上就完成了与mixins同样的功能,不过这一次我们采用的是高阶组件,所以是非侵入式的,我们没有修改原组件(WrappedComponent),而是在新组件中渲染了原组件,并且没有对原组件做任何修改。并且这里大家要注意 $listeners$attrs这么做是必须的,这就等价于在 React 中透传 props:否则在使用高阶组件的时候,被包装组件(WrappedComponent)接收不到props和 事件

那这样真的就完美解决问题了吗?不是的,首先 template 选项只有在完整版的 Vue 中可以使用,在运行时版本中是不能使用的,所以最起码我们应该使用渲染函数(render)替代模板(template),如下:

// hoc.js
export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
      })
    }
  }
}

上面的代码中,我们将模板改写成了渲染函数,看上去没什么问题,实则不然,上面的代码中 WrappedComponent组件依然收不到 props,有的同学可能会问了,我们不是已经在 h 函数的第二个参数中将 attrs 传递过去了吗,怎么还收不到?当然收不到,attrs 指的是那些没有被声明为 props 的属性,所以在渲染函数中还需要添加 props 参数

//hoc.js
export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}

那这样是不是可以了呢?依然不行,因为 this.$props 始终是空对象,这是因为这里的 this.$props 指的是高阶组件接收到的props,而高阶组件没有声明任何 props,所以 this.$props自然是空对象啦,那怎么办呢?很简单只需要将高阶组件的props 设置与被包装组件的 props 相同即可了:

// hoc.js
export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}

现在才是一个稍微完整可用的高阶组件。大家注意用词:稍微,纳尼?都修改成这样了还不行吗?当然,上面的高阶组件能完成以下工作:

  • 透传props
  • 透传没有被声明为props的属性
  • 透传事件

大家不觉得缺少点儿什么吗?我们前面说过,一个 Vue 组件的三个重要因素:props、事件 以及 slots,前两个都搞定了,但 slots 还不行。我们修改 Base组件为其添加一个具名插槽和默认插槽,如下:

<!-- Base.vue -->
<template>
  <div>
    <span @click="handleClick">props: {{test}}</span>
    <slot name="slot1"/> <!-- 具名插槽 -->
    <p>===========</p>
    <slot/> <!-- 默认插槽 -->
  </div>
</template>

<script>
export default {
  ...
}
</script>

然后我们写下如下测试代码:

<template>
  <div>
    <base>
      <h2 slot="slot1">BaseComponent slot</h2>
      <p>default slot</p>
    </base>
    <enhanced-com>
      <h2 slot="slot1">EnhancedComponent slot</h2>
      <p>default slot</p>
    </enhanced-com>
  </div>
</template>

<script>
  import Base from './base.vue'
  import hoc from './hoc.js'

  const EnhancedCom = hoc(BaseComponent)

  export default {
    components: {
      BaseComponent,
      EnhancedCom
    }
  }
</script>

渲染结果如下:
在这里插入图片描述
上图中蓝色框是 Base组件渲染的内容,是正常的。红色框是高阶组件渲染的内容,可以发现无论是具名插槽还是默认插槽全部丢失。其原因很简单,就是因为我们在高阶组件中没有将分发的插槽内容透传给被包装组件(WrappedComponent),所以我们尝试着修改高阶组件:

// hoc.js
function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {

      // 将 this.$slots 格式化为数组,因为 h 函数第三个参数是子节点,是一个数组
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])

      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      }, slots) // 将 slots 作为 h 函数的第三个参数
    }
  }
}

好啦,大功告成刷新页面,如下:在这里插入图片描述
纳尼??我们发现,分发的内容确实是渲染出来了,不过貌似顺序不太对。。。。。。蓝色框是正常的,在具名插槽与默认插槽的中间是有分界线(=)的,而红色框中所有的插槽全部渲染到了分界线(=)的下面,*看上去貌似具名插槽也被作为默认插槽处理了。*这到底是怎么回事呢?

想弄清楚这个问题,就回到了文章开始时我提到的一点,即你需要对 Vue 的实现原理有所了解才行,否则无解。接下来就从原理触发讲解如何解决这个问题。这个问题的根源在于:Vue 在处理具名插槽的时候会考虑作用域的因素。不明白没关系,我们一点点分析。

首先补充一个提示:Vue会把模板(template)编译成渲染函数(render),比如如下模板:

<div>
  <h2 slot="slot1">BaseComponent slot</h2>
</div>

会被编译成如下渲染函数:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", [
    _c("h2", {
      attrs: { slot: "slot1" },
      slot: "slot1"
    }, [
      _vm._v("BaseComponent slot")
    ])
  ])
}

我们发现无论是普通DOM还是组件,都是通过 _c函数创建其对应的 VNode的。其实 _cVue内部就是 createElement函数。createElement 函数会自动检测第一个参数是不是普通DOM标签,如果不是普通DOM标签那么 createElement 会将其视为组件,并且创建组件实例,注意⚠️组件实例是这个时候才创建的。但是创建组件实例的过程中就面临一个问题:组件需要知道父级模板中是否传递了slot 以及传递了多少,传递的是具名的还是不具名的等等。那么子组件如何才能得知这些信息呢?很简单,假如组件的模板如下:

<div>
  <base>
    <h2 slot="slot1">BaseComponent slot</h2>
    <p>default slot</p>
  </base>
</div>

父组件的模板最终会生成父组件对应的 VNode,所以以上模板对应的 VNode 全部由父组件所有,那么在创建子组件实例的时候能否通过获取父组件的VNode 进而拿到slot的内容呢?即通过父组件将下面这段模板对应的 VNode拿到:

<base>
  <h2 slot="slot1">BaseComponent slot</h2>
  <p>default slot</p>
</base>

如果能够通过父级拿到这段模板对应的 VNode,那么子组件就知道要渲染哪些slot了,其实 Vue 内部就是这么干的,实际上你可以通过访问子组件的this.$vnode来获取这段模板对应的 VNode
在这里插入图片描述
其中this.$vnode 并没有写进Vue 的官方文档。子组件拿到了需要渲染的 slot 之后进入到了关键的一步,这一步就是导致高阶组件中透传slotBaseComponent却无法正确渲染的原因,看下图:在这里插入图片描述

这张图与上一张图相同,在子组件中打印this.$vnode,标注中的 context引用着 VNode被创建时所在的组件实例,由于 this.$vnode中引用的 VNode 对象是在父组件中被创建的,所以 this.$vnode中的 context引用着父实例。理论上图中标注的两个 context应该是相等的:

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // true

Vue内部做了一件很重要的事儿,即上面那个表达式必须成立,才能够正确处理具名 slot,否则即使slot具名也不会被考虑,而是被作为默认插槽。这就是高阶组件中不能正确渲染 slot的原因。

那么为什么高阶组件中上面的表达式就不成立了呢?那是因为由于高阶组件的引入,在原本的父组件与子组件之间插入了一个组件(也就是高阶组件),这导致在子组件中访问的 this.$vnode已经不是原来的父组件中的 VNode 片段了,而是高阶组件的 VNode 片段,所以此时 this.$vnode.context 引用的是高阶组件,但是我们却将slot 透传,slot 中的 VNodecontext引用的还是原来的父组件实例,所以这就造成了以下表达式为假:

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // false

最终导致具名插槽被作为默认插槽,从而渲染不正确。

而解决办法也很简单,只需要手动设置一下 slotVNodecontext值为高阶组件实例即可,修改高阶组件如下:

// hoc.js
function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])
        // 手动更正 context
        .map(vnode => {
          vnode.context = this._self
          return vnode;
        })

      return h(WrappedComponent, {
        on: this.$listeners,
        props: this.$props,
        attrs: this.$attrs
      }, slots)
    }
  }
}

现在,都能够正常渲染啦,如下图:在这里插入图片描述
这里的关键点除了你需要了解 Vue处理slot的方式之外,你还要知道通过当前实例_self属性访问当实例本身,而不是直接使用 this,因为this 是一个代理对象。

现在貌似看上去没什么问题了,不过我们还忘记了一件事儿,即 scopedSlots,不过 scopedSlotsslot的实现机制不一样,本质上 scopedSlots 就是一个接收数据作为参数并渲染 VNode的函数,所以不存在 context的概念,所以直接透传即可:

// hoc.js
function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])
        .map(vnode => {
          vnode.context = this._self
          return vnode
        })

      return h(WrappedComponent, {
        on: this.$listeners,
        props: this.$props,
        // 透传 scopedSlots
        scopedSlots: this.$scopedSlots,
        attrs: this.$attrs
      }, slots)
    }
  }
}

到现在为止,一个高阶组件应该具备的基本功能算是实现了,但这仅仅是个开始,要实现一个完整健壮的Vue高阶组件,还要考虑很多内容,比如:

  • 函数式组件中要使用 render 函数的第二个参数代替 this
  • 以上我们只讨论了以纯对象形式存在的 Vue 组件,然而除了纯对象外还可以函数。
  • 创建 render 函数的很多步骤都可以进行封装。
  • 处理更多高阶函数组件本身的选项(而不仅仅是上面例子中的一个简单的生命周期钩子)

原文出处:https://www.jianshu.com/p/6b149189e035

Logo

前往低代码交流专区

更多推荐