探索Vue高阶组件

高阶组件(HOC)是 React 生态系统的经常使用词汇,React 中代码复用的主要方式就是使用高阶组件,而且这也是官方推荐的作法。而 Vue 中复用代码的主要方式是使用 mixins,而且在 Vue 中不多提到高阶组件的概念,这是由于在 Vue 中实现高阶组件并不像 React 中那样简单,缘由在于 ReactVue 的设计思想不一样,但并非说在 Vue 中就不能使用高阶组件,只不过在 Vue 中使用高阶组件所带来的收益相对于 mixins 并无质的变化。本篇文章主要从技术性的角度阐述 Vue 高阶组件的实现,且会从 ReactVue 二者的角度进行分析。html

从 React 提及

起初 React 也是使用 mixins 来完成代码复用的,好比为了不组件没必要要的重复渲染咱们能够在组件中混入 PureRenderMixinvue

const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
  mixins: [PureRenderMixin]
})
复制代码

后来 React 抛弃了这种方式,进而使用 shallowComparenode

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,他们认为 mixinsReact 生态系统中并非一种好的模式(注意:并无说 mixins 很差,仅仅针对 React 生态系统),观点以下:git

一、mixins 带来了隐式依赖 二、mixinsmixins 之间,mixins 与组件之间容易致使命名冲突 三、因为 mixins 是侵入式的,它改变了原组件,因此修改 mixins 等于修改原组件,随着需求的增加 mixins 将变得复杂,致使滚雪球的复杂性。github

具体你们能够查看这篇文章 Mixins Considered Harmful。不过 HOC 也并非银弹,它天然带来了它的问题,有兴趣的同窗能够查看这个视频:Michael Jackson - Never Write Another HoC,其观点是:使用普通组件配合 render prop 能够作任何 HOC 能作的事情数组

本篇文章不会过多讨论 mixinsHOC 谁好谁坏,就像技术自己就没有好坏之分,只有适合不适合。难道 ReactVue 这俩哥们儿不也是这样吗🙂。bash

ok,咱们回到高阶组件,所谓高阶组件其实就是高阶函数啦,ReactVue 都证实了一件事儿:一个函数就是一个组件。因此组件是函数这个命题成立了,那高阶组件很天然的就是高阶函数,即一个返回函数的函数,咱们知道在 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 中的高阶组件

了解了这些,接下来咱们就能够开始着手实现 Vue 高阶组件了,为了让你们有一个直观的感觉,我仍然会使用 ReactVue 进行对比的讲解。首先是一个基本的 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 组件主要观察三点:propsevent 以及 slots。对于 BaseComponent 组件而言,它接收一个数字类型的 propstest,并发射一个自定义事件,事件的名称是: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 的。其实 _cVue 内部就是 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 以后进入到了关键的一步,这一步就是致使高阶组件中透传 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 函数的不少步骤均可以进行封装。 处理更多高阶函数组件自己的选项(而不只仅是上面例子中的一个简单的生命周期钩子)

我以为须要放上两个关于高阶组件的参考连接,供参考交流:

Discussion: Best way to create a HOC github.com/jackmellis/…

为何在 Vue 中实现高阶组件比较难

前面说过要分析一下为何在 Vue 中实现高阶组件比较复杂而 React 比较简单。这主要是两者的设计思想和设计目标不一样,在 React 中写组件就是在写函数,函数拥有的功能组件都有。而 Vue 更像是高度封装的函数,在更高的层面 Vue 可以让你轻松的完成一些事情,但与高度的封装相对的就是损失必定的灵活,你须要按照必定规则才能使系统更好的运行。

有句话说的好:

会了不难,难了不会

复杂仍是简单都是相对而言的,最后但愿你们玩的转 Vue 也欣赏的了 React

原文: http://hcysun.me/2018/01/05/探索Vue高阶组件/

相关文章
相关标签/搜索