1. 程式人生 > 實用技巧 >VUE高階元件解析

VUE高階元件解析

一、高階元件介紹

  vue 高階元件的認識,在React中元件是以複用程式碼實現的,而Vue中是以mixins 實現,並且官方文件中也缺少一些高階元件的概念,因為在vue中實現高階組很困難,並不像React簡單,其實vue中mixins也同樣可以代替,在讀了一部分原始碼之後,對vue有了更深的認識。

  所謂高階元件其實就是一個高階函式, 即返回一個元件函式的函式,Vue中怎麼實現呢? 注意高階元件有如下特點

  1. 高階元件(HOC)應該是無副作用的純函式,且不應該修改原元件,即原元件不能有變動
  2. 高階元件(HOC)不關心你傳遞的資料(props)是什麼,並且新生成元件不關心資料來源
  3. 高階元件(HOC)接收到的 props 應該透傳給被包裝元件即直接將原元件prop傳給包裝元件
  4. 高階元件完全可以新增、刪除、修改 props

二、高階元件舉例

  Base.vue

<template>
  <div>
    <p @click="Click">props: {{test}}</p>
  </div>
</template>
<script>
export default {
  name: 'Base',
  props: {
    test: Number
  },
  methods: {
    Click () {
      this.$emit('Base-click')
    }
  }
}
</script>

  Vue 元件主要就是三點:props、event 以及 slots。對於 Base元件而言,它接收一個數字型別的 props 即 test,並觸發一個自定義事件,事件的名稱是:Base-click,沒有 slots。我們會這樣使用該元件:

<Base @Base-click="xxxx" :test="100" /></Base>

  現在我們需要 base-component 元件每次掛載完成的時候都列印一句話:haha,同時這也許是很多元件的需求,所以按照 mixins 的方式,我們可以這樣做,首先定義個 mixins

export default
consoleMixin { mounted () { console.log('haha') } }

  然後在 Base 元件中將 consoleMixin 混入:

<template>
  <div>
    <p @click="Click">props: {{test}}</p>
  </div>
</template>
<script>
export default {
  name: 'Base',
  props: {
    test: Number
  },
  mixins: [ consoleMixin ],
  methods: {
    Click () {
      this.$emit('Base-click')
    }
  }
}
</script>

  這樣使用 Base 元件的時候,每次掛載完成之後都會列印一句 haha,不過現在我們要使用高階元件的方式實現同樣的功能,回憶高階元件的定義:接收一個元件作為引數,返回一個新的元件,那麼此時我們需要思考的是,在 Vue 中元件是什麼?Vue 中元件是函式,不過那是最終結果,比如我們在單檔案元件中的元件定義其實就是一個普通的選項物件,如下:

export default {
  name: 'Base',
  props: {...},
  mixins: [...]
  methods: {...}
}

  這難道不是一個純物件嘛

import Base from './Base.vue'
console.log(Base)

  這裡的Base是什麼呢?對,就是一個JSON物件(如圖),而當以把他加入到一個元件的components,Vue最終會以該引數即option來構造例項的建構函式,所以Vue中元件就是個函式,但是在引入之前仍只是一個options物件,所以這樣就很好明白了 Vue中元件開始只是一個物件,即高階元件就是:一個函式接受一個純物件,並且返回一個新純物件

export default function Console (BaseComponent) {
  return {
    template: '<wrapped v-on="$listeners" v-bind="$attrs"/>',
    components: {
      wrapped: BaseComponent
    },
    mounted () {
      console.log('haha')
    }
  }
}

  這裡 Console就是一個高階元件,它接受一個引數 BaseComponent即傳入的元件,返回一個新元件,將BaseComponent作為新元件的子元件並且在mounted裡設定鉤子函式 列印haha,我們可以完成mixins同樣做到的事,我們並沒有修改子元件Base,這裡的$listeners$attrs其實是在透傳props 和事件 那這樣真的就完美解決問題了嗎?不是的,首先 template 選項只有在完整版的 Vue 中可以使用,在執行時版本中是不能使用的,所以最起碼我們應該使用渲染函式(render)替代模板(template)

// Console.js
    export default function Console (BaseComponent) {
      return {
        mounted () {
          console.log('haha')
        },
        render (h) {
          return h(BaseComponent, {
            on: this.$listeners,
            attrs: this.$attrs,
          })
        }
      }
    }

  我們將模板改寫成了渲染函式,看上去沒什麼問題,實際還是有問題,上面的程式碼中 BaseComponent 元件依然收不到 props,為什麼呢,我們不是已經在 h 函式的第二個引數中將 attrs 傳遞過去了嗎,怎麼還收不到?當然收不到,attrs 指的是那些沒有被宣告為 props 的屬性,所以在渲染函式中還需要新增 props 引數

    export default function Console (BaseComponent) {
      return {
        mounted () {
          console.log('haha')
        },
        render (h) {
          return h(BaseComponent, {
            on: this.$listeners,
            attrs: this.$attrs,
            props: this.$props
          })
        }
      }
    }

  那這樣呢?其實還是不行,props始終是空物件,這裡的props是高階元件的物件,但是高階元件並沒有宣告props所以如此故要再宣告一個props

    export default function Console (BaseComponent) {
      return {
        mounted () {
          console.log('haha')
        },
        props: BaseComponent.props,
        render (h) {
          return h(BaseComponent, {
            on: this.$listeners,
            attrs: this.$attrs,
            props: this.$props
          })
        }
      }
    }

  那麼一個差不多的高階元件就完成了,但是還沒完,我們只實現了:透傳props、透傳事件,就剩下slot了,我們修改 Base 元件為其新增一個具名插槽和預設插槽。

