Vue 高级组件(HOC)实现原理

1,998 阅读3分钟

Vue 高级组件(HOC)实现原理

转至:Vue 进阶必学之高阶组件 HOC

在实际业务中, 若想简化异步状态管理, 可以使用基于slot-scopes的开源库vue-promised.

本文主是强调实际此类高阶段组件的思想, 如果要将此使用到生产环境, 建议使用开源库vue-promised

举个例子

在平常开发中, 最常见的需求是: 异常请求数据, 并做出相应的处理:

  • 数据请求中, 给出提示, 如: 'loading'
  • 数据请求出错时, 给出错误提示, 如: 'failed to load data'

例如:

<template>
  <div>
    <div v-if="error">failed to load data!</div>
    <div v-else-if="loading">loading...</div>
    <div v-else>result: {{ result.status }}</div>
  </div>
</template>

<script>
/* eslint-disable prettier/prettier */
export default {
  data () {
    return {
      result: {
        status: 200
      },
      loading: false,
      error: false
    }
  },
  async created () {
    try {
      this.loading = true
      const data = await this.$service.get('/api/list')
      this.result = data
    } catch (e) {
      this.error = true
    } finally {
      this.loading = false
    }
  }
}
</script>

<style lang="less" scoped></style>

通常情况下, 我们可能会这么写. 但这样有一个问题, 每次使用异步请求时, 都需要去管理loading, error状态, 都需要处理和管理数据.

有没有办法抽象出来呢? 这里, 高阶组件就可能是一种选择了.

什么是高阶(HOC)组件?

高阶组件, 其实是一个函数接受一个组件为参数, 返回一个包装后的组件.

在Vue中, 组件是一个对象, 因此高阶组件就是一个函数接受一个对象, 返回一个包装好的对象, 即:

高阶组件是: fn(object) => newObject

初步实现

基于这个思路, 我们就可以开始尝试了

高阶组件其实是一个函数, 因此我们需要实现请求管理的高阶函数, 先定义它的两个入参:

  • component, 需要被包裹的组件对象
  • promiseFunc, 异步请求函数, 必须是一个promise

loading, error等状态, 对应的视图, 我们就在高阶函数中处理好, 返回一个包装后的新组件.

export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    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) {
      return h(component, {
        props: {
          result: this.result,
          loading: this.loading
        }
      })
    }
  }
}

至此, 算是差不多实现了一个初级版本. 我们添加一个示例组件:

View.vue

<template>
  <div>
    {{ result.status }}
  </div>
</template>

<script>
export default {
  props: {
    result: {
      type: Object,
      default: () => { }
    },
    loading: {
      type: Boolean,
      default: false
    }
  }
}
</script>

此时, 如果我们使用wrapperPromise包裹这个View.vue组件

const request = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({status: 200})
    }, 500)
  })
}

const hoc = wrapperPromise(View, request)

并在父组件(Parent.vue)中使用它渲染:

<template>
  <div>
    <hoc />
  </div>
</template>

<script>
import View from './View'
import {wrapperPromise} from './utils'

const request = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({status: 200})
    }, 500)
  })
}

const hoc = wrapperPromise(View, request)
export default {
  components: {
    hoc
  }
}
</script>

此时, 组件在空白500ms后, 渲染出了200, 代码运行成功, 异步数据流run通了.

进一步优化高阶组件, 增加"loading"和"error"视图, 在交互体现上更加友好一些.

/* eslint-disable max-lines-per-function */
export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    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 conf = {
        props: {
          result: this.result,
          loading: this.loading
        }
      }
      const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
      return wrapper
    }
  }
}

再完善

到目前为止, 高阶组件虽然可以使用了, 但还不够好, 仍缺少一些功能, 如:

  1. 从子组件上取得参数, 用于发送异步请求的参数
  2. 监听子组件中请求参数的变化, 并重新发送请求
  3. 外部组件传递给hoc组件的参数, 没有传递下去.例如, 我们在最外层使用hoc组件时, 希望能些额外的props或attrs(或者slot等)给最内层的被包装的组件. 此时, 就需要hoc组件将这些信息传递下去.

