探索Vue高階組件

高階組件(HOC)是 React 生態系統的經常使用詞彙,React 中代碼複用的主要方式就是使用高階組件,而且這也是官方推薦的作法。而 Vue 中複用代碼的主要方式是使用 mixins,而且在 Vue 中不多提到高階組件的概念,這是由於在 Vue 中實現高階組件並不像 React 中那樣簡單,緣由在於 ReactVue 的設計思想不一樣,但並非說在 Vue 中就不能使用高階組件,只不過在 Vue 中使用高階組件所帶來的收益相對於 mixins 並無質的變化。本篇文章主要從技術性的角度闡述 Vue 高階組件的實現,且會從 ReactVue 二者的角度進行分析。html

從 React 提及

起初 React 也是使用 mixins 來完成代碼複用的,好比爲了不組件沒必要要的重複渲染咱們能夠在組件中混入 PureRenderMixinvue

const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
  mixins: [PureRenderMixin]
})
複製代碼

後來 React 拋棄了這種方式,進而使用 shallowComparenode

const shallowCompare = require('react-addons-shallow-compare')
const Button = React.createClass({
  shouldComponentUpdate: function(nextProps, nextState) {
    return shallowCompare(this, nextProps, nextState);
  }
})
複製代碼

這須要你本身在組件中實現 shouldComponentUpdate 方法,只不過這個方法具體的工做由 shallowCompare 幫你完成,即淺比較。react

再後來 React 爲了不開發者在組件中老是要寫這樣一段一樣的代碼,進而推薦使用 React.PureComponent,總之 React 在一步步的脫離 mixins,他們認爲 mixinsReact 生態系統中並非一種好的模式(注意:並無說 mixins 很差,僅僅針對 React 生態系統),觀點以下:git

一、mixins 帶來了隱式依賴 二、mixinsmixins 之間,mixins 與組件之間容易致使命名衝突 三、因爲 mixins 是侵入式的,它改變了原組件,因此修改 mixins 等於修改原組件,隨着需求的增加 mixins 將變得複雜,致使滾雪球的複雜性。github

具體你們能夠查看這篇文章 Mixins Considered Harmful。不過 HOC 也並非銀彈,它天然帶來了它的問題,有興趣的同窗能夠查看這個視頻:Michael Jackson - Never Write Another HoC,其觀點是:使用普通組件配合 render prop 能夠作任何 HOC 能作的事情數組

本篇文章不會過多討論 mixinsHOC 誰好誰壞,就像技術自己就沒有好壞之分,只有適合不適合。難道 ReactVue 這倆哥們兒不也是這樣嗎🙂。bash

ok,咱們回到高階組件,所謂高階組件其實就是高階函數啦,ReactVue 都證實了一件事兒:一個函數就是一個組件。因此組件是函數這個命題成立了,那高階組件很天然的就是高階函數,即一個返回函數的函數,咱們知道在 React 中寫高階組件就是在寫高階函數,很簡單,那是否是在 Vue 中實現高階組件也一樣簡單呢?其實 Vue 稍微複雜,甚至須要你對 Vue 足夠了解,接下來就讓咱們一塊在 Vue 中實現高階組件,在文章的後面會分析爲何一樣都是 函數就是組件 的思想,Vue 卻不能像 React 那樣輕鬆的實現高階組件。併發

也正因如此因此咱們有必要在實現 Vue 高階組件以前充分了解 React 中的高階組件,看下面的 React 代碼:app

function WithConsole (WrappedComponent) {
  return class extends React.Component {
    componentDidMount () {
      console.log('with console: componentDidMount')
    }
    render () {
      return <WrappedComponent {...this.props}/>
    }
  }
}
複製代碼

WithConsole 就是一個高階組件,它有如下幾個特色:

一、高階組件(HOC)應該是無反作用的純函數,且不該該修改原組件

