Vue元件的另一個重要概念是插槽,它允許你以一種不同於嚴格的父子關係的方式組合元件。插槽為你提供了一個將內容放置到新位置或使元件更通用的出口。這一節將圍繞官網對插槽內容的介紹思路,按照普通插槽,具名插槽,再到作用域插槽的思路,逐步深入內在的實現原理,有對插槽使用不熟悉的,可以先參考官網對插槽的介紹。
10.1 普通插槽
插槽將<slot></slot>
作為子元件承載分發的載體,簡單的用法如下
10.1.1 基礎用法
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>
複製程式碼
10.1.2 元件掛載原理
插槽的原理,貫穿了整個元件系統編譯到渲染的過程,所以首先需要回顧一下對元件相關編譯渲染流程,簡單總結一下幾點:
- 從根例項入手進行例項的掛載,如果有手寫的
render
函式,則直接進入$mount
掛載流程。 - 只有
template
模板則需要對模板進行解析,這裡分為兩個階段,一個是將模板解析為AST
樹,另一個是根據不同平臺生成執行程式碼,例如render
函式。 $mount
流程也分為兩步,第一步是將render
函式生成Vnode
樹,子元件會以vue-componet-
為tag
標記,另一步是把Vnode
渲染成真正的DOM節點。- 建立真實節點過程中,如果遇到子的佔位符元件會進行子元件的例項化過程,這個過程又將回到流程的第一步。
接下來我們對slot
的分析將圍繞這四個具體的流程展開,對元件流程的詳細分析,可以參考深入剖析Vue原始碼 - 元件基礎小節。
10.1.3 父元件處理
回到元件例項流程中,父元件會優先於子元件進行例項的掛載,模板的解析和render
函式的生成階段在處理上沒有特殊的差異,這裡就不展開分析。接下來是render
函式生成Vnode
的過程,在這個階段會遇到子的佔位符節點(即:child),因此會為子元件建立子的Vnode
。createComponent
執行了建立子佔位節點Vnode
的過程。我們把重點放在最終Vnode
程式碼的生成。
// 建立子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
屬性的形式存在,這是插槽分析的第一步。
10.1.4 子元件流程
父元件的最後一個階段是將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
中,這是第二步的重點。
接下來是initRender
階段,在這個過程會將配置的_renderChildren
屬性做規範化處理,並將他賦值給子例項上的$slot
屬性,這是第三步的重點。
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
屬性的形式儲存在子元件的例項中。
隨後子元件也會走掛載的流程,同樣會經歷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
元件。
最後一步就是子元件真實節點的渲染了,這點沒有什麼特別點,和以往介紹的流程一致。
至此,一個完整且簡單的插槽流程分析完畢。接下來看插槽深層次的用法。
10.2 具有後備內容的插槽
有時為一個插槽設定具體的後備 (也就是預設的) 內容是很有用的,它只會在沒有提供內容的時候被渲染。檢視原始碼發現後備內容插槽的邏輯也很好理解。
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
的過程,這個過程同樣會進行子作用域的模板編譯,因此兩部分內容是相對獨立的。
10.3 具名插槽
往往我們需要靈活的使用插槽進行通用元件的開發,要求父元件每個模板對應子元件中每個插槽,這時我們可以使用<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>
複製程式碼
接下來我們在普通插槽的基礎上,看看原始碼在具名插槽實現上的區別。
10.3.1 模板編譯的差別
父元件在編譯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
是一個返回執行結果的函式。
10.3.2 父元件vnode生成階段
照例進入父元件生成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
}]
}
複製程式碼
10.3.3 子元件渲染Vnode過程
子元件在解析成AST
樹階段的不同,在於對slot
標籤的name
屬性的解析,而在render
生成Vnode
過程中,slot
的規範化處理針對具名插槽會進行特殊的處理,回到normalizeScopedSlots
的程式碼
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots, // 此時的第一個引數會拿到父元件插槽相關的資料
vm.$slots, // 記錄父元件的插槽內容
vm.$scopedSlots
);
複製程式碼
最終子元件例項上的$scopedSlots
屬性會攜帶父元件插槽相關的內容。
// 子元件Vnode
{
$scopedSlots: [{
'header': f,
'footer': f
}]
}
複製程式碼
10.3.4 子元件渲染真實dom
和普通插槽類似,子元件渲染真實節點的過程會執行子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
找到了對應父元件的插槽內容。
10.4 作用域插槽
最後說說作用域插槽,我們可以利用作用域插槽讓父元件的插槽內容訪問到子元件的資料,具體的用法是在子元件中以屬性的方式記錄在子元件中,父元件通過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>`
})
複製程式碼
作用域插槽和具名插槽的原理類似,我們接著往下看。
10.4.1 父元件編譯階段
作用域插槽和具名插槽在父元件的用法基本相同,區別在於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)}
10.4.2 子元件渲染
在子元件編譯階段,: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
傳入便可以得到想要的結果。
10.4.3 思考
作用域插槽這個概念一開始我很難理解,單純從定義和原始碼的結論上看,父元件的插槽內容可以訪問到子元件的資料,這不是明顯的子父之間的資訊通訊嗎,在事件章節我們知道,子父元件之間的通訊完全可以通過事件$emit,$on
的形式來完成,那麼為什麼還需要增加一個插槽props
的概念呢。
我們看看作者的解釋。
插槽 prop 允許我們將插槽轉換為可複用的模板,這些模板可以基於輸入的 prop 渲染出不同的內容
從我自身的角度理解,作用域插槽提供了一種方式,當你需要封裝一個通用,可複用的邏輯模組,並且這個模組給外部使用者提供了一個便利,允許你在使用元件時自定義部分佈局,這時候作用域插槽就派上大用場了,再到具體的思想,我們可以看看幾個工具庫Vue Virtual Scroller Vue Promised對這一思想的應用。
- 深入剖析Vue原始碼 - 選項合併(上)
- 深入剖析Vue原始碼 - 選項合併(下)
- 深入剖析Vue原始碼 - 資料代理,關聯子父元件
- 深入剖析Vue原始碼 - 例項掛載,編譯流程
- 深入剖析Vue原始碼 - 完整渲染過程
- 深入剖析Vue原始碼 - 元件基礎
- 深入剖析Vue原始碼 - 元件進階
- 深入剖析Vue原始碼 - 響應式系統構建(上)
- 深入剖析Vue原始碼 - 響應式系統構建(中)
- 深入剖析Vue原始碼 - 響應式系統構建(下)
- 深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!
- 深入剖析Vue原始碼 - 揭祕Vue的事件機制
- 深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!
- 深入剖析Vue原始碼 - 你瞭解v-model的語法糖嗎?
- 深入剖析Vue原始碼 - Vue動態元件的概念,你會亂嗎?
- 徹底搞懂Vue中keep-alive的魔法(上)
- 徹底搞懂Vue中keep-alive的魔法(下)