编程思想:react高阶组件 => vue 高阶组件

793 阅读6分钟

前言

最近学习react 过程中,接触到在 React + Redux 结合作为前端框架的时候,提出了一个将组件分为智能组件木偶组件两种。由于平时开发使用vue频率比较高,发现这个思想可以借鉴到vue中。

智能组件 & 木偶组件

智能组件:它是数据的所有者,它拥有数据、且拥有操作数据的action,但是它不实现任何具体功能。它会将数据和操作action传递给子组件,让子组件来完成UI或者功能。这就是智能组件,也就是项目中的各个页面。"

木偶组件:它就是一个工具,不拥有任何数据、及操作数据的action,给它什么数据它就显示什么数据,给它什么方法,它就调用什么方法,比较傻,就像一个被线操控的木偶。

一般来说,它们的结构关系是这样的:

<智能组件>
  <木偶组件 />
</智能组件><容器组件>
    <ui组件 />
</容器组件>

它们还有另一个别名,就是 容器组件 和 ui组件,是不是很形象。

什么是高阶组件?

在react中

在 React 里,组件是 Class,所以高阶组件有时候会用 装饰器 语法来实现,因为 装饰器 的本质也是接受一个 Class 返回一个新的 Class

在 React 的世界里,高阶组件就是 f(Class) -> 新的Class

我们开发一个评论列表的react组件大概是这样:

// CommentList.js
class CommentList extends React.Component {
  constructor() {
    super();
    this.state = { comments: [] };
  }
  componentDidMount() {
    $.ajax({
      url: "/my-comments.json",
      dataType: "json",
      success: function (comments) {
        this.setState({ comments: comments });
      }.bind(this),
    });
  }
  render() {
    return <ul> {this.state.comments.map(renderComment)} </ul>;
  }
  renderComment({ body, author }) {
    return (
      <li>
        {body}—{author}
      </li>
    );
  }
}

拆分过后:

// CommentListContainer.js
class CommentListContainer extends React.Component {
  constructor() {
    super();
    this.state = { comments: [] };
  }
  componentDidMount() {
    $.ajax({
      url: "/my-comments.json",
      dataType: "json",
      success: function (comments) {
        this.setState({ comments: comments });
      }.bind(this),
    });
  }
  render() {
    return <CommentList comments={this.state.comments} />;
  }
}

// CommentList.js
class CommentList extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return <ul> {this.props.comments.map(renderComment)} </ul>;
  }
  renderComment({ body, author }) {
    return (
      <li>
        {body}—{author}
      </li>
    );
  }
}

这样就做到了数据提取和渲染分离,CommentList可以复用,CommentList可以设置props判断数据的可用性。

优势:

  • 展示组件和容器组件更好的分离,更好的理解应用程序和UI重用性高,
  • 展示组件可以用于多个不同的state数据源,也可以变更展示组件。

在vue中

在 Vue 的世界里,组件是一个对象,所以高阶组件就是一个函数接受一个对象,返回一个新的包装好的对象。

类比到 Vue 的世界里,高阶组件就是 f(object) -> 新的object

Vue高阶组件 1.0(原始版)

具体到上面这个例子中,我们加入一些请求状态帮助理解,我们的思路是这样的,

    1. 高阶组件接受 木偶组件 和 请求的方法 作为参数
    1. 在 mounted 生命周期中请求到数据
    1. 把请求的数据通过 props 传递给 木偶组件
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;
    },
  };
};

在参数中:

  1. wrapped 也就是需要被包裹的组件对象。
  2. promiseFn 也就是请求对应的函数,需要返回一个 Promise

再加入 render 函数中,我们把传入的 wrapped 也就是木偶组件给包裹起来。这样就形成了 智能组件获取数据 -> 木偶组件消费数据,这样的数据流动了。

// 智能组件 1.0
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
    },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
          error: this.error
        }
      }
      const wrapper = h('div', [
        h(wrapped, args),
        this.loading ? h('span', ['加载中……']) : null,
        this.error ? h('span', ['加载错误']) : null
      ])
      return wrapper
    }
  }
}
// 木偶组件1.0 
const view = {
  props: ["result","loading","error"],
  template: `
    <ul v-if="result">
      <li v-for="item in result">
        {{ item }}
      </li>
    </ul>
  `
};

