HOC(高阶组件)在vue中的应用

5,374 阅读9分钟

高阶组件(HOC)是 React 生态系统的常用词汇,React 中代码复用的主要方式就是使用高阶组件,并且这也是官方推荐的做法。而在 Vue 中,官方给出的组件复用方式则是 mixin 。本文将对常用的组件复用方式( mixin 和 HOC )进行对比和实践。

组件复用场景

什么时候会需要组件复用呢?实际场景:

有一个使用了vue-router和vuex的单面应用。在N个(下面以两个为例子)独立页面功能完成后,需要增加权限控制的功能。有的页面需要特定的用户权限才能进入,否则如果强行输入url进入的话,会提示“没有权限访问本页面”。

场景介绍

下面是没有权限控制时,系统主要的几个代码文件:

页面入口main.js

import Vue from 'vue';
import store from './store';
import router from './routes';
import App from './app';

new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
});

页面根组件app.vue

<template>
  <div id="app">
    <div class="app-page" v-if="user.userLoaded">
      <div class="app-page-cnt">
        <router-view></router-view>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  name: 'app',
  data() {
    return {};
  },
  methods: {
    // vuex中的action,会从接口请求包含权限的用户信息,并保存到store中的user字段
    // store中具体的代码因为比较简单,并且在这里不重要,所以就不展示了,可以自己脑补
    ...mapActions(['getUserDetail']),
  },
  computed: {
    // store中的user信息,在未从接口获取返回之前,为{ userLoaded: false }
    ...(mapState(['user'])),
  },
  created() {
    this.getUserDetail();
  }
};
</script>

路由配置routes.js

import Router from 'vue-router';

// 以下是组件异步加载的写法, 功能上等同于直接import
const Page1 = resolve => require(['./page1'], resolve);
const Page2 = resolve => require(['./page2'], resolve);

export default {
  routes: [
    { path: '/page1', component: Page1 },
    { path: '/page2', component: Page2 },
  ]
}

页面组件: page1.vue, page2.vue

/**************** page1.vue ****************/
export default {
  template: `<div>欢迎访问传说中的 page1 !</div>`
}

/**************** page2.vue ****************/
export default {
  template: `<div>欢迎访问传说中的 page2 !</div>`
}

不考虑复用

在当前场景下,要对page1和page2两个页面添加权限控制,不考虑复用时可以这么粗暴地在page1.vue和page2.vue上进行如下改造来实现。

页面组件: page1.vue, page2.vue

/**************** page1.vue ****************/
export default {
  template: `
    <div v-if="hasRight">欢迎访问传说中的 page1 !</div>
    <div v-else>不好意思,由于不够帅,你没有权限访问本页面</div>
  `,
  computed: {
    hasRight() { // 判断用户是否有权限进入本页面的计算属性
      // 这里的user是之前在app中通过接口返回注入store的用户信息
      const { rightList } = this.$store.state.user;
      return rightList.indexOf('RIGHT_PAGE_1');
    }
  }
}

/**************** page2.vue ****************/
export default {
  template: `
    <div v-if="hasRight">欢迎访问传说中的 page2 !</div>
    <div v-else>不好意思,由于不够帅,你没有权限访问本页面</div>
  `,
  computed: {
    hasRight() { // 判断用户是否有权限进入本页面的计算属性
      // 这里的user是之前在app中通过接口返回注入store的用户信息
      const { rightList } = this.$store.state.user;
      return rightList.indexOf('RIGHT_PAGE_2');
    }
  }
}

以上的方式,在只有两个页面的时候,可能不觉得麻烦。如果页面多了之后,就十分难维护了,同时会有大量的重复代码。

使用mixin实现复用

在使用mixin前,先把那个分散在各种页面组件中的无权限提示,提取到单独的组件中以便复用。

提取出错误提示组件: no-right-tips.vue

export default {
  template: `<div>不好意思,由于不够帅,你没有权限访问本页面</div>`,
  name: 'no-right-tips'
}

