本篇文章是細談 vue 系列第二篇了,上篇我們已經細談了 vue 的核心之一 vdom
vue
vdom
今天我們將分析我們經常使用的 vue 功能 slot 是如何設計和實現的,本文將圍繞 普通插槽 和 作用域插槽 以及 vue 2.6.x 版本的 v-slot 展開對該話題的討論。當然還不懂用法的同學建議官網先看看相關 API 先。接下來,我們直接進入正文吧
slot
普通插槽
作用域插槽
vue 2.6.x
v-slot
首先我們看一個我們對於 slot 最常用的例子
<template> <div class="slot-demo"> <slot>this is slot default content text.</slot> </div> </template>
然後我們直接使用,頁面則正常顯示一下內容
然後,這個時候我們使用的時候,對 slot 內容進行覆蓋
<slot-demo>this is slot custom content.</slot-demo>
內容則變成下圖所示
對於此,大家可能都能清楚的知道會是這種情況。今天我就將帶領大家直接看看 vue 底層對 slot 插槽的具體實現。
我們開始前,先看看 vue 的 Component 介面上對 $slots 屬性的定義
Component
$slots
$slots: { [key: string]: Array<VNode> };
多的咱不說,咱直接 console 一下上面例子中的 $slots
console
剩下的篇幅將講解 slot 內容如何進行渲染以及如何轉換成上圖內容
看完了具體實例中 slot 渲染後的 vm.$slots 對象,這一小篇我們直接看看 renderSlot 這塊的邏輯,首先我們先看看 renderSlot 函數的幾個參數都有哪些
vm.$slots
renderSlot
renderSlot()
export function renderSlot ( name: string, // 插槽名 slotName fallback: ?Array<VNode>, // 插槽默認內容生成的 vnode 數組 props: ?Object, // props 對象 bindObject: ?Object // v-bind 綁定對象 ): ?Array<VNode> {}
這裡我們先不看 scoped-slot 的邏輯,我們只看普通 slot 的邏輯。
scoped-slot
const slotNodes = this.$slots[name] nodes = slotNodes || fallback return nodes
這裡直接先取值 this.$slots[name] ,若存在則直接返回其對其的 vnode 數組,否則返回 fallback。看到這,很多人可能不知道 this.$slots 在哪定義的。解釋這個之前我們直接往後看另外一個方法
this.$slots[name]
vnode
fallback
this.$slots
renderslots()
export function resolveSlots ( children: ?Array<VNode>, // 父節點的 children context: ?Component // 父節點的上下文,即父組件的 vm 實例 ): { [key: string]: Array<VNode> } {}
看完 resolveSlots 的參數後我們接著往後過其中具體的邏輯。如果 children 參數不存在,直接返回一個空對象
resolveSlots
children
const slots = {} if (!children) { return slots }
如果存在,則直接對 children 進行遍歷操作
for (let i = 0, l = children.length; i < l; i++) { const child = children[i] const data = child.data // 如果 data.slot 存在,將插槽名稱當做 key,child 當做值直接添加到 slots 中去 if ((child.context === context || child.fnContext === context) && data && data.slot != null ) { const name = data.slot const slot = (slots[name] || (slots[name] = [])) // child 的 tag 為 template 標籤的情況 if (child.tag === template) { slot.push.apply(slot, child.children || []) } else { slot.push(child) } // 如果 data.slot 不存在,則直接將 child 丟到 slots.default 中去 } else { (slots.default || (slots.default = [])).push(child) } }
slots 獲取到值後,則進行一些過濾操作,然後直接返回有用的 slots
slots
// ignore slots that contains only whitespace for (const name in slots) { if (slots[name].every(isWhitespace)) { delete slots[name] } } return slots
// isWhitespace 相關邏輯 function isWhitespace (node: VNode): boolean { return (node.isComment && !node.asyncFactory) || node.text === }
數組 every() 方法傳送門 - Array.prototype.every()
every()
initRender()
我們從上面已經知道了 vue 對 slots 是如何進行賦值保存數據的。而在 src/core/instance/render.js 的 initRender 方法中則是對 vm.$slots 進行了初始化的賦值。
src/core/instance/render.js
initRender
const options = vm.$options const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree const renderContext = parentVnode && parentVnode.context vm.$slots = resolveSlots(options._renderChildren, renderContext)
genSlot()
了解了是 vm.$slots 這塊邏輯後,肯定有人會問:你這不就只是拿到了一個對象么,怎麼把其中的內容給搞出來呢?別急,我們接著就來講一下對於 slot 這塊 vue 是如何進行編譯的。這裡咱就把 slot generate 相關邏輯過上一過,話不多說,咱直接上代碼
slot generate
function genSlot (el: ASTElement, state: CodegenState): string { const slotName = el.slotName || "default" // 取 slotName,若無,則直接命名為 default const children = genChildren(el, state) // 對 children 進行 generate 操作 let res = `_t(${slotName}${children ? `,${children}` : }` const attrs = el.attrs && `{${el.attrs.map(a => `${camelize(a.name)}:${a.value}`).join(,)}}` // 將 attrs 轉換成對象形式 const bind = el.attrsMap[v-bind] // 獲取 slot 上的 v-bind 屬性 // 若 attrs 或者 bind 屬性存在但是 children 卻木得,直接賦值第二參數為 null if ((attrs || bind) && !children) { res += `,null` } // 若 attrs 存在,則將 attrs 作為 `_t()` 的第三個參數(普通插槽的邏輯處理) if (attrs) { res += `,${attrs}` } // 若 bind 存在,這時如果 attrs 存在,則 bind 作為第三個參數,否則 bind 作為第四個參數(scoped-slot 的邏輯處理) if (bind) { res += `${attrs ? : ,null},${bind}` } return res + ) }
註:上面的 slotName 在 src/compiler/parser/index.js 的 processSlot() 函數中進行了賦值,並且父組件編譯階段用到的slotTarget也在這裡進行了處理
slotName
src/compiler/parser/index.js
processSlot()
slotTarget
function processSlot (el) { if (el.tag === slot) { // 直接獲取 attr 裡面 name 的值 el.slotName = getBindingAttr(el, name) // ... } // ... const slotTarget = getBindingAttr(el, slot) if (slotTarget) { // 如果 slotTarget 存在則直接取命名插槽的 slot 值,否則直接為 default el.slotTarget = slotTarget === "" ? "default" : slotTarget if (el.tag !== template && !el.slotScope) { addAttr(el, slot, slotTarget) } } }
隨即在genData()中使用slotTarget進行data的數據拼接
genData()
data
if (el.slotTarget && !el.slotScope) { data += `slot:${el.slotTarget},` }
此時父組件將生成以下代碼
with(this) { return _c(div, [ _c(slot-demo), { attrs: { slot: default }, slot: default }, [ _v(this is slot custom content.) ] ]) }
然後當 el.tag 為 slot 的情況,則直接執行 genSlot()
el.tag
else if (el.tag === slot) { return genSlot(el, state) }
按照我們舉出的例子,則子組件最終會生成以下代碼
with(this) { // _c => createElement ; _t => renderSlot ; _v => createTextVNode return _c( div, { staticClass: slot-demo }, [ _t(default, [ _v(this is slot default content text.) ]) ] ) }
上面我們已經了解到 vue 對於普通的 slot 標籤是如何進行處理和轉換的。接下來我們來分析下作用域插槽的實現邏輯。
了解之前還是老規矩,先看看 vue 的 Component 介面上對 $scopedSlots 屬性的定義
$scopedSlots
$scopedSlots: { [key: string]: () => VNodeChildren };
其中的 VNodeChildren 定義如下:
VNodeChildren
declare type VNodeChildren = Array<?VNode | string | VNodeChildren> | string;
先來個相關的例子
<template> <div class="slot-demo"> <slot text="this is a slot demo , " :msg="msg"></slot> </div> </template>
<script> export default { name: SlotDemo, data () { return { msg: this is scoped slot content. } } } </script>
然後進行使用
<template> <div class="parent-slot"> <slot-demo> <template slot-scope="scope"> <p>{{ scope.text }}</p> <p>{{ scope.msg }}</p> </template> </slot-demo> </div> </template>
效果如下
從使用層面我們能看出來,子組件的 slot 標籤上綁定了一個 text 以及 :msg 屬性。然後父組件在使用插槽使用了 slot-scope 屬性去讀取插槽帶的屬性對應的值
text
:msg
slot-scope
註:提及一下 processSlot() 對於 slot-scope 的處理邏輯
let slotScope if (el.tag === template) { slotScope = getAndRemoveAttr(el, scope) // 兼容 2.5 以前版本 slot scope 的用法(這塊有個警告,我直接忽略掉了) el.slotScope = slotScope || getAndRemoveAttr(el, slot-scope) } else if ((slotScope = getAndRemoveAttr(el, slot-scope))) { el.slotScope = slotScope }
從上面的代碼我們能看出,vue 對於這塊直接讀取 slot-scope 屬性並賦值給 AST 抽象語法樹的 slotScope 屬性上。而擁有 slotScope 屬性的節點,會直接以 插槽名稱 name 為 key、本身為 value 的對象形式掛載在父節點的 scopedSlots 屬性上
slotScope
name
key
value
scopedSlots
else if (element.slotScope) { currentParent.plain = false const name = element.slotTarget || "default" ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element }
然後在 src/core/instance/render.js 的 renderMixin 方法中對 vm.$scopedSlots 則是進行了如下賦值:
renderMixin
vm.$scopedSlots
if (_parentVnode) { vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject }
然後 genData() 里會進行以下邏輯處理
if (el.scopedSlots) { data += `${genScopedSlots(el, el.scopedSlots, state)},` }
緊接著我們來看看 genScopedSlots 中的邏輯
genScopedSlots
function genScopedSlots ( slots: { [key: string]: ASTElement }, state: CodegenState ): string { // 對 el.scopedSlots 對象進行遍歷,執行 genScopedSlot,且將結果用逗號進行拼接 // _u => resolveScopedSlots (具體邏輯下面一個小節進行分析) return `scopedSlots:_u([${ Object.keys(slots).map(key => { return genScopedSlot(key, slots[key], state) }).join(,) }])` }
然後我們再來看看 genScopedSlot 是如何生成 render function 字元串的
genScopedSlot
render function
function genScopedSlot ( key: string, el: ASTElement, state: CodegenState ): string { if (el.for && !el.forProcessed) { return genForScopedSlot(key, el, state) } // 函數參數為標籤上 slot-scope 屬性對應的值 (getAndRemoveAttr(el, slot-scope)) const fn = `function(${String(el.slotScope)}){` + `return ${el.tag === template ? el.if ? `${el.if}?${genChildren(el, state) || undefined}:undefined` : genChildren(el, state) || undefined : genElement(el, state) }}` // key 為插槽名稱,fn 為生成的函數代碼 return `{key:${key},fn:${fn}}` }
我們把上面例子的 $scopedSlots 列印一下,結果如下
然後上面例子中父組件最終會生成如下代碼
with(this){ // _c => createElement ; _u => resolveScopedSlots // _v => createTextVNode ; _s => toString return _c(div, { staticClass: parent-slot }, [_c(slot-demo, { scopedSlots: _u([ { key: default, fn: function(scope) { return [ _c(p, [ _v(_s(scope.text)) ]), _c(p, [ _v(_s(scope.msg)) ]) ] } }]) } )] ) }
上面我們提及對於插槽 render 邏輯的時候忽略了 slot-scope 的相關邏輯,這裡我們來看看這部分內容
render
export function renderSlot ( name: string, fallback: ?Array<VNode>, props: ?Object, bindObject: ?Object ): ?Array<VNode> { const scopedSlotFn = this.$scopedSlots[name] let nodes if (scopedSlotFn) { // scoped slot props = props || {} // ... nodes = scopedSlotFn(props) || fallback } // ... return nodes }
resolveScopedSlots()
這裡我們看看 renderHelps 裡面的 _u ,即 resolveScopedSlots,其邏輯如下
renderHelps
_u
resolveScopedSlots
export function resolveScopedSlots ( fns: ScopedSlotsData, // Array<{ key: string, fn: Function } | ScopedSlotsData> res?: Object ): { [key: string]: Function } { res = res || {} // 遍歷 fns 數組,生成一個 `key 為插槽名稱,value 為函數` 的對象 for (let i = 0; i < fns.length; i++) { if (Array.isArray(fns[i])) { resolveScopedSlots(fns[i], res) } else { res[fns[i].key] = fns[i].fn } } return res }
genSlot
這塊會對 attrs 和 v-bind 進行,對於這塊內容上面我已經提過了,要看請往上翻閱。結合我們的例子,子組件則會生成以下代碼
attrs
v-bind
with(this) { return _c( div, { staticClass: slot-demo }, [ _t(default, null, { text: this is a slot demo , , msg: msg }) ] ) }
到目前為止,對於普通插槽和作用域插槽已經談的差不多了。接下來,我們將一起看看 vue 2.6.x 版本的 v-slot
vue 2.6.x 已經出來有一段時間了,其中對於插槽這塊則是放棄了 slot-scope 作用域插槽推薦寫法,直接改成了 v-slot 指令形式的推薦寫法(當然這只是個語法糖而已)。下面我們將仔細談談 v-slot 這塊的內容。
在看具體實現邏輯前,我們先通過一個例子來先了解下其基本用法
<template> <div class="slot-demo"> <slot name="demo">this is demo slot.</slot> <slot text="this is a slot demo , " :msg="msg"></slot> </div> </template>
<template> <slot-demo> <template v-slot:demo>this is custom slot.</template> <template v-slot="scope"> <p>{{ scope.text }}{{ scope.msg }}</p> </template> </slot-demo> </template>
頁面展示效果如下
看著好 easy 。
接下來,咱來會會這個新特性
$slots & $scopedSlots
$slots 這塊邏輯沒變,還是沿用的以前的代碼
// $slots const options = vm.$options const parentVnode = vm.$vnode = options._parentVnode const renderContext = parentVnode && parentVnode.context vm.$slots = resolveSlots(options._renderChildren, renderContext)
$scopedSlots 這塊則進行了改造,執行了 normalizeScopedSlots() 並接收其返回值為 $scopedSlots 的值
normalizeScopedSlots()
if (_parentVnode) { vm.$scopedSlots = normalizeScopedSlots( _parentVnode.data.scopedSlots, vm.$slots, vm.$scopedSlots ) }
接著,我們來會一會 normalizeScopedSlots ,首先我們先看看它的幾個參數
normalizeScopedSlots
export function normalizeScopedSlots ( slots: { [key: string]: Function } | void, // 某節點 data 屬性上 scopedSlots normalSlots: { [key: string]: Array<VNode> }, // 當前節點下的普通插槽 prevSlots?: { [key: string]: Function } | void // 當前節點下的特殊插槽 ): any {}
{}
if (!slots) { res = {} }
prevSlots
const hasNormalSlots = Object.keys(normalSlots).length > 0 // 是否擁有普通插槽 const isStable = slots ? !!slots.$stable : !hasNormalSlots // slots 上的 $stable 值 const key = slots && slots.$key // slots 上的 $key 值 else if ( isStable && prevSlots && prevSlots !== emptyObject && key === prevSlots.$key && // slots $key 值與 prevSlots $key 相等 !hasNormalSlots && // slots 中沒有普通插槽 !prevSlots.$hasNormal // prevSlots 中沒有普通插槽 ) { return prevSlots }
註:這裡的 $key , $hasNormal , $stable 是直接使用 vue 內部對 Object.defineProperty 封裝好的 def() 方法進行賦值的
$key
$hasNormal
$stable
Object.defineProperty
def()
def(res, $stable, isStable) def(res, $key, key) def(res, $hasNormal, hasNormalSlots)
normalSlots
normalizeScopedSlot
res
let res else { res = {} for (const key in slots) { if (slots[key] && key[0] !== $) { res[key] = normalizeScopedSlot(normalSlots, key, slots[key]) } } }
proxyNormalSlot
for (const key in normalSlots) { if (!(key in res)) { res[key] = proxyNormalSlot(normalSlots, key) } }
function proxyNormalSlot(slots, key) { return () => slots[key] }
normalizeScopedSlot()
fn
function normalizeScopedSlot(normalSlots, key, fn) { const normalized = function () { // 若參數為多個,則直接使用 arguments 作為 fn 的參數,否則直接傳空對象作為 fn 的參數 let res = arguments.length ? fn.apply(null, arguments) : fn({}) // fn 執行返回的 res 不是數組,則是單 vnode 的情況,賦值為 [res] 即可 // 否則執行 normalizeChildren 操作,這塊主要對針對 slot 中存在 v-for 操作 res = res && typeof res === object && !Array.isArray(res) ? [res] // single vnode : normalizeChildren(res) return res && ( res.length === 0 || (res.length === 1 && res[0].isComment) // slot 上 v-if 相關處理 ) ? undefined : res } // v-slot 語法糖處理 if (fn.proxy) { Object.defineProperty(normalSlots, key, { get: normalized, enumerable: true, configurable: true }) } return normalized }
這塊邏輯處理其實和之前是一樣的,只是刪除了一些警告的代碼而已。這點這裡就不展開敘述了
processSlot
首先,這裡解析 slot 的方法名從 processSlot 變成了 processSlotContent,但其實前面的邏輯和以前是一樣的。只是新增了一些對於 v-slot 的邏輯處理,下面我們就來捋捋這塊。過具體邏輯前,我們先看一些相關的正則和方法
processSlotContent
dynamicArgRE
const dynamicArgRE = /^[.*]$/ // 匹配到 [] 則為 true,如 [ item ]
slotRE
const slotRE = /^v-slot(:|$)|^#/ // 匹配到 v-slot 或 v-slot: 則為 true
getAndRemoveAttrByRegex
attr
export function getAndRemoveAttrByRegex ( el: ASTElement, name: RegExp // ) { const list = el.attrsList // attrsList 類型為 Array<ASTAttr> // 對 attrsList 進行遍歷,若有滿足 RegExp 的則直接返回當前對應的 attr // 若參數 name 傳進來的是 slotRE = /^v-slot(:|$)|^#/ // 那麼匹配到 v-slot 或者 v-slot:xxx 則會返回其對應的 attr for (let i = 0, l = list.length; i < l; i++) { const attr = list[i] if (name.test(attr.name)) { list.splice(i, 1) return attr } } }
ASTAttr
declare type ASTAttr = { name: string; value: any; dynamic?: boolean; start?: number; end?: number };
createASTElement
ASTElement
export function createASTElement ( tag: string, // 標籤名 attrs: Array<ASTAttr>, // attrs 數組 parent: ASTElement | void // 父節點 ): ASTElement { return { type: 1, tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent, children: [] } }
getSlotName
function getSlotName (binding) { // v-slot:item 匹配獲取到 item let name = binding.name.replace(slotRE, ) if (!name) { if (binding.name[0] !== #) { name = default } else if (process.env.NODE_ENV !== production) { warn( `v-slot shorthand syntax requires a slot name.`, binding ) } } // 返回一個 key 包含 name,dynamic 的對象 // v-slot:[item] 匹配然後 replace 後獲取到 name = [item] // 進而進行動態參數進行匹配 dynamicArgRE.test(name) 結果為 true return dynamicArgRE.test(name) ? { name: name.slice(1, -1), dynamic: true } // 截取變數,如 [item] 截取後變成 item : { name: `"${name}"`, dynamic: false } }
這裡我們先看看 slot 對於 template 是如何處理的
template
if (el.tag === template) { // 匹配綁定在 template 上的 v-slot 指令,這裡會匹配到對應 v-slot 的 attr(類型為 ASTAttr) const slotBinding = getAndRemoveAttrByRegex(el, slotRE) // 若 slotBinding 存在,則繼續進行 slotName 的正則匹配 // 隨即將匹配出來的 name 賦值給 slotTarget,dynamic 賦值給 slotTargetDynamic // slotScope 賦值為 slotBinding.value 或者 _empty_ if (slotBinding) { const { name, dynamic } = getSlotName(slotBinding) el.slotTarget = name el.slotTargetDynamic = dynamic el.slotScope = slotBinding.value || emptySlotScopeToken } }
如果不是 template,而是綁定在 component 上的話,對於 v-slot 指令和 slotName 的匹配操作是一樣的,不同點在於由於這裡需要將組件的 children 添加到其默認插槽中去
component
else { // v-slot on component 表示默認插槽 const slotBinding = getAndRemoveAttrByRegex(el, slotRE) // 將組件的 children 添加到其默認插槽中去 if (slotBinding) { // 獲取當前組件的 scopedSlots const slots = el.scopedSlots || (el.scopedSlots = {}) // 匹配拿到 slotBinding 中 name,dynamic 的值 const { name, dynamic } = getSlotName(slotBinding) // 獲取 slots 中 key 對應匹配出來 name 的 slot // 然後再其下面創建一個標籤名為 template 的 ASTElement,attrs 為空數組,parent 為當前節點 const slotContainer = slots[name] = createASTElement(template, [], el) // 這裡 name、dynamic 統一賦值給 slotContainer 的 slotTarget、slotTargetDynamic,而不是 el slotContainer.slotTarget = name slotContainer.slotTargetDynamic = dynamic // 將當前節點的 children 添加到 slotContainer 的 children 屬性中 slotContainer.children = el.children.filter((c: any) => { if (!c.slotScope) { c.parent = slotContainer return true } }) slotContainer.slotScope = slotBinding.value || emptySlotScopeToken // 清空當前節點的 children el.children = [] el.plain = false } }
這樣處理後我們就可以直接在父組件上面直接使用 v-slot 指令去獲取 slot 綁定的值。舉個官方例子來表現一下
<!-- old --> <foo> <template slot-scope="{ msg }"> {{ msg }} </template> </foo>
<!-- new --> <foo v-slot="{ msg }"> {{ msg }} </foo>
<!-- old --> <foo> <div slot-scope="{ msg }"> {{ msg }} </div> </foo>
<!-- new --> <foo v-slot="{ msg }"> <div> {{ msg }} </div> </foo>
更多例子請點擊 new-slot-syntax 自行查閱
generate
genSlot() 在這塊邏輯也沒發生本質性的改變,唯一一個改變就是為了支持 v-slot 動態參數做了些改變,具體如下
// old const attrs = el.attrs && `{${el.attrs.map(a => `${camelize(a.name)}:${a.value}`).join(,)}}`
// new // attrs、dynamicAttrs 進行 concat 操作,並執行 genProps 將其轉換成對應的 generate 字元串 const attrs = el.attrs || el.dynamicAttrs ? genProps( (el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({ // slot props are camelized name: camelize(attr.name), value: attr.value, dynamic: attr.dynamic })) ) : null
文章到這,對於 普通插槽、作用域插槽、v-slot 基本用法以及其背後實現原理的相關內容已經是結束了,還想要深入了解的同學,可自行查閱 vue 源碼進行研究。
老規矩,文章末尾打上波廣告
前端交流群:731175396
群里不定期進行視頻或語音的技術類分享,歡迎小夥伴吧上車,帥哥美女等著你們呢。
然後,emmm,我要去打籃球了 ~
推薦閱讀: