在Vue中使用HOC模式

6,721 阅读3分钟

前言

HOCReact常用的一种模式,但HOC只能是在React才能玩吗?先来看看React官方文档是怎么介绍HOC的:

高阶组件(HOC)是React中用于复用组件逻辑的一种高级技巧。HOC自身不是ReactAPI的一部分,它是一种基于React的组合特性而形成的设计模式。

HOC它是一个模式,是一种思想,并不是只能在React中才能用。所以结合Vue的特性,一样能在Vue中玩HOC

HOC

HOC要解决的问题

并不是说哪种技术新颖,就得使用哪一种。得看这种技术能够解决哪些痛点。

HOC主要解决的是可复用性的问题。在Vue中,这种问题一般是用Mixin解决的。Mixin是一种通过扩展收集功能的方式,它本质上是将一个对象的属性拷贝到另一个对象上去。

最初React也是使用Mixin的,但是后面发现MixinReact中并不是一种好的模式,它有以下的缺点:

  • mixin与组件之间容易导致命名冲突
  • mixin是侵入式的,改变了原组件,复杂性大大提高。

所以React就慢慢的脱离了mixin,从而推荐使用HOC。并不是mixin不优秀,只是mixin不适合React

HOC是什么

HOC全称:high-order component--也就是高阶组件。具体而言,高阶组件是参数为组件,返回值为新组件的函数。

而在ReactVue中组件就是函数,所以的高阶组件其实就是高阶函数,也就是返回一个函数的函数。

来看看HOCReact的用法:

function withComponent(WrappedComponent) {
    return class extends Component {
        componentDidMount () {
          	console.log('已经挂载完成')
        }
        render() {
         	return <WrappedComponent {...props} />;
        }
    }
}

withComponent就是一个高阶组件,它有以下特点:

  • HOC是一个纯函数,且不应该修改原组件
  • HOC不关心传递的props是什么,并且WrappedComponent不关心数据来源
  • HOC接收到的props应该透传给WrapperComponent

在Vue中使用HOC

怎么样才能将Vue上使用HOC的模式呢?

我们一般书写的Vue组件是这样的:

<template>
  <div>
    <p>{{title}}</p>
    <button @click="changeTitle"></button>
  </div>
</template>

<script>
export default {
  name: 'ChildComponent',
  props: ['title'],
  methods: {
  	changeTitle () {
  		this.$emit('changeTitle');
  	}
  }
}
</script>

withComponet函数的功能是在每次挂载完成后都打印一句:已经挂载完成。
既然HOC是替代mixin的,所以我们先用mixin书写一遍:

export default {
	mounted () {
    	console.log('已经挂载完成')
    }
}

然后导入到ChildComponent

import withComponent from './withComponent';
export default {
	...
    mixins: ['withComponet'],
}

对于这个组件,我们在父组件中是这样调用的

<child-component :title='title' @changeTitle='changeTitle'></child-component>

<script>
import ChildComponent from './childComponent.vue';
export default {
	...
  	components: {ChildComponent}
}
</script>

大家有没有发现,当我们导入一个Vue组件时,其实是导入一个对象。

export default {}

至于说组件是函数,其实是经过处理之后的结果。所以Vue中的高阶组件也可以是:接收一个纯对象,返回一个纯对象。

所以改为HOC模式,是这样的:

export default function withComponent (WrapperComponent) {
	return {
        mounted () {
            console.log('已经挂载完成')
        },
        props: WrappedComponent.props,
        render (h) {
            return h(WrapperComponent, {
                on: this.$listeners,
                attrs: this.$attrs,
                props: this.$props
            })
        }
    }
}

注意{on: this.$listeners,attr: this.$attrs, props: this.props}这一句就是透传props的原理,等价于React中的<WrappedComponent {...props} />;

this.$props是指已经被声明的props属性,this.$attrs是指没被声明的props属性。这一定要两个一起透传,缺少哪一个,props都不完整。

为了通用性,这里使用了render函数来构建,这是因为template只有在完整版的Vue中才能使用。

这样似乎还不错,但是还有一个重要的问题,在Vue组件中是可以使用插槽的。

比如:

<template>
  <div>
    <p>{{title}}</p>
    <button @click="changeTitle"></button>
    <slot></slot>
  </div>
</template>

在父组件中

<child-component :title='title' @changeTitle='changeTitle'>Hello, HOC</child-component>

可以用this.$solts访问到被插槽分发的内容。每个具名插槽都有其相应的property,例如v-slot:foo中的内容将会在this.$slots.foo中被找到。而default property包括了所有没有被包含在具名插槽中的节点,或v-slot:default的内容。

