探索Vue高階組件的使用
高階組件( HOC )是 React 生態(tài)系統(tǒng)的常用詞匯, React 中代碼復用的主要方式就是使用高階組件,并且這也是官方推薦的做法。而 Vue 中復用代碼的主要方式是使用 mixins ,并且在 Vue 中很少提到高階組件的概念,這是因為在 Vue 中實現(xiàn)高階組件并不像 React 中那樣簡單,原因在于 React 和 Vue 的設計思想不同,但并不是說在 Vue 中就不能使用高階組件,只不過在 Vue 中使用高階組件所帶來的收益相對于 mixins 并沒有質(zhì)的變化。本篇文章主要從技術性的角度闡述 Vue 高階組件的實現(xiàn),且會從 React 與 Vue 兩者的角度進行分析。
從 React 說起
起初 React 也是使用 mixins 來完成代碼復用的,比如為了避免組件不必要的重復渲染我們可以在組件中混入 PureRenderMixin :
const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
mixins: [PureRenderMixin]
})
后來 React 拋棄了這種方式,進而使用 shallowCompare :
const shallowCompare = require('react-addons-shallow-compare')
const Button = React.createClass({
shouldComponentUpdate: function(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
}
})
這需要你自己在組件中實現(xiàn) shouldComponentUpdate 方法,只不過這個方法具體的工作由 shallowCompare 幫你完成,即淺比較。
再后來 React 為了避免開發(fā)者在組件中總是要寫這樣一段同樣的代碼,進而推薦使用 React.PureComponent ,總之 React 在一步步的脫離 mixins ,他們認為 mixins 在 React 生態(tài)系統(tǒng)中并不是一種好的模式(注意:并沒有說 mixins 不好,僅僅針對 React 生態(tài)系統(tǒng)),觀點如下:
1、 mixins 帶來了隱式依賴
2、 mixins 與 mixins 之間, mixins 與組件之間容易導致命名沖突
3、由于 mixins 是侵入式的,它改變了原組件,所以修改 mixins 等于修改原組件,隨著需求的增長 mixins 將變得復雜,導致滾雪球的復雜性。
具體大家可以查看這篇文章 Mixins Considered Harmful 。不過 HOC 也并不是銀彈,它自然帶來了它的問題,其觀點是: 使用普通組件配合 render prop 可以做任何 HOC 能做的事情 。
本篇文章不會過多討論 mixins 和 HOC 誰好誰壞,就像技術本身就沒有好壞之分,只有適合不適合。難道 React 和 Vue 這倆哥們兒不也是這樣嗎🙂。
ok ,我們回到高階組件,所謂高階組件其實就是高階函數(shù)啦, React 和 Vue 都證明了一件事兒: 一個函數(shù)就是一個組件 。所以組件是函數(shù)這個命題成立了,那高階組件很自然的就是高階函數(shù),即一個返回函數(shù)的函數(shù),我們知道在 React 中寫高階組件就是在寫高階函數(shù),很簡單,那是不是在 Vue 中實現(xiàn)高階組件也同樣簡單呢?其實 Vue 稍微復雜,甚至需要你對 Vue 足夠了解,接下來就讓我們一塊在 Vue 中實現(xiàn)高階組件,在文章的后面會分析為什么同樣都是 函數(shù)就是組件 的思想, Vue 卻不能像 React 那樣輕松的實現(xiàn)高階組件。
也正因如此所以我們有必要在實現(xiàn) Vue 高階組件之前充分了解 React 中的高階組件,看下面的 React 代碼:
function WithConsole (WrappedComponent) {
return class extends React.Component {
componentDidMount () {
console.log('with console: componentDidMount')
}
render () {
return <WrappedComponent {...this.props}/>
}
}
}
WithConsole 就是一個高階組件,它有以下幾個特點:
1、高階組件( HOC )應該是無副作用的純函數(shù),且不應該修改原組件
可以看到 WithConsole 就是一個純函數(shù),它接收一個組件作為參數(shù)并返回了一個新的組件,在新組件的 render 函數(shù)中僅僅渲染了被包裝的組件( WrappedComponent ),并沒有侵入式的修改它。
2、高階組件( HOC )不關心你傳遞的數(shù)據(jù)( props )是什么,并且被包裝組件( WrappedComponent )不關心數(shù)據(jù)來源
這是保證高階組件與被包裝組件能夠完美配合的根本
3、高階組件( HOC )接收到的 props 應該透傳給被包裝組件( WrappedComponent )
高階組件完全可以添加、刪除、修改 props ,但是除此之外,要將其余 props 透傳,否則在層級較深的嵌套關系中( 這是高階組件的常見問題 )將造成 props 阻塞。
以上是 React 中高階組件的基本約定,除此之外還要注意其他問題,如:高階組件( HOC )不應該在 render 函數(shù)中創(chuàng)建;高階組件( HOC )也需要復制組件中的靜態(tài)方法;高階組件( HOC )中的 ref 引用的是最外層的容器組件而不是被包裝組件( WrappedComponent ) 等等。
Vue 中的高階組件
了解了這些,接下來我們就可以開始著手實現(xiàn) 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 組件而言,它接收一個數(shù)字類型的 props 即 test ,并發(fā)射一個自定義事件,事件的名稱是: customize-click ,沒有 slots 。我們會這樣使用該組件:
<base-component @customize-click="handleCustClick" :test="100" />
現(xiàn)在我們需要 base-component 組件每次掛載完成的時候都打印一句話: I have already mounted ,同時這也許是很多組件的需求,所以按照 mixins 的方式,我們可以這樣做,首先定義個 mixins :
export default {
name: 'BaseComponent',
props: {
test: Number
},
mixins: [ consoleMixin ]
methods: {
handleClick () {
this.$emit('customize-click')
}
}
}
然后在 BaseComponent 組件中將 consoleMixin 混入:
export default {
name: 'BaseComponent',
props: {
test: Number
},
mixins: [ consoleMixin ]
methods: {
handleClick () {
this.$emit('customize-click')
}
}
}
這樣使用 BaseComponent 組件的時候,每次掛載完成之后都會打印一句 I have already mounted ,不過現(xiàn)在我們要使用高階組件的方式實現(xiàn)同樣的功能,回憶高階組件的定義: 接收一個組件作為參數(shù),返回一個新的組件 ,那么此時我們需要思考的是,在 Vue 中組件是什么?有的同學可能會有疑問,難道不是函數(shù)嗎?對, Vue 中組件是函數(shù)沒有問題,不過那是最終結(jié)果,比如我們在單文件組件中的組件定義其實就是一個普通的選項對象,如下:
export default {
name: 'BaseComponent',
props: {...},
mixins: [...]
methods: {...}
}
這不就是一個純對象嗎?所以當我們從單文件中導入一個組件的時候:
import BaseComponent from './base-component.vue' console.log(BaseComponent)
思考一下,這里的 BaseComponent 是什么?它是函數(shù)嗎?不是,雖然單文件組件會被 vue-loader 處理,但處理后的結(jié)果,也就是我們這里的 BaseComponent 仍然還是一個普通的 JSON 對象,只不過當你把這個對象注冊為組件( components 選項)之后, Vue 最終會以該對象為參數(shù)創(chuàng)建一個構造函數(shù),該構造函數(shù)就是生產(chǎn)組件實例的構造函數(shù),所以在 Vue 中組件確實是函數(shù),只不過那是最終結(jié)果罷了,在這之前我們完全可以說在 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 就是一個高階組件,它接收一個組件作為參數(shù): 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 中可以使用,在運行時版本中是不能使用的,所以最起碼我們應該使用渲染函數(shù)( 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,
})
}
}
}
上面的代碼中,我們將模板改寫成了渲染函數(shù),看上去沒什么問題,實則不然,上面的代碼中 WrappedComponent 組件依然收不到 props ,有的同學可能會問了,我們不是已經(jīng)在 h 函數(shù)的第二個參數(shù)中將 attrs 傳遞過去了嗎,怎么還收不到?當然收不到, attrs 指的是那些沒有被聲明為 props 的屬性,所以在渲染函數(shù)中還需要添加 props 參數(shù):
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
})
}
}
}
現(xiàn)在才是一個稍微完整可用的高階組件。大家注意用詞: 稍微 ,納尼?都修改成這樣了還不行嗎?當然,上面的高階組件能完成以下工作:
1、透傳 props
2、透傳沒有被聲明為 props 的屬性
3、透傳事件
大家不覺得缺少點兒什么嗎?我們前面說過,一個 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>
渲染結(jié)果如下:

上圖中藍色框是 BaseComponent 組件渲染的內(nèi)容,是正常的。紅色框是高階組件渲染的內(nèi)容,可以發(fā)現(xiàn)無論是具名插槽還是默認插槽全部丟失。其原因很簡單,就是因為我們在高階組件中沒有將分發(fā)的插槽內(nèi)容透傳給被包裝組件( WrappedComponent ),所以我們嘗試著修改高階組件:
hoc.js
function WithConsole (WrappedComponent) {
return {
mounted () {
console.log('I have already mounted')
},
props: WrappedComponent.props,
render (h) {
// 將 this.$slots 格式化為數(shù)組,因為 h 函數(shù)第三個參數(shù)是子節(jié)點,是一個數(shù)組
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 函數(shù)的第三個參數(shù)
}
}
}
好啦,大功告成刷新頁面,如下:

納尼:scream:?我們發(fā)現(xiàn),分發(fā)的內(nèi)容確實是渲染出來了,不過貌似順序不太對。。。。。。藍色框是正常的,在具名插槽與默認插槽的中間是有分界線( =========== )的,而紅色框中所有的插槽全部渲染到了分界線( =========== )的下面,看上去貌似具名插槽也被作為默認插槽處理了。這到底是怎么回事呢?
想弄清楚這個問題,就回到了文章開始時我提到的一點,即你需要對 Vue 的實現(xiàn)原理有所了解才行,否則無解。接下來就從原理觸發(fā)講解如何解決這個問題。這個問題的根源在于: Vue 在處理具名插槽的時候會考慮作用域的因素 。不明白沒關系,我們一點點分析。
首先補充一個提示: Vue 會把模板( template )編譯成渲染函數(shù)( render ) ,比如如下模板:
<div> <h2 slot="slot1">BaseComponent slot</h2> </div>
會被編譯成如下渲染函數(shù):
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") ]) ])}
想要查看一個組件的模板被編譯后的渲染函數(shù)很簡單,只需要在訪問 this.$options.render 即可。觀察上面的渲染函數(shù)我們發(fā)現(xiàn)普通的 DOM 是通過 _c 函數(shù)創(chuàng)建對應的 VNode 的?,F(xiàn)在我們修改模板,模板中除了有普通 DOM 之外,還有組件,如下:
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")
])
])
}
那么生成的渲染函數(shù)( render )是這樣的:
<div> <base-component> <h2 slot="slot1">BaseComponent slot</h2> <p>default slot</p> </base-component> </div>
我們發(fā)現(xiàn)無論是普通DOM還是組件,都是通過 _c 函數(shù)創(chuàng)建其對應的 VNode 的。其實 _c 在 Vue 內(nèi)部就是 createElement 函數(shù)。 createElement 函數(shù)會自動檢測第一個參數(shù)是不是普通DOM標簽,如果不是普通DOM標簽那么 createElement 會將其視為組件,并且創(chuàng)建組件實例, 注意組件實例是這個時候才創(chuàng)建的 。但是創(chuàng)建組件實例的過程中就面臨一個問題: 組件需要知道父級模板中是否傳遞了 slot 以及傳遞了多少,傳遞的是具名的還是不具名的等等 。那么子組件如何才能得知這些信息呢?很簡單,假如組件的模板如下:
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
)
}
父組件的模板最終會生成父組件對應的 VNode ,所以以上模板對應的 VNode 全部由父組件所有,那么在創(chuàng)建子組件實例的時候能否通過獲取父組件的 VNode 進而拿到 slot 的內(nèi)容呢?即通過父組件將下面這段模板對應的 VNode 拿到:
<div> <base-component> <h2 slot="slot1">BaseComponent slot</h2> <p>default slot</p> </base-component> </div>
如果能夠通過父級拿到這段模板對應的 VNode ,那么子組件就知道要渲染哪些 slot 了,其實 Vue 內(nèi)部就是這么干的,實際上你可以通過訪問子組件的 this.$vnode 來獲取這段模板對應的 VNode :

