Skyphobia

Vue 组件封装指南(二)——高阶组件

Front-EndjsVue

从透明封装组件到高阶组件

From Transparent Wrapper component to High-Order Component

上次我们介绍了什么是透明封装组件,以及如何创建一个透明封装组件并把参数和事件的集合向子组件传递。不难看出,透明封装组件在这里起到了一个“代理”的作用,可以比较方便地对子组件进行隔离,以及基于子组件的功能扩展一个新的组件进行增强。

然后我们再回顾下透明封装组件的核心代码:

HTML
01<Child v-bind="$attrs" v-on="$listners" />

可以看出透明封装组件的侧重点是参数透传,把这个思想转化成函数大致是这个样子:

JavaScript
01f(props) => Child(props)

直觉上能够感觉到这样代码扩展性不算特别好,起码 Child 也要能作为参数接收才对嘛。于是我们把这个函数扩展成这样:

JavaScript
01f(Child, props) => Child(props)

OK,Child也 变成了参数。现在这个函数接收一个组件(和 props),返回了一个新的组件。这个形式是不是有“那味儿”了?这不就是上回我们一笔带过的“为什么 React 不需要透明封装组件”一章中提到的高阶组件(HOCHigher-Order Component嘛。

如何把 slots 传递给子组件

在开始把透明封装组件改造成 HOC 前,我们先填一下之前关于 slots 的坑:

先说下最简单的 Vue 2.6.x+ 的处理方式,this.$scopedSlots 包含了完整的 slots VNode 数组,可以直接作为 scopedSlots 的值传递下去。

JavaScript
01export default {
02 render (h) {
03 return h(Component, {
04 on: this.$listeners,
05 attrs: this.$attrs,
06 scopedSlots: this.$scopedSlots
07 })
08 }
09}

顺带一提,Vue 2.6.x 的 this.$attrs 也已经包含了原来的 this.$props,不再需要额外传递 props 的值给子组件了。

然后扩展阅读一下考虑场景比较复杂的 Vue 2.5.x- 的场景(没兴趣可以跳过这段):

默认的 slot 和所有具名的静态 slots 都可以通过 this.$slots 访问到。this.$slots 是一个对象,对象的 key 是具名 slots 的命名,默认 slot 的命名为 default;对象的 valueVNode 数组。在传递过程中需要把这个对象展开成一个 VNode 数组:

JavaScript
01export default {
02 render (h) {
03 const slots = Object.keys(this.$slots)
04 .reduce((arr, key) => arr.concat(this.$slots[key]), [])
05 .map(vnode => {
06 // IMPORTANT: VNode 的上下文必须绑定为当前的组件的上下文,
07 // 因为插槽运行时的上下文实际还是在父组件
08 vnode.context = this._self
09 return vnode
10 })
11
12 return h(Component, {
13 ...renderOptions,
14 scopedSlots: this.$scopedSlots
15 }, slots)
16 }
17}

创建 HOC

尝试把之前的透明封装组件改造成参数为组件对象,返回值为新的组件对象的函数:

JavaScript
01export default (Component) => {
02 return {
03 render (h) {
04 return h(Component)
05 }
06 }
07}

为了灵活性我们可能需要 HOC 能够接收并处理一些参数:

JavaScript
01export default (Component, options) => {
02 // TODO: options 待处理
03 return {
04 render (h) {
05 return h(Component)
06 }
07 }
08}

边界处理

React 中的组件通常为 ClassFunction,Vue 中主要为 Object,偶尔会有 Function(Vue extend 等场景)的情况,所以在合并参数的场景下需要做一些额外的判断:

JavaScript
01export default function createHOC (Component, options) {
02 const { props = {}, listeners = {} } = Component?.options || Component || {}
03
04 const hoc = {
05 props,
06
07 attrs: {
08 ...this.$props,
09 ...this.$attrs
10 },
11
12 on: {
13 ...this.$listeners,
14 ...listeners
15 },
16
17 render (h) {
18 const slots = Object.keys(this.$slots)
19 .reduce((arr, key) => arr.concat(this.$slots[key]), [])
20 .map(vnode => {
21 vnode.context = this._self
22 return vnode
23 })
24
25 return h(Component, {
26 ...renderOptions,
27 scopedSlots: this.$scopedSlots
28 }, slots)
29 }
30 }
31
32 return hoc
33}

合并 options

options 参数可以在 createHOC 函数中按需处理,只是碰到 mixin 时会有一些问题,原则上我们不推荐 mixin 和 HOC 混用,因为这违反了单一职责的原则。mixin 本身也会造成隐式依赖和命名冲突等问题。如果一定要在 HOC 中合并 mixin 的话可以参考以下代码:

JavaScript
01if (!options.mixins) options.mixins = []
02options.mixins.push(hoc)

使用 HOC

HTML
01<template>
02 <HOCForm>
03 <input />
04 </HOCForm>
05</template>
06
07<script>
08import Form from 'input.vue'
09import createHOC from 'createHOC'
10
11export default {
12 components: {
13 createHOC(Form, {
14 name: 'HOCForm'
15 })
16 }
17}
18</script>

HOC 的问题

HOC 的优势在于可以很方便地复用组件并扩展功能,而实际业务中复用组件的方式中还存在使用 mixins 的解决方案。两者各自都存在一些问题,导致没有一种方案是最为完美的——HOC 创建了更多的层级,使得数据传递的追踪变得复杂;mixin 带来的问题则是灾难性的,除了前述的隐式依赖和命名冲突,还可能因其创建的组件不具有层次结构,导致很难理解其数据流,以及进行依赖的追踪。

同样的问题在 React 中也存在,所以 React 提出了 Hooks API,允许 Hooks 在不改变组件层次结构的情况下重用声明的逻辑。在 Vue 中与之对应的就是 Composition API