为实现第1点, 需要在View.vue中添加一个特定的字段, 作为请求参数, 如: requestParams

<template>
  <div>
    {{ result.status + ' => ' + result.name }}
  </div>
</template>

<script>
export default {
  props: {
    result: {
      type: Object,
      default: () => { }
    },
    loading: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      requestParams: {
        name: 'http'
      }
    }
  }
}
</script>

<style lang="scss" scoped></style>

同时改写下request函数, 让它接受请求参数. 这里我们不做什么处理, 原样返回.

const request = params => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({...params, status: 200})
    }, 500)
  })
}

有一个问题是, 我们如何能够拿到View.vue组件中的值的呢? 可以考虑通过ref来获取, 例如:

export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    data() {
      return {
        loading: false,
        error: false,
        result: null
      }
    },
    async mounted() {
      this.loading = true
      // 获取包裹组件的请求参数
      const {requestParams} = this.$refs.wrapper

      const result = await promiseFn(requestParams).finally(() => {
        this.loading = false
      })
      this.result = result
    },
    render(h) {
      const conf = {
        props: {
          result: this.result,
          loading: this.loading
        },
        ref: 'wrapper'
      }
      const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
      return wrapper
    }
  }
}

第2点, 子组件请求参数发生变化时, 父组件要同步更新请求参数, 并重新发送请求, 然后把新数据传递给子组件.

/* eslint-disable max-lines-per-function */
export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    data() {
      return {
        loading: false,
        error: false,
        result: null
      }
    },
    methods: {
      async request() {
        this.loading = true

        const {requestParams} = this.$refs.wrapper

        const result = await promiseFn(requestParams).finally(() => {
          this.loading = false
        })
        this.result = result
      }
    },
    async mounted() {
      this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
        immediate: true
      })
    },
    render(h) {
      const conf = {
        props: {
          result: this.result,
          loading: this.loading
        },
        ref: 'wrapper'
      }
      const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
      return wrapper
    }
  }
}

第2个问题, 我们只在渲染子组件时, 把$attrs, $listeners, $scopedSlots传递下去即可.

export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    data() {
      return {
        loading: false,
        error: false,
        result: {}
      }
    },
    methods: {
      async request() {
        this.loading = true

        const {requestParams} = this.$refs.wrapper

        const result = await promiseFn(requestParams).finally(() => {
          this.loading = false
        })
        this.result = result
      }
    },
    async mounted() {
      this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
        immediate: true,
        deep: true
      })
    },
    render(h) {
      const conf = {
        props: {
          // 混入 $attrs
          ...this.$attrs,
          result: this.result,
          loading: this.loading
        },
        // 传递事件
        on: this.$listeners,
        // 传递 $scopedSlots
        scopedSlots: this.$scopedSlots,
        ref: 'wrapper'
      }
      const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
      return wrapper
    }
  }
}

至此, 完整代码 Parent.vue

<template>
  <div>
    <hoc />
  </div>
</template>

<script>
import View from './View'
import {wrapperPromise} from './utils'

const request = params => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({...params, status: 200})
    }, 500)
  })
}

const hoc = wrapperPromise(View, request)
export default {
  components: {
    hoc
  }
}
</script>

utils.js

export const wrapperPromise = (component, promiseFn) => {
  return {
    name: 'promise-component',
    data() {
      return {
        loading: false,
        error: false,
        result: {}
      }
    },
    methods: {
      async request() {
        this.loading = true

        const {requestParams} = this.$refs.wrapper

        const result = await promiseFn(requestParams).finally(() => {
          this.loading = false
        })
        this.result = result
      }
    },
    async mounted() {
      this.$refs.wrapper.$watch('requestParams', this.request.bind(this), {
        immediate: true,
        deep: true
      })
    },
    render(h) {
      const conf = {
        props: {
          // 混入 $attrs
          ...this.$attrs,
          result: this.result,
          loading: this.loading
        },
        // 传递事件
        on: this.$listeners,
        // 传递 $scopedSlots
        scopedSlots: this.$scopedSlots,
        ref: 'wrapper'
      }
      const wrapper = h('div', [h(component, conf), this.loading ? h('div', ['loading...']) : null, this.error ? h('div', ['error!!!']) : null])
      return wrapper
    }
  }
}

