Vue組件的另外一個重要概念是插槽,它容許你以一種不一樣於嚴格的父子關係的方式組合組件。插槽爲你提供了一個將內容放置到新位置或使組件更通用的出口。這一節將圍繞官網對插槽內容的介紹思路,按照普通插槽,具名插槽,再到做用域插槽的思路,逐步深刻內在的實現原理,有對插槽使用不熟悉的,能夠先參考官網對插槽的介紹。html
插槽將<slot></slot>
做爲子組件承載分發的載體,簡單的用法以下vue
var child = {
template: `<div class="child"><slot></slot></div>`
}
var vm = new Vue({
el: '#app',
components: {
child
},
template: `<div id="app"><child>test</child></div>`
})
// 最終渲染結果
<div class="child">test</div>
複製代碼
插槽的原理,貫穿了整個組件系統編譯到渲染的過程,因此首先須要回顧一下對組件相關編譯渲染流程,簡單總結一下幾點:node
render
函數,則直接進入$mount
掛載流程。template
模板則須要對模板進行解析,這裏分爲兩個階段,一個是將模板解析爲AST
樹,另外一個是根據不一樣平臺生成執行代碼,例如render
函數。$mount
流程也分爲兩步,第一步是將render
函數生成Vnode
樹,子組件會以vue-componet-
爲tag
標記,另外一步是把Vnode
渲染成真正的DOM節點。接下來咱們對slot
的分析將圍繞這四個具體的流程展開,對組件流程的詳細分析,能夠參考深刻剖析Vue源碼 - 組件基礎小節。git
回到組件實例流程中,父組件會優先於子組件進行實例的掛載,模板的解析和render
函數的生成階段在處理上沒有特殊的差別,這裏就不展開分析。接下來是render
函數生成Vnode
的過程,在這個階段會遇到子的佔位符節點(即:child),所以會爲子組件建立子的Vnode
。createComponent
執行了建立子佔位節點Vnode
的過程。咱們把重點放在最終Vnode
代碼的生成。github
// 建立子Vnode過程
function createComponent (
Ctor, // 子類構造器
data,
context, // vm實例
children, // 父組件須要分發的內容
tag // 子組件佔位符
){
···
// 建立子vnode,其中父保留的children屬性會以選項的形式傳遞給Vnode
var vnode = new VNode(
("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
data, undefined, undefined, undefined, context,
{ Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
asyncFactory
);
}
// Vnode構造器
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {
···
this.componentOptions = componentOptions; // 子組件的選項相關
}
複製代碼
createComponent
函數接收的第四個參數children
就是父組件須要分發的內容。在建立子Vnode
過程當中,會以會componentOptions
配置傳入Vnode
構造器中。最終Vnode
中父組件須要分發的內容以componentOptions
屬性的形式存在,這是插槽分析的第一步。算法
父組件的最後一個階段是將Vnode
渲染爲真正的DOM節點,在這個過程當中若是遇到子Vnode
會優先實例化子組件並進行一系列子組件的渲染流程。子組件初始化會先調用init
方法,而且和父組件不一樣的是,子組件會調用initInternalComponent
方法拿到父組件擁有的相關配置信息,並賦值給子組件自身的配置選項。數組
// 子組件的初始化
Vue.prototype._init = function(options) {
if (options && options._isComponent) {
initInternalComponent(vm, options);
}
initRender(vm)
}
function initInternalComponent (vm, options) {
var opts = vm.$options = Object.create(vm.constructor.options);
var parentVnode = options._parentVnode;
opts.parent = options.parent;
opts._parentVnode = parentVnode;
// componentOptions爲子vnode記錄的相關信息
var vnodeComponentOptions = parentVnode.componentOptions;
opts.propsData = vnodeComponentOptions.propsData;
opts._parentListeners = vnodeComponentOptions.listeners;
// 父組件須要分發的內容賦值給子選項配置的_renderChildren
opts._renderChildren = vnodeComponentOptions.children;
opts._componentTag = vnodeComponentOptions.tag;
if (options.render) {
opts.render = options.render;
opts.staticRenderFns = options.staticRenderFns;
}
}
複製代碼
最終在子組件實例的配置中拿到了父組件保存的分發內容,記錄在組件實例$options._renderChildren
中,這是第二步的重點。promise
接下來是initRender
階段,在這個過程會將配置的_renderChildren
屬性作規範化處理,並將他賦值給子實例上的$slot
屬性,這是第三步的重點。bash
function initRender(vm) {
···
vm.$slots = resolveSlots(options._renderChildren, renderContext);// $slots拿到了子佔位符節點的_renderchildren(即須要分發的內容),保留做爲子實例的屬性
}
function resolveSlots (children,context) {
// children是父組件須要分發到子組件的Vnode節點,若是不存在,則沒有分發內容
if (!children || !children.length) {
return {}
}
var slots = {};
for (var i = 0, l = children.length; i < l; i++) {
var child = children[i];
var data = child.data;
// remove slot attribute if the node is resolved as a Vue slot node
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot;
}
// named slots should only be respected if the vnode was rendered in the
// same context.
// 分支1爲具名插槽的邏輯,放後分析
if ((child.context === context || child.fnContext === context) &&
data && data.slot != null
) {
var name = data.slot;
var slot = (slots[name] || (slots[name] = []));
if (child.tag === 'template') {
slot.push.apply(slot, child.children || []);
} else {
slot.push(child);
}
} else {
// 普通插槽的重點,核心邏輯是構造{ default: [children] }對象返回
(slots.default || (slots.default = [])).push(child);
}
}
return slots
}
複製代碼
其中普通插槽的處理邏輯核心在(slots.default || (slots.default = [])).push(child);
,即以數組的形式賦值給default
屬性,並以$slot
屬性的形式保存在子組件的實例中。app
隨後子組件也會走掛載的流程,一樣會經歷template
模板到render
函數,再到Vnode
,最後渲染真實DOM
的過程。解析AST
階段,slot
標籤和其餘普通標籤處理相同,不一樣之處在於AST
生成render
函數階段,對slot
標籤的處理,會使用_t函數
進行包裹。這是關鍵步驟的第四步
子組件渲染的大體流程簡單梳理以下
// ast 生成 render函數
var code = generate(ast, options);
// generate實現
function generate(ast, options) {
var state = new CodegenState(options);
var code = ast ? genElement(ast, state) : '_c("div")';
return {
render: ("with(this){return " + code + "}"),
staticRenderFns: state.staticRenderFns
}
}
// genElement實現
function genElement(el, state) {
// 針對slot標籤的處理走```genSlot```分支
if (el.tag === 'slot') {
return genSlot(el, state)
}
}
// 核心genSlot原理
function genSlot (el, state) {
// slotName記錄着插槽的惟一標誌名,默認爲default
var slotName = el.slotName || '"default"';
// 若是子組件的插槽還有子元素,則會遞歸調執行子元素的建立過程
var children = genChildren(el, state);
// 經過_t函數包裹
var res = "_t(" + slotName + (children ? ("," + children) : '');
// 具名插槽的其餘處理
···
return res + ')'
}
複製代碼
最終子組件的render
函數爲: "with(this){return _c('div',{staticClass:"child"},[_t("default")],2)}"
第五步到了子組件渲染爲Vnode
的過程。render
函數執行階段會執行_t()
函數,_t
函數是renderSlot
函數簡寫,它會在Vnode樹中進行分發內容的替換,具體看看實現邏輯。
// target._t = renderSlot;
// render函數渲染Vnode函數
Vue.prototype._render = function() {
var _parentVnode = ref._parentVnode;
if (_parentVnode) {
// slots的規範化處理並賦值給$scopedSlots屬性。
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots, // 記錄父組件的插槽內容
vm.$scopedSlots
);
}
}
複製代碼
normalizeScopedSlots
的邏輯較長,但並非本節的重點。拿到$scopedSlots
屬性後會執行真正的render
函數,其中_t
的執行邏輯以下:
// 渲染slot組件內容
function renderSlot (
name,
fallback, // slot插槽後備內容(針對後備內容)
props, // 子傳給父的值(做用域插槽)
bindObject
) {
// scopedSlotFn拿到父組件插槽的執行函數,默認slotname爲default
var scopedSlotFn = this.$scopedSlots[name];
var nodes;
// 具名插槽分支(暫時忽略)
if (scopedSlotFn) { // scoped slot
props = props || {};
if (bindObject) {
if (!isObject(bindObject)) {
warn(
'slot v-bind without argument expects an Object',
this
);
}
props = extend(extend({}, bindObject), props);
}
// 執行時將子組件傳遞給父組件的值傳入fn
nodes = scopedSlotFn(props) || fallback;
} else {
// 若是父佔位符組件沒有插槽內容,this.$slots不會有值,此時vnode節點爲後備內容節點。
nodes = this.$slots[name] || fallback;
}
var target = props && props.slot;
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
複製代碼
renderSlot
執行過程會拿到父組件須要分發的內容,最終Vnode
樹將父元素的插槽替換掉子組件的slot
組件。
最後一步就是子組件真實節點的渲染了,這點沒有什麼特別點,和以往介紹的流程一致。
至此,一個完整且簡單的插槽流程分析完畢。接下來看插槽深層次的用法。
有時爲一個插槽設置具體的後備 (也就是默認的) 內容是頗有用的,它只會在沒有提供內容的時候被渲染。查看源碼發現後備內容插槽的邏輯也很好理解。
var child = {
template: `<div class="child"><slot>後備內容</slot></div>`
}
var vm = new Vue({
el: '#app',
components: {
child
},
template: `<div id="app"><child></child></div>`
})
// 父沒有插槽內容,子的slot會渲染後備內容
<div class="child">後備內容</div>
複製代碼
父組件沒有須要分發的內容,子組件會默認顯示插槽裏面的內容。源碼中的不一樣體如今下面的幾點。
componentOptions.children
屬性來記錄內容。$slot
屬性的內容.render
函數最後在_t
函數參數會攜帶第二個參數,該參數以數組的形式傳入slot
插槽的後備內容。例with(this){return _c('div',{staticClass:"child"},[_t("default",[_v("test")])],2)}
Vnode
會執行renderSlot(_t)
函數時,第二個參數fallback
有值,且this.$slots
沒值,vnode
會直接返回後備內容做爲渲染對象。function renderSlot (
name,
fallback, // slot插槽後備內容(針對後備內容)
props, // 子傳給父的值(做用域插槽)
bindObject
){
if() {
···
}else{
//fallback爲後備內容
// 若是父佔位符組件沒有插槽內容,this.$slots不會有值,此時vnode節點爲後備內容節點。
nodes = this.$slots[name] || fallback;
}
}
複製代碼
最終,在父組件沒有提供內容時,slot
的後備內容被渲染。
父級模板裏的全部內容都是在父級做用域中編譯的;子模板裏的全部內容都是在子做用域中編譯的。
父組件模板的內容在父組件編譯階段就肯定了,而且保存在componentOptions
屬性中,而子組件有自身初始化init
的過程,這個過程一樣會進行子做用域的模板編譯,所以兩部份內容是相對獨立的。
每每咱們須要靈活的使用插槽進行通用組件的開發,要求父組件每一個模板對應子組件中每一個插槽,這時咱們可使用<slot>
的name
屬性,一樣舉個簡單的例子。
var child = {
template: `<div class="child"><slot name="header"></slot><slot name="footer"></slot></div>`,
}
var vm = new Vue({
el: '#app',
components: {
child
},
template: `<div id="app"><child><template v-slot:header><span>頭部</span></template><template v-slot:footer><span>底部</span></template></child></div>`,
})
複製代碼
渲染結果:
<div class="child"><span>頭部</span><span>底部</span></div>
複製代碼
接下來咱們在普通插槽的基礎上,看看源碼在具名插槽實現上的區別。
父組件在編譯AST
階段和普通節點的過程不一樣,具名插槽通常會在template
模板中用v-slot:
來標註指定插槽,這一階段會在編譯階段特殊處理。最終的AST
樹會攜帶scopedSlots
用來記錄具名插槽的內容
{
scopedSlots: {
footer: { ··· },
header: { ··· }
}
}
複製代碼
AST
生成render
函數的過程也不詳細分析了,咱們只分析父組件最終返回的結果(若是對parse, generate
感興趣的同窗,能夠直接看源碼分析,編譯階段冗長且難以講解,跳過這部分分析)
with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"header",fn:function(){return [_c('span',[_v("頭部")])]},proxy:true},{key:"footer",fn:function(){return [_c('span',[_v("底部")])]},proxy:true}])})],1)}
複製代碼
很明顯,父組件的插槽內容用_u
函數封裝成數組的形式,並賦值到scopedSlots
屬性中,而每個插槽以對象形式描述,key
表明插槽名,fn
是一個返回執行結果的函數。
照例進入父組件生成Vnode
階段,其中_u
函數的原形是resolveScopedSlots
,其中第一個參數就是插槽數組。
// vnode生成階段針對具名插槽的處理 _u (target._u = resolveScopedSlots)
function resolveScopedSlots (fns,res,hasDynamicKeys,contentHashKey) {
res = res || { $stable: !hasDynamicKeys };
for (var i = 0; i < fns.length; i++) {
var slot = fns[i];
// fn是數組須要遞歸處理。
if (Array.isArray(slot)) {
resolveScopedSlots(slot, res, hasDynamicKeys);
} else if (slot) {
// marker for reverse proxying v-slot without scope on this.$slots
if (slot.proxy) { // 針對proxy的處理
slot.fn.proxy = true;
}
// 最終返回一個對象,對象以slotname做爲屬性,以fn做爲值
res[slot.key] = slot.fn;
}
}
if (contentHashKey) {
(res).$key = contentHashKey;
}
return res
}
複製代碼
最終父組件的vnode
節點的data
屬性上多了scopedSlots
數組。回顧一下,具名插槽和普通插槽實現上有明顯的不一樣,普通插槽是以componentOptions.child
的形式保留在父組件中,而具名插槽是以scopedSlots
屬性的形式存儲到data
屬性中。
// vnode
{
scopedSlots: [{
'header': fn,
'footer': fn
}]
}
複製代碼
子組件在解析成AST
樹階段的不一樣,在於對slot
標籤的name
屬性的解析,而在render
生成Vnode
過程當中,slot
的規範化處理針對具名插槽會進行特殊的處理,回到normalizeScopedSlots
的代碼
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots, // 此時的第一個參數會拿到父組件插槽相關的數據
vm.$slots, // 記錄父組件的插槽內容
vm.$scopedSlots
);
複製代碼
最終子組件實例上的$scopedSlots
屬性會攜帶父組件插槽相關的內容。
// 子組件Vnode
{
$scopedSlots: [{
'header': f,
'footer': f
}]
}
複製代碼
和普通插槽相似,子組件渲染真實節點的過程會執行子render
函數中的_t
方法,這部分的源碼會和普通插槽走不一樣的分支,其中this.$scopedSlots
根據上面分析會記錄着父組件插槽內容相關的數據,因此會和普通插槽走不一樣的分支。而最終的核心是執行nodes = scopedSlotFn(props)
,也就是執行function(){return [_c('span',[_v("頭部")])]}
,具名插槽之因此是函數的形式執行而不是直接返回結果,咱們在後面揭曉。
function renderSlot (
name,
fallback, // slot插槽後備內容
props, // 子傳給父的值
bindObject
){
var scopedSlotFn = this.$scopedSlots[name];
var nodes;
// 針對具名插槽,特色是$scopedSlots有值
if (scopedSlotFn) { // scoped slot
props = props || {};
if (bindObject) {
if (!isObject(bindObject)) {
warn('slot v-bind without argument expects an Object',this);
}
props = extend(extend({}, bindObject), props);
}
// 執行時將子組件傳遞給父組件的值傳入fn
nodes = scopedSlotFn(props) || fallback;
}···
}
複製代碼
至此子組件經過slotName
找到了對應父組件的插槽內容。
最後說說做用域插槽,咱們能夠利用做用域插槽讓父組件的插槽內容訪問到子組件的數據,具體的用法是在子組件中以屬性的方式記錄在子組件中,父組件經過v-slot:[name]=[props]
的形式拿到子組件傳遞的值。子組件<slot>
元素上的特性稱爲插槽Props
,另外,vue2.6之後的版本已經棄用了slot-scoped
,採用v-slot
代替。
var child = {
template: `<div><slot :user="user"></div>`,
data() {
return {
user: {
firstname: 'test'
}
}
}
}
var vm = new Vue({
el: '#app',
components: {
child
},
template: `<div id="app"><child><template v-slot:default="slotProps">{{slotProps.user.firstname}}</template></child></div>`
})
複製代碼
做用域插槽和具名插槽的原理相似,咱們接着往下看。
做用域插槽和具名插槽在父組件的用法基本相同,區別在於v-slot
定義了一個插槽props
的名字,參考對於具名插槽的分析,生成render
函數階段fn
函數會攜帶props
參數傳入。即: with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"default",fn:function(slotProps){return [_v(_s(slotProps.user.firstname))]}}])})],1)}
在子組件編譯階段,:user="user"
會以屬性的形式解析,最終在render
函數生成階段以對象參數的形式傳遞_t
函數。 with(this){return _c('div',[_t("default",null,{"user":user})],2)}
子組件渲染Vnode階段,根據前面分析會執行renderSlot
函數,這個函數前面分析過,對於做用域插槽的處理,集中體如今函數傳入的第三個參數。
// 渲染slot組件vnode
function renderSlot(
name,
fallback,
props, // 子傳給父的值 { user: user }
bindObject
) {
// scopedSlotFn拿到父組件插槽的執行函數,默認slotname爲default
var scopedSlotFn = this.$scopedSlots[name];
var nodes;
// 具名插槽分支
if (scopedSlotFn) { // scoped slot
props = props || {};
if (bindObject) {
if (!isObject(bindObject)) {
warn(
'slot v-bind without argument expects an Object',
this
);
}
// 合併props
props = extend(extend({}, bindObject), props);
}
// 執行時將子組件傳遞給父組件的值傳入fn
nodes = scopedSlotFn(props) || fallback;
}
複製代碼
最終將子組件的插槽props
做爲參數傳遞給執行函數執行。回過頭看看爲何具名插槽是函數的形式執行而不是直接返回結果。學完做用域插槽咱們發現這就是設計巧妙的地方,函數的形式讓執行過程更加靈活,做用域插槽只須要以參數的形式將插槽props
傳入即可以獲得想要的結果。
做用域插槽這個概念一開始我很難理解,單純從定義和源碼的結論上看,父組件的插槽內容能夠訪問到子組件的數據,這不是明顯的子父之間的信息通訊嗎,在事件章節咱們知道,子父組件之間的通訊徹底能夠經過事件$emit,$on
的形式來完成,那麼爲何還須要增長一個插槽props
的概念呢。 咱們看看做者的解釋。
插槽 prop 容許咱們將插槽轉換爲可複用的模板,這些模板能夠基於輸入的 prop 渲染出不一樣的內容
從我自身的角度理解,做用域插槽提供了一種方式,當你須要封裝一個通用,可複用的邏輯模塊,而且這個模塊給外部使用者提供了一個便利,容許你在使用組件時自定義部分佈局,這時候做用域插槽就派上大用場了,再到具體的思想,咱們能夠看看幾個工具庫Vue Virtual Scroller Vue Promised對這一思想的應用。