所以在使用渲染函数书写一个组件时,访问this.$slots最有帮助的。

先将this.$slots转化为数组,因为渲染函数的第三个参数是子节点,是一个数组

export default function withComponent (WrapperComponent) {
	return {
        mounted () {
			      console.log('已经挂载完成')
        },
        props: WrappedComponent.props,
        render (h) {
            const keys = Object.keys(this.$slots);
            const slotList = keys.reduce((arr, key) => arr.concat(this.$slots[key]), []);
            return h(WrapperComponent, {
                on: this.$listeners,
                attrs: this.$attrs,
                props: this.$props
            }, slotList)
        }
    }
}

总算是有模有样了,但这还没结束,你会发现使不使用具名插槽都一样,最后都是按默认插槽来处理的。

有点纳闷,去看看Vue源码中是怎么具名插槽的。
src/core/instance/render.js文件中找到了initRender函数,在初始化render函数时

const options = vm.$options
const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context
vm.$slots = resolveSlots(options._renderChildren, renderContext)

这一段代码是Vue解析并处理slot的。
vm.$options._parentVnode赋值为vm.$vnode,也就是$vnode就是父组件的vnode。如果父组件存在,定义renderContext = vm.$vnode.contextrenderContext就是父组件要渲染的实例。 然后把renderContext$options._renderChildren作为参数传进resolveSlots()函数中。

接下里看看resolveSlots()函数,在src/core/instance/render-helper/resolve-slots.js文件中

export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  if (!children || !children.length) {
    return {}
  }
  const slots = {}
  for (let i = 0, l = children.length; i < l; i++) {
    const child = children[i]
    const data = child.data
    // remove slot attribute if the node is resolved as a Vue slot node
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot
    }
    // named slots should only be respected if the vnode was rendered in the
    // same context.
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
      const name = data.slot
      const slot = (slots[name] || (slots[name] = []))
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || [])
      } else {
        slot.push(child)
      }
    } else {
      (slots.default || (slots.default = [])).push(child)
    }
  }
  // ignore slots that contains only whitespace
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

重点来看里面的一段if语句

// named slots should only be respected if the vnode was rendered in the
// same context.
if ((child.context === context || child.fnContext === context) &&
  data && data.slot != null
) {
  const name = data.slot
  const slot = (slots[name] || (slots[name] = []))
  if (child.tag === 'template') {
    slot.push.apply(slot, child.children || [])
  } else {
    slot.push(child)
  }
} else {
  (slots.default || (slots.default = [])).push(child)
}

只有当if ((child.context === context || child.fnContext === context) && data && data.slot != null ) 为真时,才处理为具名插槽,否则不管具名不具名,都当成默认插槽处理

else {
  (slots.default || (slots.default = [])).push(child)
}

那为什么HOC上的if条件是不成立的呢?

这是因为由于HOC的介入,在原本的父组件与子组件之间插入了一个组件--也就是HOC,这导致了子组件中访问的this.$vode已经不是原本的父组件的vnode了,而是HOC中的vnode,所以这时的this.$vnode.context引用的是高阶组件,但是我们却将slot透传了,slot中的VNodecontext引用的还是原来的父组件实例,所以就导致不成立。

从而都被处理为默认插槽。

解决方法也很简单,只需手动的将slot中的vnodecontext指向为HOC实例即可。注意当前实例 _self 属性访问当前实例本身,而不是直接使用 this,因为 this 是一个代理对象。

export default function withComponent (WrapperComponent) {
	return {
        mounted () {
			      console.log('已经挂载完成')
        },
        props: WrappedComponent.props,
        render (h) {
            const keys = Object.keys(this.$slots);
            const slotList = keys.reduce((arr, key) => arr.concat(this.$slots[key]), []).map(vnode => {
                vnode.context = this._self
                return vnode
            });
            return h(WrapperComponent, {
            	on: this.$listeners,
                attrs: this.$attrs,
        		props: this.$props
            }, slotList)
        }
    }
}

而且scopeSlotslot的处理方式是不同的,所以将scopeSlot一起透传

export default function withComponent (WrapperComponent) {
	return {
        mounted () {
			      console.log('已经挂载完成')
        },
        props: WrappedComponent.props,
        render (h) {
            const keys = Object.keys(this.$slots);
            const slotList = keys.reduce((arr, key) => arr.concat(this.$slots[key]), []).map(vnode => {
                vnode.context = this._self
                return vnode
            });
            return h(WrapperComponent, {
                on: this.$listeners,
                attrs: this.$attrs,
                props: this.$props,
                scopedSlots: this.$scopedSlots
            }, slotList)
        }
    }
}

这样就行了。

结尾

更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。