高阶组件这个概念在 React 中一度非常流行,但是在 Vue 的社区里讨论的不多,本篇文章就真正的带你来玩一个进阶的骚操作。
先和大家说好,本篇文章的核心是学会这样的思想,也就是 容器 和 木偶 组件的解耦合,这可以有很多方式,比如 slot-scopes,比如未来的composition-api。本篇所写的代码也不推荐用到生产环境,生产环境有更成熟的库去使用,这篇强调的是 思想,顺便把 React 社区的玩法移植过来皮一下。
不要喷我,不要喷我,不要喷我,此篇只为演示高阶组件的思路,如果实际业务中想要简化文中所提到的异步状态管理,请使用基于 slot-scopes 的开源库 vue-promised
另外标题中提到的 20k 其实有点标题党,我更多的想表达的是我们要有这样的精神,只会这一个技巧肯定不能让你达到 20k。但我相信只要大家有这样钻研高级用法,不断优化业务代码,不断提效的的精神,我们总会达到的,而且这一天不会很远。
例子
本文就以平常开发中最常见的需求,也就是异步数据的请求为例,先来个普通玩家的写法:
`
    
    
    
  
`
一般我们都这样写,平常也没感觉有啥问题,但是其实我们每次在写异步请求的时候都要有 loading、 error 状态,都需要有 取数据 的逻辑,并且要管理这些状态。
那么想个办法抽象它?好像特别好的办法也不多,React 社区在 Hook 流行之前,经常用 HOC(high order component) 也就是高阶组件来处理这样的抽象。
高阶组件是什么?
说到这里,我们就要思考一下高阶组件到底是什么概念,其实说到底,高阶组件就是:
一个函数接受一个组件为参数,返回一个包装后的组件。
在 React 中
在 React 里,组件是 Class,所以高阶组件有时候会用 装饰器 语法来实现,因为 装饰器 的本质也是接受一个 Class 返回一个新的 Class。
在 React 的世界里,高阶组件就是 f(Class) -> 新的Class。
在 Vue 中
在 Vue 的世界里,组件是一个对象,所以高阶组件就是一个函数接受一个对象,返回一个新的包装好的对象。
类比到 Vue 的世界里,高阶组件就是 f(object) -> 新的object。
实现
有了这个思路,我们就开始尝试实现。
首先上文提到了,HOC 是个函数,本次我们的需求是实现请求管理的 HOC,那么先定义它接受两个参数
- wrapped也就是需要被包裹的组件对象。
- promiseFunc也就是请求对应的函数,需要返回一个 Promise
并且 loading、error 等状态,还有 加载中、加载错误 等对应的视图,我们都要在 新返回的包装组件 中定义好。
const withPromise = (wrapped, promiseFn) => {     return {       name: "with-promise",       data() {         return {           loading: false,           error: false,           result: null,         };       },       async mounted() {         this.loading = true;         const result = await promiseFn().finally(() => {           this.loading = false;         });         this.result = result;       },     };   };   
看起来不错了,但是函数里我们好像不能像在 .vue 单文件里去书写 template 那样书写模板了,
但是我们又知道模板最终还是被编译成组件对象上的 render 函数,那我们就直接写这个 render 函数。(注意,本例子是因为便于演示才使用的原始语法,脚手架创建的项目可以直接用 jsx 语法。)
const withPromise = (wrapped, promiseFn) => {     return {       data() { ... },       async mounted() { ... },       render(h) {         return h(wrapped, {           props: {             result: this.result,             loading: this.loading,           },         });       },     };   };   
到了这一步,已经是一个勉强可用的雏形了,我们来声明一下 木偶 组件。
const view = {     template: `       <span>         <span>{{result?.name}}</span>       </span>     `,     props: ["result", "loading"],   };   
注意这里的组件就可以是任意 .vue 文件了,我这里只是为了简化而采用这种写法。
然后用神奇的事情发生了,别眨眼,我们用 withPromise 包裹这个 view 组件。
`// 假装这是一个 axios 请求函数
const request = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: "ssh" });
    }, 1000);
  });
};  
const hoc = withPromise(view, request)
`
然后在父组件中渲染它:
`
`
此时,组件在空白了一秒后,渲染出了我的大名 ssh,整个异步数据流就跑通了。
现在在加上 加载中 和 加载失败 视图,让交互更友好点。
`const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    async mounted() { ... },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
        },
      };  
      const wrapper = h("div", [
        h(wrapped, args),
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
      ]);  
      return wrapper;
    },
  };
};  
`
到此为止的代码可以在 效果预览 里查看,控制台的 source 里也可以直接预览源代码。
完善
到此为止的高阶组件虽然可以演示,但是并不是完整的,它还缺少一些功能,比如
- 要拿到子组件上定义的参数,作为初始化发送请求的参数。 
- 要监听子组件中请求参数的变化,并且重新发送请求。 
- 外部组件传递给 - hoc组件的参数现在没有透传下去。
第一点很好理解,我们请求的场景的参数是很灵活的。
第二点也是实际场景中常见的一个需求。
第三点为了避免有的同学不理解,这里再啰嗦下,比如我们在最外层使用 hoc 组件的时候,可能希望传递一些 额外的props 或者 attrs 甚至是 插槽slot 给最内层的 木偶 组件。那么 hoc 组件作为桥梁,就要承担起将它透传下去的责任。
为了实现第一点,我们约定好 view 组件上需要挂载某个特定 key 的字段作为请求参数,比如这里我们约定它叫做 requestParams。
const view = {     template: `       <span>         <span>{{result?.name}}</span>       </span>     `,     data() {       // 发送请求的时候要带上它       requestParams: {         name: 'ssh'       }       },     props: ["result", "loading"],   };   
改写下我们的 request 函数,让它为接受参数做好准备,
并且让它的 响应数据 原样返回 请求参数。
// 假装这是一个 axios 请求函数   const request = (params) => {     return new Promise((resolve) => {       setTimeout(() => {         resolve(params);       }, 1000);     });   };   
那么问题现在就在于我们如何在 hoc 组件中拿到 view 组件的值了,
平常我们怎么拿子组件实例的?没错就是 ref,这里也用它:
`const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    async mounted() {
      this.loading = true;
      // 从子组件实例里拿到数据
      const { requestParams } = this.$refs.wrapped
      // 传递给请求函数
      const result = await promiseFn(requestParams).finally(() => {
        this.loading = false;
      });
      this.result = result;
    },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
        },
        // 这里传个 ref,就能拿到子组件实例了,和平常模板中的用法一样。
        ref: 'wrapped'
      };  
      const wrapper = h("div", [
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
        h(wrapped, args),
      ]);  
      return wrapper;
    },
  };
};
`
再来完成第二点,子组件的请求参数发生变化时,父组件也要响应式的重新发送请求,并且把新数据带给子组件。
const withPromise = (wrapped, promiseFn) => {     return {       data() { ... },       methods: {         // 请求抽象成方法         async request() {           this.loading = true;           // 从子组件实例里拿到数据           const { requestParams } = this.$refs.wrapped;           // 传递给请求函数           const result = await promiseFn(requestParams).finally(() => {             this.loading = false;           });           this.result = result;         },       },       async mounted() {         // 立刻发送请求,并且监听参数变化重新请求         this.$refs.wrapped.$watch("requestParams", this.request.bind(this), {           immediate: true,         });       },       render(h) { ... },     };   };   
第二个问题,我们只要在渲染子组件的时候把 $attrs、$listeners、$scopedSlots 传递下去即可,
此处的 $attrs 就是外部模板上声明的属性,$listeners 就是外部模板上声明的监听函数,
以这个例子来说:
<my-input value="ssh" @change="onChange" />   
组件内部就能拿到这样的结构:
{     $attrs: {       value: 'ssh'     },     $listeners: {       change: onChange     }   }   
注意,传递 $attrs、$listeners 的需求不仅发生在高阶组件中,平常我们假如要对 el-input 这种组件封装一层变成 my-input 的话,如果要一个个声明 el-input 接受的 props,那得累死,直接透传 $attrs 、$listeners 即可,这样 el-input 内部还是可以照样处理传进去的所有参数。
// my-input 内部   <template>     <el-input v-bind="$attrs" v-on="$listeners" />   </template>   
那么在 render 函数中,可以这样透传:
`const withPromise = (wrapped, promiseFn) => {
  return {
    ...,
    render(h) {
      const args = {
        props: {
          // 混入 $attrs
          ...this.$attrs,
          result: this.result,
          loading: this.loading,
        },  
        // 传递事件
        on: this.$listeners,  
        // 传递 $scopedSlots
        scopedSlots: this.$scopedSlots,
        ref: "wrapped",
      };  
      const wrapper = h("div", [
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
        h(wrapped, args),
      ]);  
      return wrapper;
    },
  };
};
`
至此为止,完整的代码也就实现了:
``
  
<hoc msg="msg" @change="onChange">
``
可以在 这里 预览代码效果。
我们开发新的组件,只要拿 hoc 过来复用即可,它的业务价值就体现出来了,代码被精简到不敢想象。
``import { getListData } from 'api'
import { withPromise } from 'hoc'  
const listView = {
  props: ["result"],
  template:        <ul v-if="result>         <li v-for="item in result">           {{ item }}         </li>       </ul>     ,
};  
export default withPromise(listView, getListData)
``
一切变得简洁而又优雅。
组合
注意,这一章节对于没有接触过 React 开发的同学可能很困难,可以先适当看一下或者跳过。
有一天,我们突然又很开心,写了个高阶组件叫 withLog,它很简单,就是在 mounted 声明周期帮忙打印一下日志。
const withLog = (wrapped) => {     return {       mounted() {         console.log("I am mounted!")       },       render(h) {         return h(wrapped)       },     }   }   
这里我们发现,又要把on、scopedSlots 等属性提取并且透传下去,其实挺麻烦的,我们封装一个从 this 上整合需要透传属性的函数:
function normalizeProps(vm) {     return {       on: vm.$listeners,       attr: vm.$attrs,       // 传递 $scopedSlots       scopedSlots: vm.$scopedSlots,     }   }   
然后在 h 的第二个参数提取并传递即可。
const withLog = (wrapped) => {     return {       mounted() {         console.log("I am mounted!")       },       render(h) {         return h(wrapped, normalizeProps(this))       },     }   }   
然后再包在刚刚的 hoc 之外:
var hoc = withLog(withPromise(view, request));   
可以看出,这样的嵌套是比较让人头疼的,我们把 redux 这个库里的 compose 函数给搬过来,这个 compose 函数,其实就是不断的把函数给高阶化,返回一个新的函数。
function compose(...funcs) {     return funcs.reduce((a, b) => (...args) => a(b(...args)))   }   
compose(a, b, c) -> c(b(a))
这个函数对于第一次接触的同学来说可能需要很长时间来理解,因为它确实非常复杂,但是一旦理解了,你的函数式思想又更上一层楼了。
但是这也说明我们要改造 withPromise 高阶函数了,因为仔细观察这个 compose,它会包装函数,让它接受一个参数,并且把第一个函数的返回值 传递给下一个函数作为参数。
比如 compose(a, b) 来说,b(arg) 返回的值就会作为 a 的参数,进一步调用 a(b(args))
这需要保证参数只有一个。
那么按照这个思路,我们改造 withPromise,让它只接受一个参数 被包裹的函数,其实就是要进一步高阶化它:
const withPromise = (promiseFn) => {     // 返回的这一层函数,就符合我们的要求,只接受一个参数     return function (wrapped) {       return {         mounted() {},         render() {},       }     }   }   
有了它以后,就可以更优雅的组合高阶组件了:
`const compsosed = compose(
    withPromise(request),
    withLog,
)  
const hoc = compsosed(view)
`
以上 compose 章节的完整代码 在这。
注意,这一节如果第一次接触这些概念看不懂很正常,这些在 React 社区里很流行,但是在 Vue 社区里很少有人讨论!关于这个 compose 函数,第一次在 React 社区接触到它的时候我完全看不懂,先知道它的用法,慢慢理解也不迟。
总结
本篇文章的所有代码都保存在 Github仓库 中,并且提供预览。
谨以此文献给在我源码学习道路上给了我很大帮助的 《Vue技术内幕》 作者 hcysun 大佬,虽然我还没和他说过话,但是在我还是一个工作几个月的小白的时候,一次业务需求的思考就让我找到了这篇文章:探索Vue高阶组件 | HcySunYang
当时的我还不能看懂这篇文章中涉及到的源码问题和修复方案,然后改用了另一种方式实现了业务,但是这篇文章里提到的东西一直在我的心头萦绕,我在忙碌的工作之余努力学习源码,期望有朝一日能彻底看懂这篇文章。
时至今日我终于能理解文章中说到的 $vnode 和 context 代表什么含义,但是这个 bug 在 Vue 2.6 版本由于 slot 的实现方式被重写,也顺带修复掉了,现在在 Vue 中使用最新的 slot 语法配合高阶函数,已经不会遇到这篇文章中提到的 bug 了。
本文分享自微信公众号 - 前端从进阶到入院(code_with_love)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
 
  
  
  
 
 
  
 
 
 
 
 
 
 