// Base.vue
<template>
  <div>
    <span @click="handleClick">props: {{test}}</span>
    <slot name="slot1"/> <!-- 具名插槽 --></slot>
    <p>===========</p>
    <slot><slot/> <!-- 預設插槽 -->
  </div>
</template>
 
<script>
export default {
  ...
}
</script>
    <template>
      <div>
        <Base>
          <h2 slot="slot1">BaseComponent slot</h2>
          <p>default slot</p>
        </Base>
        <wrapBase>
          <h2 slot="slot1">EnhancedComponent slot</h2>
          <p>default slot</p>
        </wrapBase>
      </div>
    </template>
     
    <script>
      import Base from './Base.vue'
      import Console from './Console.js'
     
      const wrapBase = Console(Base)
     
      export default {
        components: {
          Base,
          wrapBase
        }
      }
    </script>

  這裡的執行結果就是 wrapBase裡的slot都沒有了 所以就要改一下高階組建了

function Console (BaseComponent) {
  return {
    mounted () {
      console.log('haha')
    },
    props: BaseComponent.props,
    render (h) {
 
      // 將 this.$slots 格式化為陣列,因為 h 函式第三個引數是子節點,是一個數組
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])
 
      return h(BaseComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      }, slots) // 將 slots 作為 h 函式的第三個引數
    }
  }
}

  這時 slot內容確實渲染出來了 但是順序不太對,高階元件的全部渲染到了末尾。其實 Vue在處理具名插槽會考慮作用域的因素,首先 Vue 會把模板(template)編譯成渲染函式(render),比如如下模板:

<div>
  <p slot="slot1">Base slot</p>
</div>

  會被編譯成如下渲染函式:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", [
    _c("div", {
      attrs: { slot: "slot1" },
      slot: "slot1"
    }, [
      _vm._v("Base slot")
    ])
  ])
}

  觀察上面的渲染函式,我們發現普通的 DOM 是通過 _c 函式建立對應的 VNode 的。現在我們修改模板,模板中除了有普通 DOM 之外,還有元件,如下:

<div>
  <Base>
    <p slot="slot1">Base slot</p>
    <p>default slot</p>
  </Base>
</div>

  其render函式

    var render = function() {
      var _vm = this
      var _h = _vm.$createElement
      var _c = _vm._self._c || _h
      return _c(
        "div",
        [
          _c("Base", [
            _c("p", { attrs: { slot: "slot1" }, slot: "slot1" }, [
              _vm._v("Base slot")
            ]),
            _vm._v(" "),
            _c("p", [_vm._v("default slot")])
          ])
        ],
      )
    }

  我們發現無論是普通DOM還是元件,都是通過 _c 函式建立其對應的 VNode 的,其實 _c 在 Vue 內部就是 createElement 函式。

  createElement 函式會自動檢測第一個引數是不是普通DOM標籤。如果不是普通DOM標籤那麼 createElement 會將其視為元件,並且建立元件例項(注意元件例項是這個時候才建立的)但是建立元件例項的過程中就面臨一個問題:元件需要知道父級模板中是否傳遞了 slot 以及傳遞了多少,傳遞的是具名的還是不具名的等等。那麼子元件如何才能得知這些資訊呢?很簡單,假如元件的模板如下

<div>
  <Base>
    <p slot="slot1">Base slot</p>
    <p>default slot</p>
  </Base>
</div>

  父元件的模板最終會生成父元件對應的 VNode,所以以上模板對應的 VNode 全部由父元件所有,那麼在建立子元件例項的時候能否通過獲取父元件的 VNode 進而拿到 slot 的內容呢?即通過父元件將下面這段模板對應的 VNode 拿到

<Base>
    <p slot="slot1">Base slot</p>
    <p>default slot</p>
</Base>

  如果能夠通過父級拿到這段模板對應的 VNode,那麼子元件就知道要渲染哪些 slot 了,其實 Vue 內部就是這麼幹的,實際上你可以通過訪問子元件的 this.$vnode 來獲取這段模板對應的 VNode。

  this.$vnode 並沒有寫進 Vue 的官方文件

  子元件拿到了需要渲染的 slot 之後進入到了關鍵的一步,這一步就是導致高階元件中透傳 slot 給 Base元件 卻無法正確渲染的原因。children的VNode中的context引用父元件例項,其本身的context也會引用本身例項 其實是一個東西

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) //ture

  而 Vue 內部做了一件很重要的事兒,即上面那個表示式必須成立,才能夠正確處理具名 slot,否則即使 slot 具名也不會被考慮,而是被作為預設插槽。這就是高階元件中不能正確渲染 slot 的原因

  即 高階元件中 本來是父元件和子元件之間插入了一個元件(高階元件),而子元件的 this.$vnode其實是高階元件的例項,但是我們將slot透傳給子元件,slot裡 VNode 的context實際引用的還是父元件 所以

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // false

  最終導致具名插槽被作為預設插槽,從而渲染不正確。

  解決辦法也很簡單,只需要手動設定一下 slot 中 VNode 的 context 值為高階元件例項即可

function Console (Base) {
  return {
    mounted () {
      console.log('haha')
    },
    props: Base.props,
    render (h) {
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])
        // 手動更正 context
        .map(vnode => {
          vnode.context = this._self //繫結到高階元件上
          return vnode
        })
 
      return h(WrappedComponent, {
        on: this.$listeners,
        props: this.$props,
        attrs: this.$attrs
      }, slots)
    }
  }
}

  說明白就是強制把slot的歸屬權給高階元件,而不是 父元件。通過當前例項 _self 屬性訪問當例項本身,而不是直接使用 this,因為 this 是一個代理物件。