高階組件(HOC
)是 React
生態系統的經常使用詞彙,React
中代碼複用的主要方式就是使用高階組件,而且這也是官方推薦的作法。而 Vue
中複用代碼的主要方式是使用 mixins
,而且在 Vue
中不多提到高階組件的概念,這是由於在 Vue
中實現高階組件並不像 React
中那樣簡單,緣由在於 React
和 Vue
的設計思想不一樣,但並非說在 Vue
中就不能使用高階組件,只不過在 Vue
中使用高階組件所帶來的收益相對於 mixins
並無質的變化。本篇文章主要從技術性的角度闡述 Vue
高階組件的實現,且會從 React
與 Vue
二者的角度進行分析。html
起初 React
也是使用 mixins
來完成代碼複用的,好比爲了不組件沒必要要的重複渲染咱們能夠在組件中混入 PureRenderMixin
:vue
const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
mixins: [PureRenderMixin]
})
複製代碼
後來 React
拋棄了這種方式,進而使用 shallowCompare
:node
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
,他們認爲 mixins
在 React
生態系統中並非一種好的模式(注意:並無說 mixins
很差,僅僅針對 React
生態系統),觀點以下:git
一、
mixins
帶來了隱式依賴 二、mixins
與mixins
之間,mixins
與組件之間容易致使命名衝突 三、因爲mixins
是侵入式的,它改變了原組件,因此修改mixins
等於修改原組件,隨着需求的增加mixins
將變得複雜,致使滾雪球的複雜性。github
具體你們能夠查看這篇文章 Mixins Considered Harmful。不過 HOC
也並非銀彈,它天然帶來了它的問題,有興趣的同窗能夠查看這個視頻:Michael Jackson - Never Write Another HoC,其觀點是:使用普通組件配合 render prop 能夠作任何 HOC 能作的事情。數組
本篇文章不會過多討論 mixins
和 HOC
誰好誰壞,就像技術自己就沒有好壞之分,只有適合不適合。難道 React
和 Vue
這倆哥們兒不也是這樣嗎🙂。bash
ok
,咱們回到高階組件,所謂高階組件其實就是高階函數啦,React
和 Vue
都證實了一件事兒:一個函數就是一個組件。因此組件是函數這個命題成立了,那高階組件很天然的就是高階函數,即一個返回函數的函數,咱們知道在 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
高階組件了,爲了讓你們有一個直觀的感覺,我仍然會使用 React
與 Vue
進行對比的講解。首先是一個基本的 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
組件主要觀察三點:props
、event
以及 slots
。對於 BaseComponent
組件而言,它接收一個數字類型的 props
即 test
,併發射一個自定義事件,事件的名稱是: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
的。其實 _c
在 Vue
內部就是 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
以後進入到了關鍵的一步,這一步就是致使高階組件中透傳 slot
給 BaseComponent
卻沒法正確渲染的緣由。
這張圖與上一張圖相同,在子組件中打印 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
中的 VNode
的 context
引用的仍是原來的父組件實例,因此這就形成了如下表達式爲假:
console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // false
複製代碼
最終致使具名插槽被做爲默認插槽,從而渲染不正確。
而解決辦法也很簡單,只須要手動設置一下 slot
中 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]), [])
// 手動更正 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
,不過 scopedSlots
與 slot
的實現機制不同,本質上 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
中實現高階組件比較複雜而 React
比較簡單。這主要是兩者的設計思想和設計目標不一樣,在 React
中寫組件就是在寫函數,函數擁有的功能組件都有。而 Vue
更像是高度封裝的函數,在更高的層面 Vue
可以讓你輕鬆的完成一些事情,但與高度的封裝相對的就是損失必定的靈活,你須要按照必定規則才能使系統更好的運行。
有句話說的好:
會了不難,難了不會
複雜仍是簡單都是相對而言的,最後但願你們玩的轉 Vue
也欣賞的了 React
。
原文: http://hcysun.me/2018/01/05/探索Vue高階組件/