能夠看到 WithConsole 就是一個純函數,它接收一個組件做爲參數並返回了一個新的組件,在新組件的 render 函數中僅僅渲染了被包裝的組件(WrappedComponent),並無侵入式的修改它。

二、高階組件(HOC)不關心你傳遞的數據(props)是什麼,而且被包裝組件(WrappedComponent)不關心數據來源

這是保證高階組件與被包裝組件可以完美配合的根本

三、高階組件(HOC)接收到的 props 應該透傳給被包裝組件(WrappedComponent)

高階組件徹底能夠添加、刪除、修改 props,可是除此以外,要將其他 props 透傳,不然在層級較深的嵌套關係中(這是高階組件的常見問題)將形成 props 阻塞。

以上是 React 中高階組件的基本約定,除此以外還要注意其餘問題,如:高階組件(HOC)不該該在 render 函數中建立;高階組件(HOC)也須要複製組件中的靜態方法;高階組件(HOC)中的 ref 引用的是最外層的容器組件而不是被包裝組件(WrappedComponent) 等等。

Vue 中的高階組件

瞭解了這些,接下來咱們就能夠開始着手實現 Vue 高階組件了,爲了讓你們有一個直觀的感覺,我仍然會使用 ReactVue 進行對比的講解。首先是一個基本的 Vue 組件,咱們常稱其爲被包裝組件(WrappedComponent),假設咱們的組件叫作 BaseComponent

base-component.vue

<template>
  <div>
    <span @click="handleClick">props: {{test}}</span>
  </div>
</template>

<script>
export default {
  name: 'BaseComponent',
  props: {
    test: Number
  },
  methods: {
    handleClick () {
      this.$emit('customize-click')
    }
  }
}
</script>
複製代碼

咱們觀察一個 Vue 組件主要觀察三點:propsevent 以及 slots。對於 BaseComponent 組件而言,它接收一個數字類型的 propstest,併發射一個自定義事件,事件的名稱是:customize-click,沒有 slots。咱們會這樣使用該組件:

<base-component @customize-click="handleCustClick" :test="100" />
複製代碼

如今咱們須要 base-component 組件每次掛載完成的時候都打印一句話:I have already mounted,同時這也許是不少組件的需求,因此按照 mixins 的方式,咱們能夠這樣作,首先定義個 mixins

export default consoleMixin {
  mounted () {
    console.log('I have already mounted')
  }
}
複製代碼

而後在 BaseComponent 組件中將 consoleMixin 混入:

export default {
  name: 'BaseComponent',
  props: {
    test: Number
  },
  mixins: [ consoleMixin ]
  methods: {
    handleClick () {
      this.$emit('customize-click')
    }
  }
}
複製代碼

這樣使用 BaseComponent 組件的時候,每次掛載完成以後都會打印一句 I have already mounted,不過如今咱們要使用高階組件的方式實現一樣的功能,回憶高階組件的定義:接收一個組件做爲參數,返回一個新的組件,那麼此時咱們須要思考的是,在 Vue 中組件是什麼?有的同窗可能會有疑問,難道不是函數嗎?對,Vue 中組件是函數沒有問題,不過那是最終結果,好比咱們在單文件組件中的組件定義其實就是一個普通的選項對象,以下:

export default {
  name: 'BaseComponent',
  props: {...},
  mixins: [...]
  methods: {...}
}
複製代碼

這不就是一個純對象嗎?因此當咱們從單文件中導入一個組件的時候:

import BaseComponent from './base-component.vue'
console.log(BaseComponent)
複製代碼

思考一下,這裏的 BaseComponent 是什麼?它是函數嗎?不是,雖然單文件組件會被 vue-loader 處理,但處理後的結果,也就是咱們這裏的 BaseComponent 仍然仍是一個普通的 JSON 對象,只不過當你把這個對象註冊爲組件(components 選項)以後,Vue 最終會以該對象爲參數建立一個構造函數,該構造函數就是生產組件實例的構造函數,因此在 Vue 中組件確實是函數,只不過那是最終結果罷了,在這以前咱們徹底能夠說在 Vue 中組件也能夠是一個普通對象,就像單文件組件中所導出的對象同樣。