创建一个用于权限控制的mixin,目标是使页面组件(page1,page2)不用关心权限校验是如何运行的。在这个例子中,只需要把hasRight这个计算属性提取到mixin中。

right-mixin.js

export default {
  computed: {
    hasRight() {
      const { rightList } = this.$store.state.user;
      return rightList.indexOf('RIGHT_PAGE_?'); // 注意这里,无法确定各个页面的权限标志
    }
  }
}

注意看上面代码的最后一行注释。我们希望把权限验证放到mixin中,但问题是不同页面所需要的权限是不一样的啊,无法将RIGHT_PAGE_1之类的具体权限写死在mixin中。怎么办呢?用函数包一层:

right-mixin.js

export default (rightType) => ({ // rightType作为参数传入,返回特定mixin
  computed: {
    hasRight() {
      const { rightList } = this.$store.state.user;
      return rightList.indexOf(rightType); // 问题解决
    }
  }
})

上面的所说的用函数包一层,听起来好low是吧?我们来给这种方式起个高逼格一点的名字吧,我们称上面的方式为高阶mixin。是不是瞬间听起来不一样了?

这个名字听起来是不是和我们后面要讲的高阶组件如出一辙?

我们先不纠结名字了,看看我们上面的方式如何在页面组件中使用吧:

page1.vue, page2.vue

/**************** page1.vue ****************/
import NoRightTips from './no-right-tips';
import rightmixin from './right-mixin';

export default {
  mixin: [rightmixin('RIGHT_PAGE_1')],
  template: `
    <div v-if="hasRight">欢迎访问传说中的 page1 !</div>
    <no-right-tips v-else></no-right-tips>
  `,
  components: {
    NoRightTips
  }
}

/**************** page2.vue ****************/
import NoRightTips from './no-right-tips';
import rightmixin from './right-mixin';

export default {
  mixin: [rightmixin('RIGHT_PAGE_2')],
  template: `
    <div v-if="hasRight">欢迎访问传说中的 page2 !</div>
    <no-right-tips v-else></no-right-tips>
  `,
  components: {
    NoRightTips
  }
}

经过mixin改造和错误提示的组件提取之后,代码看起来复用度提高了,职责也分明了。现在页面组件不用关心权限是怎么检验的,只用管从mixin提供的computed属性中判断检验结果,并在没有权限时直接展示公共的错误提示组件。感觉不错!

使用高阶组件(HOC)实现复用

铺垫了这么多,终于要进入主题了!在使用高阶组件之前,先简单描述一下它。我们上面起了一个高逼格的名字: 高阶mixin,用来表示被函数包了一层的普通mixin。是有一定依据的。

维基百科对于高阶函数的定义(点击查看):

在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

React中HOC的定义(点击查看):

a higher-order component is a function that takes a component and returns a new component 高阶组件是一个方法,这个方法接收一个原始组件作为参数,并返回新的组件。

根据上面的定义,我们可以引申为:通过函数向现有XXX添加功能,就是高阶XXX。在上面mixin的例子中,通用函数,给普通mixin提供了可配置的权限检测参数,所以可称之为高阶mixin。

大家可能觉得奇怪,为什么我要用react官方里的定义来说明高阶组件呢?是因为高阶组件最开始就是在react中提出来的。下边我们看一下,如何在vue中使用高阶组件来实现上面场景中的组件复用功能。

我们创建如下高阶组件: right-hoc.js

import NoRightTips from './no-right-tips';

export default (Comp, rightType) => ({
  components: {
    Comp,
    NoRightTips,
  },
  computed: {
    hasRight() {
      const { rightList } = this.$store.state.user;
      return rightList.indexOf(rightType);
    }
  },
  render(h) {
    return this.hasRight ? h(Comp, {}) : h(NoRightTips, {});
  }
})

接下来去掉页面组件中已经提取到高阶组件中的部分逻辑: page1.vue, page2.vue

/**************** page1.vue ****************/
export default {
  template: `<div>欢迎访问传说中的 page1 !</div>`,
}

