Vue使用HOC模式 在Vue中使用HOC模式的实现
zhangwinwin 人气:0前言
HOC是React常用的一种模式,但HOC只能是在React才能玩吗?先来看看React官方文档是怎么介绍HOC的:
高阶组件(HOC)是React中用于复用组件逻辑的一种高级技巧。HOC自身不是ReactAPI的一部分,它是一种基于React的组合特性而形成的设计模式。
HOC它是一个模式,是一种思想,并不是只能在React中才能用。所以结合Vue的特性,一样能在Vue中玩HOC。
HOC
HOC要解决的问题
并不是说哪种技术新颖,就得使用哪一种。得看这种技术能够解决哪些痛点。
HOC主要解决的是可复用性的问题。在Vue中,这种问题一般是用Mixin解决的。Mixin是一种通过扩展收集功能的方式,它本质上是将一个对象的属性拷贝到另一个对象上去。
最初React也是使用Mixin的,但是后面发现Mixin在React中并不是一种好的模式,它有以下的缺点:
- mixin与组件之间容易导致命名冲突
- mixin是侵入式的,改变了原组件,复杂性大大提高。
所以React就慢慢的脱离了mixin,从而推荐使用HOC。并不是mixin不优秀,只是mixin不适合React。
HOC是什么
HOC全称:high-order component--也就是高阶组件。具体而言,高阶组件是参数为组件,返回值为新组件的函数。
而在React和Vue中组件就是函数,所以的高阶组件其实就是高阶函数,也就是返回一个函数的函数。
来看看HOC在React的用法:
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.context。renderContext就是父组件要渲染的实例。 然后把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中的VNode的context引用的还是原来的父组件实例,所以就导致不成立。
从而都被处理为默认插槽。
解决方法也很简单,只需手动的将slot中的vnode的context指向为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) } } }
而且scopeSlot与slot的处理方式是不同的,所以将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,对作者也是一种鼓励。
加载全部内容