基於此,咱們知道在 Vue 中一個組件能夠以純對象的形式存在,因此 Vue 中的高階組件能夠這樣定義:接收一個純對象,並返回一個新的純對象,以下代碼:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    template: '<wrapped v-on="$listeners" v-bind="$attrs"/>',
    components: {
      wrapped: WrappedComponent
    },
    mounted () {
      console.log('I have already mounted')
    }
  }
}
複製代碼

WithConsole 就是一個高階組件,它接收一個組件做爲參數:WrappedComponent,並返回一個新的組件。在新的組件定義中,咱們將 WrappedComponent 註冊爲 wrapped 組件,並在 template 中將其渲染出來,同時添加 mounted 鉤子,打印 I have already mounted

以上就完成了與 mixins 一樣的功能,不過這一次咱們採用的是高階組件,因此是非侵入式的,咱們沒有修改原組件(WrappedComponent),而是在新組件中渲染了原組件,而且沒有對原組件作任何修改。而且這裏你們要注意 $listeners$attrs

'<wrapped v-on="$listeners" v-bind="$attrs"/>'
複製代碼

這麼作是必須的,這就等價於在 React 中透傳 props

<WrappedComponent {...this.props}/>
複製代碼

不然在使用高階組件的時候,被包裝組件(WrappedComponent)接收不到 props事件

那這樣真的就完美解決問題了嗎?不是的,首先 template 選項只有在完整版的 Vue 中可使用,在運行時版本中是不能使用的,因此最起碼咱們應該使用渲染函數(render)替代模板(template),以下:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
      })
    }
  }
}
複製代碼

上面的代碼中,咱們將模板改寫成了渲染函數,看上去沒什麼問題,實則否則,上面的代碼中 WrappedComponent 組件依然收不到 props,有的同窗可能會問了,咱們不是已經在 h 函數的第二個參數中將 attrs 傳遞過去了嗎,怎麼還收不到?固然收不到,attrs 指的是那些沒有被聲明爲 props 的屬性,因此在渲染函數中還須要添加 props 參數:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}
複製代碼

那這樣是否是能夠了呢?依然不行,由於 this.$props 始終是空對象,這是由於這裏的 this.$props 指的是高階組件接收到的 props,而高階組件沒有聲明任何 props,因此 this.$props 天然是空對象啦,那怎麼辦呢?很簡單隻須要將高階組件的 props 設置與被包裝組件的 props 相同便可了:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}
複製代碼

如今纔是一個稍微完整可用的高階組件。你們注意用詞:稍微,納尼?都修改爲這樣了還不行嗎?固然,上面的高階組件能完成如下工做:

一、透傳 props 二、透傳沒有被聲明爲 props 的屬性 三、透傳事件

你們不以爲缺乏點兒什麼嗎?咱們前面說過,一個 Vue 組件的三個重要因素:props事件 以及 slots,前兩個都搞定了,但 slots 還不行。咱們修改 BaseComponent 組件爲其添加一個具名插槽和默認插槽,以下:

base-component.vue

<template>
  <div>
    <span @click="handleClick">props: {{test}}</span>
    <slot name="slot1"/> <!-- 具名插槽 -->
    <p>===========</p>
    <slot/> <!-- 默認插槽 -->
  </div>
</template>

<script>
export default {
  ...
}
</script>
複製代碼

而後咱們寫下以下測試代碼:

<template>
  <div>
    <base-component>
      <h2 slot="slot1">BaseComponent slot</h2>
      <p>default slot</p>
    </base-component>
    <enhanced-com>
      <h2 slot="slot1">EnhancedComponent slot</h2>
      <p>default slot</p>
    </enhanced-com>
  </div>
</template>