具体使用生成一个HOC组件

// HOC 组件 1.0
// 这里先用promise 模拟一个请求数据过程
const getListData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(['foo','bar','Too']);
    }, 1000);
  });
};

export default withPromise(view, getListData)

1.0 示例,来个demo 验证一下:

import Vue from 'vue'

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
    },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
          error: this.error
        }
      }
      const wrapper = h('div', [
        h(wrapped, args),
        this.loading ? h('span', ['加载中……']) : null,
        this.error ? h('span', ['加载错误']) : null
      ])
      return wrapper
    }
  }
}

const view = {
  props: ['result', 'loading', 'error'],
  template: `
        <ul v-if="result">
            <li v-for="item in result">
                {{ item }}
            </li>
        </ul>
    `
}

// 这里先用promise 模拟一个请求数据过程
const getListData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(['foo', 'bar', 'baz'])
    }, 1000)
  })
}

var hoc = withPromise(view, getListData)

new Vue({
  el: '#app',
  template: `<hoc></hoc>`,
  components: {
    hoc
  }
})

Vue高阶组件 2.0(进阶版)

1.0 只是初步雏形,实际开发过程中,接口是参数频繁变动的,我们希望得到改进几个地方

  • 由于逻辑视图是分离的,我们需要考虑如何解决从视图层通知逻辑层更新数据
  • 额外的props 或者 attrs listener甚至是 插槽slot 给最内层的 木偶组件 

第一个问题,我们只需要在智能组件上使用ref访问到 木偶组件 监听木偶组件参数变化,回调中更新数据即可。 第二个问题,我们只要在渲染子组件的时候把 $attrs$listeners$scopedSlots 传递下去即可

// 智能组件2.0
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
    },
    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', [
        h(wrapped, args),
        this.loading ? h('span', ['加载中……']) : null,
        this.error ? h('span', ['加载错误']) : null
      ])
      return wrapper
    }
  }
}

2.0 示例


import Vue from 'vue'

// 这里先用promise 模拟一个请求数据过程
const api = {
  getCommenList: (params) => {
    // 打印请求参数
    console.log(params)
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(['foo', 'bar', 'baz'])
      }, 1000)
    })
  },
  getUserList: (params) => {
    // 打印请求参数
    console.log(params)
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(['Tim', 'Alia', 'Jessia'])
      }, 1000)
    })
  }
}
const axios = (url, params) => {
  return api[url](params)
}

const withPromise = (wrapped, promiseFn) => {
  return {
    name: 'with-promise',
    data() {
      return {
        loading: false,
        error: false,
        result: null
      }
    },
    methods: {
      async request() {
        this.loading = true
        const { requestApi, requestParams } = this.$refs.wrapped
        const result = await promiseFn(requestApi, requestParams).finally(
          () => {
            this.loading = false
          }
        )
        this.result = result
      }
    },
    mounted() {
      // 立刻发送请求,并且监听参数变化重新请求
      this.$refs.wrapped.$watch('requestParams', this.request.bind(this), {
        immediate: true
      })
    },
    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
    }
  }
}

const view1 = {
  props: ['result', 'loading', 'error'],
  template: `
      <div>
      <ul v-if="result && !loading">
          <li v-for="item in result">
                {{ item }}
            </li>
        </ul>
        <button @click="addComment">新增一条评论</button>
        <slot></slot>
        <slot name="named"></slot>
      </div>
    `,
  data() {
    return {
      requestApi: 'getCommenList',
      requestParams: {
        page: 1,
        pageSize: 10
      }
    }
  },
  methods: {
    addComment() {
      this.$emit('change', '新增了一条评论')
    }
  }
}

const view2 = {
  props: ['result', 'loading', 'error'],
  template: `
    <div>
          <ul v-if="result && !loading">
              <li v-for="item in result">
                  {{ item }}
              </li>
          </ul>
          <button @click="reload">重新加载数据</button>
        </div>
      `,
  methods: {
    reload() {
      this.requestParams = {
        page: this.requestParams.page++
      }
    }
  },
  data() {
    return {
      requestApi: 'getUserList',
      requestParams: {
        page: 1,
        pageSize: 10
      }
    }
  }
}

