一、高階組件介紹
vue 高階組件的認識,在React中組件是以復用代碼實現的,而Vue中是以mixins 實現,並且官方文檔中也缺少一些高階組件的概念,因為在vue中實現高階組很困難,並不像React簡單,其實vue中mixins也同樣可以代替,在讀了一部分源碼之后,對vue有了更深的認識。
所謂高階組件其實就是高階函數啦,React
和 Vue
都證明了一件事兒:一個函數就是一個組件。所以組件是函數這個命題成立了。所謂高階組件其實就是一個高階函數, 即返回一個組件函數的函數,Vue中怎么實現呢? 注意高階組件有如下特點
- 高階組件(HOC)應該是無副作用的純函數,且不應該修改原組件,即原組件不能有變動
- 高階組件(HOC)不關心你傳遞的數據(props)是什么,並且新生成組件不關心數據來源
- 高階組件(HOC)接收到的 props 應該透傳給被包裝組件即直接將原組件prop傳給包裝組件
- 高階組件完全可以添加、刪除、修改 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 是一個代理對象。