<script>
  import BaseComponent from './base-component.vue'
  import hoc from './hoc.js'

  const EnhancedCom = hoc(BaseComponent)

  export default {
    components: {
      BaseComponent,
      EnhancedCom
    }
  }
</script>
複製代碼

上圖中藍色框是 BaseComponent 組件渲染的內容,是正常的。紅色框是高階組件渲染的內容,能夠發現不管是具名插槽仍是默認插槽所有丟失。其緣由很簡單,就是由於咱們在高階組件中沒有將分發的插槽內容透傳給被包裝組件(WrappedComponent),因此咱們嘗試着修改高階組件:

hoc.js

function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {

      // 將 this.$slots 格式化爲數組,由於 h 函數第三個參數是子節點,是一個數組
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])

      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      }, slots) // 將 slots 做爲 h 函數的第三個參數
    }
  }
}
複製代碼

好啦,大功告成刷新頁面。

納尼😱?咱們發現,分發的內容確實是渲染出來了,不過貌似順序不太對。。。。。。藍色框是正常的,在具名插槽與默認插槽的中間是有分界線(===========)的,而紅色框中全部的插槽所有渲染到了分界線(===========)的下面,看上去貌似具名插槽也被做爲默認插槽處理了。這究竟是怎麼回事呢?

想弄清楚這個問題,就回到了文章開始時我提到的一點,即你須要對 Vue 的實現原理有所瞭解才行,不然無解。接下來就從原理觸發講解如何解決這個問題。這個問題的根源在於:Vue 在處理具名插槽的時候會考慮做用域的因素。不明白不要緊,咱們一點點分析。

首先補充一個提示:Vue 會把模板(template)編譯成渲染函數(render),好比以下模板:

<div>
  <h2 slot="slot1">BaseComponent slot</h2>
</div>
複製代碼

會被編譯成以下渲染函數:

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

想要查看一個組件的模板被編譯後的渲染函數很簡單,只須要在訪問 this.$options.render 便可。觀察上面的渲染函數咱們發現普通的 DOM 是經過 _c 函數建立對應的 VNode 的。如今咱們修改模板,模板中除了有普通 DOM 以外,還有組件,以下:

<div>
  <base-component>
    <h2 slot="slot1">BaseComponent slot</h2>
    <p>default slot</p>
  </base-component>
</div>
複製代碼

那麼生成的渲染函數(render)是這樣的:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    [
      _c("base-component", [
        _c("h2", { attrs: { slot: "slot1" }, slot: "slot1" }, [
          _vm._v("BaseComponent slot")
        ]),
        _vm._v(" "),
        _c("p", [_vm._v("default slot")])
      ])
    ],
    1
  )
}
複製代碼

咱們發現不管是普通DOM仍是組件,都是經過 _c 函數建立其對應的 VNode 的。其實 _cVue 內部就是 createElement 函數。createElement 函數會自動檢測第一個參數是否是普通DOM標籤,若是不是普通DOM標籤那麼 createElement 會將其視爲組件,而且建立組件實例,注意組件實例是這個時候才建立的。可是建立組件實例的過程當中就面臨一個問題:組件須要知道父級模板中是否傳遞了 slot 以及傳遞了多少,傳遞的是具名的仍是不具名的等等。那麼子組件如何才能得知這些信息呢?很簡單,假如組件的模板以下:

<div>
  <base-component>
    <h2 slot="slot1">BaseComponent slot</h2>
    <p>default slot</p>
  </base-component>
</div>
複製代碼

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

<base-component>
  <h2 slot="slot1">BaseComponent slot</h2>
  <p>default slot</p>
</base-component>
複製代碼

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

其中 this.$vnode 並無寫進 Vue 的官方文檔。子組件拿到了須要渲染的 slot 以後進入到了關鍵的一步,這一步就是致使高階組件中透傳 slotBaseComponent 卻沒法正確渲染的緣由。