var hoc1 = withPromise(view1, axios)
var hoc2 = withPromise(view2, axios)

new Vue({
  el: '#app',
  template: `
    <div>
        评论列表:
        <hoc1 @change="onchange">
            <template>
                <div>这是一个插槽</div>
            </template>
            <template v-slot:named>
                <div>这是一个具名插槽</div>
            </template>
        </hoc1>
        用户列表:
        <hoc2></hoc2>
    </div>
  `,
  components: {
    hoc1,
    hoc2
  },
  methods: {
    onchange(msg) {
      alert(msg)
    }
  }
})

Vue高阶组件 3.0(最终版)

突然有一天我想改造这个组件(天气冷,多穿几件衣服)。组件嵌套

<容器组件A>
    <容器组件B>
        <容器组件C> 
            <UI组件></UI组件>
        </容器组件C>
    </容器组件B>
</容器组件A>

于是,再包在刚刚的 hoc 之外,这里我们不考虑使用jsx语法:

function normalizeProps(vm) {
  return {
    on: vm.$listeners,
    attr: vm.$attrs,
    // 传递 $scopedSlots
    scopedSlots: vm.$scopedSlots,
  }
}
var withA = (wrapped) => {
  return {
    mounted() {
      console.log('I am withA!')
    },
    render(h) {
      return h(wrapped, normalizeProps(this))
    }
  }
}

var withB = (wrapped) => {
  return {
    mounted() {
      console.log('I am withB!')
    },
    render(h) {
      return h(wrapped, normalizeProps(this))
    }
  }
}

// 这里 withC = withPromise(view, axios)
var hoc = withA(withB(withPromise(view, axios)));
或者
var hoc = withB(withA(withPromise(view, axios)));

这样的循环嵌套确实让人头疼,不由让我们想到函数式编程的composed,我们如何改造下?

什么是组合(composed)?

compose在函数式编程中是一个很重要的工具函数,在这里实现的compose有三点说明 特点:

  • 第一个函数是多元的(接受多个参数),后面的函数都是单元的(接受一个参数)
  • 执行顺序的自右向左的
  • 所有函数的执行都是同步的,当然异步也能处理,这里暂时不考虑
// ====== compose ======
// 借助 compose 函数对连续的异步过程进行组装,不同的组合方式实现不同的业务流程
// 我们希望使用:compose(f1,f2,f3,init)(...args) ==> 实际执行:init(f1(f2(f3.apply(this,args))))

// ===== 组合同步操作 ====  start
const _pipe = (f, g) => {
  return (...arg) => {
    return g.call(this, f.apply(this, arg))
  }
}
const compose = (...fns) => fns.reverse().reduce(_pipe, fns.shift())

var f1 = function(){
    console.log(1)
}
var f2 = function(){
    console.log(2)
}
var f3 = function(){
    console.log(3)
}

// 随机组合
console.log('f1,f2,f3:')
compose(...[f3, f2, f1])()

console.log('f3,f2,f1:')
compose(...[f1, f2, f3])()


// ===== 组合同步操作 ====  end

再来个例子加深理解

let init = (...args) => args.reduce((ele1, ele2) => ele1 + ele2, 0)
let step2 = (val) => val + 2
let step3 = (val) => val + 3
let step4 = (val) => val + 4

let composeFunc = compose(...steps)
console.log(composeFunc(1, 2, 3))

改造后 3.0
// 大部分代不变 只是hoc 组件做了调整
const withPromise = (promiseFn) => {
  // 返回的这一层函数,就符合我们的要求,只接受一个参数
  return function (wrapped) {
    return {
      mounted() {
          ...
      },
      render() {
          ...
      },
    }
  }
}

// 以后我们使用的时候,就能用过composed更优雅的实现了
const composed = compose(withA,
    withB,
    withPromise(request)    
)

const hoc = composed(view1)

结语

这是目前我对组件编程一些思考,以前编写Vue业务组件的缺乏这方面思考(Mark)。希望有一些别的观点相互碰撞。

拓展

# Vue 进阶必学之高阶组件 HOC