Vue 高級組件(HOC)實現原理

Vue 高級組件(HOC)實現原理

轉至:Vue 進階必學之高階組件 HOCjavascript

在實際業務中, 若想簡化異步狀態管理, 能夠使用基於slot-scopes的開源庫vue-promised.css

本文主是強調實際此類高階段組件的思想, 若是要將此使用到生產環境, 建議使用開源庫vue-promisedhtml

舉個例子

在日常開發中, 最多見的需求是: 異常請求數據, 並作出相應的處理:前端

  • 數據請求中, 給出提示, 如: 'loading'
  • 數據請求出錯時, 給出錯誤提示, 如: 'failed to load data'

例如:vue

<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狀態, 都須要處理和管理數據.java

有沒有辦法抽象出來呢? 這裏, 高階組件就多是一種選擇了.git

什麼是高階(HOC)組件?

高階組件, 實際上是一個函數接受一個組件爲參數, 返回一個包裝後的組件.github

在Vue中, 組件是一個對象, 所以高階組件就是一個函數接受一個對象, 返回一個包裝好的對象, 即:編程

高階組件是: fn(object) => newObject
複製代碼

初步實現

基於這個思路, 咱們就能夠開始嘗試了redux

高階組件實際上是一個函數, 所以咱們須要實現請求管理的高階函數, 先定義它的兩個入參:

  • 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輪 fn1 fn2 (...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)的同窗, 可能比較難以理解, 但一旦理解了, 你的函數式編程思想就又昇華了. :::

相關連接

相關文章
相關標籤/搜索