這張圖與上一張圖相同,在子組件中打印 this.$vnode,標註中的 context 引用着 VNode 被建立時所在的組件實例,因爲 this.$vnode 中引用的 VNode 對象是在父組件中被建立的,因此 this.$vnode 中的 context 引用着父實例。理論上圖中標註的兩個 context 應該是相等的:

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // true
複製代碼

Vue 內部作了一件很重要的事兒,即上面那個表達式必須成立,纔可以正確處理具名 slot,不然即便 slot 具名也不會被考慮,而是被做爲默認插槽。這就是高階組件中不能正確渲染 slot 的緣由。

那麼爲何高階組件中上面的表達式就不成立了呢?那是由於因爲高階組件的引入,在本來的父組件與子組件之間插入了一個組件(也就是高階組件),這致使在子組件中訪問的 this.$vnode 已經不是原來的父組件中的 VNode 片斷了,而是高階組件的 VNode 片斷,因此此時 this.$vnode.context 引用的是高階組件,可是咱們卻將 slot 透傳,slot 中的 VNodecontext 引用的仍是原來的父組件實例,因此這就形成了如下表達式爲假:

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

最終致使具名插槽被做爲默認插槽,從而渲染不正確。

而解決辦法也很簡單,只須要手動設置一下 slotVNodecontext 值爲高階組件實例便可,修改高階組件以下:

hoc.js

function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.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)
    }
  }
}
複製代碼

如今,都可以正常渲染啦。

這裏的關鍵點除了你須要瞭解 Vue 處理 slot 的方式以外,你還要知道經過當前實例 _self 屬性訪問當實例自己,而不是直接使用 this,由於 this 是一個代理對象。

如今貌似看上去沒什麼問題了,不過咱們還忘記了一件事兒,即 scopedSlots,不過 scopedSlotsslot 的實現機制不同,本質上 scopedSlots 就是一個接收數據做爲參數並渲染 VNode 的函數,因此不存在 context 的概念,因此直接透傳便可:

hoc.js

function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      const slots = Object.keys(this.$slots)
        .reduce((arr, key) => arr.concat(this.$slots[key]), [])
        .map(vnode => {
          vnode.context = this._self
          return vnode
        })

      return h(WrappedComponent, {
        on: this.$listeners,
        props: this.$props,
        // 透傳 scopedSlots
        scopedSlots: this.$scopedSlots,
        attrs: this.$attrs
      }, slots)
    }
  }
}
複製代碼

到如今爲止,一個高階組件應該具有的基本功能算是實現了,但這僅僅是個開始,要實現一個完整健壯的 Vue 高階組件,還要考慮不少內容,好比:

函數式組件中要使用 render 函數的第二個參數代替 this。 以上咱們只討論了以純對象形式存在的 Vue 組件,然而除了純對象外還能夠函數。 建立 render 函數的不少步驟均可以進行封裝。 處理更多高階函數組件自己的選項(而不只僅是上面例子中的一個簡單的生命週期鉤子)

我以爲須要放上兩個關於高階組件的參考連接,供參考交流:

Discussion: Best way to create a HOC github.com/jackmellis/…

爲何在 Vue 中實現高階組件比較難

前面說過要分析一下爲何在 Vue 中實現高階組件比較複雜而 React 比較簡單。這主要是兩者的設計思想和設計目標不一樣,在 React 中寫組件就是在寫函數,函數擁有的功能組件都有。而 Vue 更像是高度封裝的函數,在更高的層面 Vue 可以讓你輕鬆的完成一些事情,但與高度的封裝相對的就是損失必定的靈活,你須要按照必定規則才能使系統更好的運行。

有句話說的好:

會了不難,難了不會

複雜仍是簡單都是相對而言的,最後但願你們玩的轉 Vue 也欣賞的了 React

原文: http://hcysun.me/2018/01/05/探索Vue高階組件/

相關文章
相關標籤/搜索