其中 this.$vnode 并沒有寫進 Vue 的官方文檔。子組件拿到了需要渲染的 slot 之后進入到了關鍵的一步,這一步就是導致高階組件中透傳 slot 給 BaseComponent 卻無法正確渲染的原因,看下圖:

這張圖與上一張圖相同,在子組件中打印 this.$vnode ,標注中的 context 引用著 VNode 被創(chuàng)建時所在的組件實例,由于 this.$vnode 中引用的 VNode 對象是在父組件中被創(chuàng)建的,所以 this.$vnode 中的 context 引用著父實例。理論上圖中標注的兩個 context 應該是相等的:
console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // true
而 Vue 內(nèi)部做了一件很重要的事兒,即上面那個表達式必須成立,才能夠正確處理具名 slot ,否則即使 slot 具名也不會被考慮,而是被作為默認插槽。這就是高階組件中不能正確渲染 slot 的原因。
那么為什么高階組件中上面的表達式就不成立了呢?那是因為由于高階組件的引入,在原本的父組件與子組件之間插入了一個組件( 也就是高階組件 ),這導致在子組件中訪問的 this.$vnode 已經(jīng)不是原來的父組件中的 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)
}
}
}
現(xiàn)在,都能夠正常渲染啦,如下圖:

這里的關鍵點除了你需要了解 Vue 處理 slot 的方式之外,你還要知道通過當前實例 _self 屬性訪問當實例本身,而不是直接使用 this ,因為 this 是一個代理對象。
現(xiàn)在貌似看上去沒什么問題了,不過我們還忘記了一件事兒,即 scopedSlots ,不過 scopedSlots 與 slot 的實現(xiàn)機制不一樣,本質(zhì)上 scopedSlots 就是一個接收數(shù)據(jù)作為參數(shù)并渲染 VNode 的函數(shù),所以不存在 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)
}
}
}
到現(xiàn)在為止,一個高階組件應該具備的基本功能算是實現(xiàn)了,但這僅僅是個開始,要實現(xiàn)一個完整健壯的 Vue 高階組件,還要考慮很多內(nèi)容,比如:
函數(shù)式組件中要使用 render 函數(shù)的第二個參數(shù)代替 this 。
以上我們只討論了以純對象形式存在的 Vue 組件,然而除了純對象外還可以函數(shù)。
創(chuàng)建 render 函數(shù)的很多步驟都可以進行封裝。
處理更多高階函數(shù)組件本身的選項( 而不僅僅是上面例子中的一個簡單的生命周期鉤子 )
我覺得需要放上兩個關于高階組件的參考鏈接,供參考交流:
Discussion: Best way to create a HOC
https://github.com/jackmellis/vue-hoc
為什么在 Vue 中實現(xiàn)高階組件比較難
前面說過要分析一下為什么在 Vue 中實現(xiàn)高階組件比較復雜而 React 比較簡單。這主要是二者的設計思想和設計目標不同,在 React 中寫組件就是在寫函數(shù),函數(shù)擁有的功能組件都有。而 Vue 更像是高度封裝的函數(shù),在更高的層面 Vue 能夠讓你輕松的完成一些事情,但與高度的封裝相對的就是損失一定的靈活,你需要按照一定規(guī)則才能使系統(tǒng)更好的運行。
有句話說的好:
會了不難,難了不會
復雜還是簡單都是相對而言的,最后希望大家玩的轉(zhuǎn) Vue 也欣賞的了 React 。放上兩張我比較認同的圖片供各位看官討論:


相關文章
vue項目中data數(shù)據(jù)之間互相訪問的實現(xiàn)
本文主要介紹了vue項目中data數(shù)據(jù)之間互相訪問的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2022-05-05
vue 微信分享回調(diào)iOS和安卓回調(diào)出現(xiàn)錯誤的解決
這篇文章主要介紹了vue 微信分享回調(diào)iOS和安卓回調(diào)出現(xiàn)錯誤的解決,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-09-09
Vue如何使用百度地圖自定義信息窗口InfoWindow的樣式
這篇文章主要介紹了Vue如何使用百度地圖自定義信息窗口InfoWindow的樣式問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-03-03
在vue中使用screenfull?依賴,實現(xiàn)全屏組件方式
這篇文章主要介紹了在vue中使用screenfull?依賴,實現(xiàn)全屏組件方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-12-12
vue 實現(xiàn)setInterval 創(chuàng)建和銷毀實例
這篇文章主要介紹了vue 實現(xiàn)setInterval 創(chuàng)建和銷毀實例,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-07-07
vue Element-ui input 遠程搜索與修改建議顯示模版的示例代碼
本文分為html,js和css代碼給大家詳細介紹了vue Element-ui input 遠程搜索與修改建議顯示模版功能,感興趣的朋友一起看看吧2017-10-10

