高阶组件(HOC
)是 React
生态系统的经常使用词汇,React
中代码复用的主要方式就是使用高阶组件,而且这也是官方推荐的作法。而 Vue
中复用代码的主要方式是使用 mixins
,而且在 Vue
中不多提到高阶组件的概念,这是由于在 Vue
中实现高阶组件并不像 React
中那样简单,缘由在于 React
和 Vue
的设计思想不一样,但并非说在 Vue
中就不能使用高阶组件,只不过在 Vue
中使用高阶组件所带来的收益相对于 mixins
并无质的变化。本篇文章主要从技术性的角度阐述 Vue
高阶组件的实现,且会从 React
与 Vue
二者的角度进行分析。html
起初 React
也是使用 mixins
来完成代码复用的,好比为了不组件没必要要的重复渲染咱们能够在组件中混入 PureRenderMixin
:vue
const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
mixins: [PureRenderMixin]
})
复制代码
后来 React
抛弃了这种方式,进而使用 shallowCompare
:node
const shallowCompare = require('react-addons-shallow-compare')
const Button = React.createClass({
shouldComponentUpdate: function(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
}
})
复制代码
这须要你本身在组件中实现 shouldComponentUpdate
方法,只不过这个方法具体的工做由 shallowCompare
帮你完成,即浅比较。react
再后来 React
为了不开发者在组件中老是要写这样一段一样的代码,进而推荐使用 React.PureComponent
,总之 React
在一步步的脱离 mixins
,他们认为 mixins
在 React
生态系统中并非一种好的模式(注意:并无说 mixins
很差,仅仅针对 React
生态系统),观点以下:git
一、
mixins
带来了隐式依赖 二、mixins
与mixins
之间,mixins
与组件之间容易致使命名冲突 三、因为mixins
是侵入式的,它改变了原组件,因此修改mixins
等于修改原组件,随着需求的增加mixins
将变得复杂,致使滚雪球的复杂性。github
具体你们能够查看这篇文章 Mixins Considered Harmful。不过 HOC
也并非银弹,它天然带来了它的问题,有兴趣的同窗能够查看这个视频:Michael Jackson - Never Write Another HoC,其观点是:使用普通组件配合 render prop 能够作任何 HOC 能作的事情。数组
本篇文章不会过多讨论 mixins
和 HOC
谁好谁坏,就像技术自己就没有好坏之分,只有适合不适合。难道 React
和 Vue
这俩哥们儿不也是这样吗🙂。bash
ok
,咱们回到高阶组件,所谓高阶组件其实就是高阶函数啦,React
和 Vue
都证实了一件事儿:一个函数就是一个组件。因此组件是函数这个命题成立了,那高阶组件很天然的就是高阶函数,即一个返回函数的函数,咱们知道在 React
中写高阶组件就是在写高阶函数,很简单,那是否是在 Vue
中实现高阶组件也一样简单呢?其实 Vue
稍微复杂,甚至须要你对 Vue
足够了解,接下来就让咱们一块在 Vue
中实现高阶组件,在文章的后面会分析为何一样都是 函数就是组件
的思想,Vue
却不能像 React
那样轻松的实现高阶组件。并发
也正因如此因此咱们有必要在实现 Vue
高阶组件以前充分了解 React
中的高阶组件,看下面的 React
代码:app
function WithConsole (WrappedComponent) {
return class extends React.Component {
componentDidMount () {
console.log('with console: componentDidMount')
}
render () {
return <WrappedComponent {...this.props}/>
}
}
}
复制代码
WithConsole
就是一个高阶组件,它有如下几个特色:
一、高阶组件(
HOC
)应该是无反作用的纯函数,且不该该修改原组件
能够看到 WithConsole
就是一个纯函数,它接收一个组件做为参数并返回了一个新的组件,在新组件的 render
函数中仅仅渲染了被包装的组件(WrappedComponent
),并无侵入式的修改它。
二、高阶组件(
HOC
)不关心你传递的数据(props
)是什么,而且被包装组件(WrappedComponent
)不关心数据来源
这是保证高阶组件与被包装组件可以完美配合的根本
三、高阶组件(
HOC
)接收到的props
应该透传给被包装组件(WrappedComponent
)
高阶组件彻底能够添加、删除、修改 props
,可是除此以外,要将其他 props
透传,不然在层级较深的嵌套关系中(这是高阶组件的常见问题
)将形成 props
阻塞。
以上是 React
中高阶组件的基本约定,除此以外还要注意其余问题,如:高阶组件(HOC
)不该该在 render
函数中建立;高阶组件(HOC
)也须要复制组件中的静态方法;高阶组件(HOC
)中的 ref
引用的是最外层的容器组件而不是被包装组件(WrappedComponent
) 等等。
了解了这些,接下来咱们就能够开始着手实现 Vue
高阶组件了,为了让你们有一个直观的感觉,我仍然会使用 React
与 Vue
进行对比的讲解。首先是一个基本的 Vue
组件,咱们常称其为被包装组件(WrappedComponent
),假设咱们的组件叫作 BaseComponent
:
base-component.vue
<template>
<div>
<span @click="handleClick">props: {{test}}</span>
</div>
</template>
<script>
export default {
name: 'BaseComponent',
props: {
test: Number
},
methods: {
handleClick () {
this.$emit('customize-click')
}
}
}
</script>
复制代码
咱们观察一个 Vue
组件主要观察三点:props
、event
以及 slots
。对于 BaseComponent
组件而言,它接收一个数字类型的 props
即 test
,并发射一个自定义事件,事件的名称是:customize-click
,没有 slots
。咱们会这样使用该组件:
<base-component @customize-click="handleCustClick" :test="100" />
复制代码
如今咱们须要 base-component
组件每次挂载完成的时候都打印一句话:I have already mounted
,同时这也许是不少组件的需求,因此按照 mixins
的方式,咱们能够这样作,首先定义个 mixins
:
export default consoleMixin {
mounted () {
console.log('I have already mounted')
}
}
复制代码
而后在 BaseComponent
组件中将 consoleMixin
混入:
export default {
name: 'BaseComponent',
props: {
test: Number
},
mixins: [ consoleMixin ]
methods: {
handleClick () {
this.$emit('customize-click')
}
}
}
复制代码
这样使用 BaseComponent
组件的时候,每次挂载完成以后都会打印一句 I have already mounted
,不过如今咱们要使用高阶组件的方式实现一样的功能,回忆高阶组件的定义:接收一个组件做为参数,返回一个新的组件,那么此时咱们须要思考的是,在 Vue
中组件是什么?有的同窗可能会有疑问,难道不是函数吗?对,Vue
中组件是函数没有问题,不过那是最终结果,好比咱们在单文件组件中的组件定义其实就是一个普通的选项对象,以下:
export default {
name: 'BaseComponent',
props: {...},
mixins: [...]
methods: {...}
}
复制代码
这不就是一个纯对象吗?因此当咱们从单文件中导入一个组件的时候:
import BaseComponent from './base-component.vue'
console.log(BaseComponent)
复制代码
思考一下,这里的 BaseComponent
是什么?它是函数吗?不是,虽然单文件组件会被 vue-loader
处理,但处理后的结果,也就是咱们这里的 BaseComponent
仍然仍是一个普通的 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
:
'<wrapped v-on="$listeners" v-bind="$attrs"/>'
复制代码
这么作是必须的,这就等价于在 React
中透传 props
:
<WrappedComponent {...this.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
还不行。咱们修改 BaseComponent
组件为其添加一个具名插槽和默认插槽,以下:
base-component.vue
<template>
<div>
<span @click="handleClick">props: {{test}}</span>
<slot name="slot1"/> <!-- 具名插槽 -->
<p>===========</p>
<slot/> <!-- 默认插槽 -->
</div>
</template>
<script>
export default {
...
}
</script>
复制代码
而后咱们写下以下测试代码:
<template>
<div>
<base-component>
<h2 slot="slot1">BaseComponent slot</h2>
<p>default slot</p>
</base-component>
<enhanced-com>
<h2 slot="slot1">EnhancedComponent slot</h2>
<p>default slot</p>
</enhanced-com>
</div>
</template>
<script>
import BaseComponent from './base-component.vue'
import hoc from './hoc.js'
const EnhancedCom = hoc(BaseComponent)
export default {
components: {
BaseComponent,
EnhancedCom
}
}
</script>
复制代码
上图中蓝色框是 BaseComponent
组件渲染的内容,是正常的。红色框是高阶组件渲染的内容,能够发现不管是具名插槽仍是默认插槽所有丢失。其缘由很简单,就是由于咱们在高阶组件中没有将分发的插槽内容透传给被包装组件(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")
])
])
}
复制代码
想要查看一个组件的模板被编译后的渲染函数很简单,只须要在访问 this.$options.render
便可。观察上面的渲染函数咱们发现普通的 DOM
是经过 _c
函数建立对应的 VNode
的。如今咱们修改模板,模板中除了有普通 DOM
以外,还有组件,以下:
<div>
<base-component>
<h2 slot="slot1">BaseComponent slot</h2>
<p>default slot</p>
</base-component>
</div>
复制代码
那么生成的渲染函数(render
)是这样的:
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
[
_c("base-component", [
_c("h2", { attrs: { slot: "slot1" }, slot: "slot1" }, [
_vm._v("BaseComponent slot")
]),
_vm._v(" "),
_c("p", [_vm._v("default slot")])
])
],
1
)
}
复制代码
咱们发现不管是普通DOM仍是组件,都是经过 _c
函数建立其对应的 VNode
的。其实 _c
在 Vue
内部就是 createElement
函数。createElement
函数会自动检测第一个参数是否是普通DOM标签,若是不是普通DOM标签那么 createElement
会将其视为组件,而且建立组件实例,注意组件实例是这个时候才建立的。可是建立组件实例的过程当中就面临一个问题:组件须要知道父级模板中是否传递了 slot 以及传递了多少,传递的是具名的仍是不具名的等等。那么子组件如何才能得知这些信息呢?很简单,假如组件的模板以下:
<div>
<base-component>
<h2 slot="slot1">BaseComponent slot</h2>
<p>default slot</p>
</base-component>
</div>
复制代码
父组件的模板最终会生成父组件对应的 VNode
,因此以上模板对应的 VNode
所有由父组件全部,那么在建立子组件实例的时候可否经过获取父组件的 VNode
进而拿到 slot
的内容呢?即经过父组件将下面这段模板对应的 VNode
拿到:
<base-component>
<h2 slot="slot1">BaseComponent slot</h2>
<p>default slot</p>
</base-component>
复制代码
若是可以经过父级拿到这段模板对应的 VNode
,那么子组件就知道要渲染哪些 slot
了,其实 Vue
内部就是这么干的,实际上你能够经过访问子组件的 this.$vnode
来获取这段模板对应的 VNode
:
其中 this.$vnode
并无写进 Vue
的官方文档。子组件拿到了须要渲染的 slot
以后进入到了关键的一步,这一步就是致使高阶组件中透传 slot
给 BaseComponent
却没法正确渲染的缘由。
这张图与上一张图相同,在子组件中打印 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
中的 VNode
的 context
引用的仍是原来的父组件实例,因此这就形成了如下表达式为假:
console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // false
复制代码
最终致使具名插槽被做为默认插槽,从而渲染不正确。
而解决办法也很简单,只须要手动设置一下 slot
中 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]), [])
// 手动更正 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
,不过 scopedSlots
与 slot
的实现机制不同,本质上 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
函数的不少步骤均可以进行封装。 处理更多高阶函数组件自己的选项(而不只仅是上面例子中的一个简单的生命周期钩子
)
我以为须要放上两个关于高阶组件的参考连接,供参考交流:
Discussion: Best way to create a HOC github.com/jackmellis/…
前面说过要分析一下为何在 Vue
中实现高阶组件比较复杂而 React
比较简单。这主要是两者的设计思想和设计目标不一样,在 React
中写组件就是在写函数,函数拥有的功能组件都有。而 Vue
更像是高度封装的函数,在更高的层面 Vue
可以让你轻松的完成一些事情,但与高度的封装相对的就是损失必定的灵活,你须要按照必定规则才能使系统更好的运行。
有句话说的好:
会了不难,难了不会
复杂仍是简单都是相对而言的,最后但愿你们玩的转 Vue
也欣赏的了 React
。
原文: http://hcysun.me/2018/01/05/探索Vue高阶组件/