/**************** page2.vue ****************/
export default {
  template: `<div>欢迎访问传说中的 page2 !</div>`,
}

所有的权限相关代码都抽出来了!组件回归到的之前没有权限功能时的样子。那hoc在哪里与组件结合起来呢?答案是在routes里,在使用组件的地方:

路由配置: routes.js

import Router from 'vue-router';
import rightHoc from './right-hoc';

// 以下是组件异步加载的写法, 功能上等同于直接import
const Page1 = resolve => require(['./page1'], resolve);
const Page2 = resolve => require(['./page2'], resolve);

export default {
  routes: [
    { path: '/page1', component: rightHoc(Page1, 'RIGHT_PAGE_1') },
    { path: '/page2', component: rightHoc(Page2, 'RIGHT_PAGE_2') },
  ]
}

使用高阶组件同样实现了组件复用。而且看起来似乎更优雅?我们来对比一下高阶组件和mixin两种方式,在以上场景中的区别:

HOC

  • 增加了一个hoc文件, hoc文件中引入no-right-tips
  • 路由配置中,使用页面组件的地方引入并使用了hoc

mixin

  • 增加了一个mixin文件
  • 每个组件代码中,引入mixin、no-right-tips, 并且增加相应的模板逻辑(v-if)

在本文的场景中,使用HOC相比使用mixin有以下优势:

  • 减少对原始组件的入侵,降低耦合。HOC中,原始组件只用考虑自身逻辑,不用考虑,也感知不到HOC对它做了什么。而mixin,组件在内部需要使用mixin的计算属性(更复杂的mixin还会用到生命周期和methods方法)。
  • 权限控制方便集中管理,直接在routes配置中管理各个页面配置,而不是分散在各个页面组件内部。
  • 避免命名冲突。如果页面自己有自己内部的权限控制,刚好也有个computed属性叫hasRight呢?在HOC下没问题,但mixin就不行了。

React中的HOC现状

其实最早在React中,也是使用mixin来实现组件功能复用的,但从v0.13.0开始,React的ES6 class组件写法中就不支持mixin了。这应该算是比较大的特性调整了。在此之后,已经使用了React的项目,可以继续使用React.createClass定义组件的方式来继续使用mixin,如果要使用ES6 class并且实现同样的组件复用,就必须使用HOC了。

React为什么做了这个决定呢?人家不是没事搞事情,而是有原因的。官方博客专门发文列举mixin可能带来的一些问题: mixin Considered Harmful。这篇文章里结合实际例子列举了mixin在React中可能带来的几个问题,并且给出了mixin迁移到HOC的一些指导。这里简单地把文章里提到的mixin可能带来的几个问题列举一下:

1、mixin会导致依赖不明确

  • mixin会调用组件内部方法/数据,组件会调用mixin方法/数据, 无法保证双方方法稳定存在。
  • 多个mixin同时作用时,依赖关系对于被mixin的组件来说会更困惑。

2、mixin会导致命名冲突

  • 多个mixin和组件本身,方法名称会有命名冲突风险,如果遇到了,不得不重命名某些方法。

3、mixin会带来滚雪球般的复杂度

  • 原文中列举了一个复杂的mixin例子。

也就是说,现在React体系中mixin已经不推荐使用,而推荐使用HOC。下图是mixin和HOC的对比:

Vue中的HOC现状

相比于React,Vue目前还是使用mixin作为官方的组件复用方式。在探索Vue中HOC的时候,发现很少有相关描述和实践和文章。在百度里搜不出来,在google里也只能搜出寥寥几个。在我找到的资源中,有一个vuejs的github issue十分有代表价值:Discussion: Best way to create a HOC

本文的例子简单地实现了HOC,但是实际的场景可能更复杂,涉及属性传递,slots等问题。而上面的issue就是在讨论这个问题。目前这个issue已经关闭,结论有两个:

  • 暂时由热心人士产出了一个npm包: vue-hoc来帮助Vue方便地实现HOC。
  • 官方暂时不考虑将HOC加入vue core中,因为觉得相比于mixin的优势不够巨大。