View.vue

<template>
  <div>
    {{ result.status + ' => ' + result.name }}
  </div>
</template>

<script>
export default {
  props: {
    result: {
      type: Object,
      default: () => { }
    },
    loading: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      requestParams: {
        name: 'http'
      }
    }
  }
}
</script>

扩展

假如, 业务上需要在某些组件的mounted的钩子函数中帮忙打印日志

const wrapperLog = (component) => {
  return {
    mounted(){
      window.console.log("I am mounted!!!")
    },
    render(h) {
      return h(component, {
        on: this.$listeners,
        attr: this.$attrs,
        scopedSlots: this.$scopedSlots,
      })
    }
  }
}

思考

此时, 若结合前文中实现的高阶组件, 如果两个一起使用呢?

const hoc = wrapperLog(wrapperPromise(View, request))

这样的写法, 看起来会有些困难, 若学过React的同学, 可以考虑把Redux中的compose函数拿来使用.

compose

在了解redux compose函数前, 了解下函数式编程中纯函数的定义. :::tip 纯函数 纯函数, 指相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。 :::

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

其实compose函数, 是将var a = fn1(fn2(fn3(fn4(x))))这种难以阅读的嵌套调用方式改写为: var a = compose(fn1, fn2, fn3, fn4)(x)的方式来调用.

redux的compose实现很简单, 使用数组的reduce方法来实现, 核心代码只有一句:

return funcs.reduce((a,b) => (..args) => a(b(...args)))

虽然写了多年的前端代码, 与使用过reduce函数, 但是看到这句代码还是比较懵的.

举例说明

因此,在这里举一个例子来看看这个函数是执行的.

import {compose} from 'redux'

let a = 10
function fn1(s) {return s + 1}
function fn2(s) {return s + 2}
function fn3(s) {return s + 3}
function fn4(s) {return s + 4}

let res = fn1(fn2(fn3(fn4(a)))) // 即: 10 + 4 + 3 + 2 + 1

// 根据compose的功能, 可以上面的代码改写为:
let composeFn = compose(fn1, fn2, fn3, fn4)
let result = composeFn(a) // 20

代码解释

根据compose的源码来看, composeFn其执行等价于:

[fn1, fn2, fn3, fn4].reduce((a, b) => (...args) => a(b(...args)))
循环a的值b的值返回值
第1轮fn1fn2(...args) => fn1(fn2(...args))
第2轮(...args) => fn1(fn2(...args))fn3(...args) => fn1(fn2(fn3(...args)))
第3轮(...args) => fn1(fn2(fn3(...args)))fn4(...args) => fn1(fn2(fn3(fn4(...args))))

循环到最后的返回值: (...args) => fn1(fn2(fn3(fn4(...args)))). 经过compose处理后, 函数变成了我们想要的格式.

代码优化

按这个思路, 我们改造下wrapperPromise函数, 让它只接受一个被包裹的参数, 即进一步高阶化它.

const wrapperPromise = (promiseFn) => {
  return function(wrapper){
    return {
      mounted() {},
      render() {}
    }
  }
}

有了这个后, 就可以更加优雅的组件高阶组件了.

const composed = compose(wrapperPromise(request), wrapperLog)
const hoc = composed(View)

小结

compose函数其实在函数式编程中也比较常见. redux中对compose的实现也很简单, 理解起来应该还好.

主要是对Array.prototype.reduce函数使用并不是很熟练, 再加上使用函数返回函数的写法, 并配上几个连续的=>(箭头函数), 基本上就晕了.

::: tip warning 对于第一次接触此类函数(compose)的同学, 可能比较难以理解, 但一旦理解了, 你的函数式编程思想就又升华了. :::

相关链接