系列文章
- [Vue源码学习] new Vue()
- [Vue源码学习] 配置合并
- [Vue源码学习] $mount挂载
- [Vue源码学习] _render(上)
- [Vue源码学习] _render(下)
- [Vue源码学习] _update(上)
- [Vue源码学习] _update(中)
- [Vue源码学习] _update(下)
- [Vue源码学习] 响应式原理(上)
- [Vue源码学习] 响应式原理(中)
- [Vue源码学习] 响应式原理(下)
- [Vue源码学习] props
- [Vue源码学习] computed
- [Vue源码学习] watch
- [Vue源码学习] 插槽(上)
- [Vue源码学习] 插槽(下)
前言
在Vue中,可以通过插槽实现内容的分发,在2.6.0版本之前,是通过slot和slot-scope选项,来区分普通插槽和作用域插槽,它们之间有很大的区别,普通插槽是在父实例执行render的过程中,立即创建插槽对应的VNode,而作用域插槽是在子实例执行render的过程中,通过normalizeScopedSlots方法解析data.scopedSlots创建其对应的VNode,为了统一实现插槽的功能,所以在2.6.0版本之后,Vue提供了v-slot指令,用来统一普通插槽和作用域插槽,现在只要使用v-slot指令,那么其内部的内容,就会自动定义为作用域插槽,也就是说,它们都是在子实例执行render的过程中,才去创建其对应的VNode,那么接下来,就根据一个简单的例子,来看看v-slot是如何实现插槽功能的,示例如下所示:
Vue.component('parent', {
template: `
<div class="parent">
<child>
<template v-slot:header>
<div>Parent Heaader</div>
</template>
<template v-slot:default="slotProps">
<div>Parent {{slotProps.message}}</div>
</template>
<template v-slot:footer>
<div>Parent Footer</div>
</template>
</child>
</div>
`
})
Vue.component('child', {
template: `
<div class="child">
<slot name="header">
<div>Header</div>
</slot>
<slot :message="message">
<div>Main</div>
</slot>
<slot name="footer">
<div>Footer</div>
</slot>
</div>
`,
data() {
return {
message: 'Main'
}
}
})
v-slot
首先来看看在编译的过程中,Vue是如何处理父模板中定义的v-slot指令的。
在编译的parse阶段,在解析开始标签时,会将标签上的每对属性保存在AST的attrsList和attrsMap中,当解析到对应的结束标签时,会调用processElement方法来处理AST上的属性,代码如下所示:
/* compiler/parser/index.js */
function closeElement(element) {
// ...
if (!inVPre && !element.processed) {
element = processElement(element, options)
}
// ...
}
export function processElement(
element: ASTElement,
options: CompilerOptions
) {
// ...
processSlotContent(element)
// ...
}
对于包含v-slot指令的AST节点来说,它会调用processSlotContent方法做进一步的处理,代码如下所示:
/* compiler/parser/index.js */
export const emptySlotScopeToken = `_empty_`
const slotRE = /^v-slot(:|$)|^#/
function processSlotContent(el) {
let slotScope
if (el.tag === 'template') {
slotScope = getAndRemoveAttr(el, 'scope')
// ...
el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
} else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
// ...
el.slotScope = slotScope
}
// slot="xxx"
const slotTarget = getBindingAttr(el, 'slot')
if (slotTarget) {
el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
// preserve slot as an attribute for native shadow DOM compat
// only for non-scoped slots.
if (el.tag !== 'template' && !el.slotScope) {
addAttr(el, 'slot', slotTarget, getRawBindingAttr(el, 'slot'))
}
}
// 2.6 v-slot syntax
if (process.env.NEW_SLOT_SYNTAX) {
if (el.tag === 'template') {
// v-slot on <template>
const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
if (slotBinding) {
// ...
const { name, dynamic } = getSlotName(slotBinding)
el.slotTarget = name
el.slotTargetDynamic = dynamic
el.slotScope = slotBinding.value || emptySlotScopeToken // force it into a scoped slot for perf
}
} else {
// v-slot on component, denotes default slot
const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
if (slotBinding) {
// ...
// add the component's children to its default slot
const slots = el.scopedSlots || (el.scopedSlots = {})
const { name, dynamic } = getSlotName(slotBinding)
const slotContainer = slots[name] = createASTElement('template', [], el)
slotContainer.slotTarget = name
slotContainer.slotTargetDynamic = dynamic
slotContainer.children = el.children.filter((c: any) => {
if (!c.slotScope) {
c.parent = slotContainer
return true
}
})
slotContainer.slotScope = slotBinding.value || emptySlotScopeToken
// remove children as they are returned from scopedSlots now
el.children = []
// mark el non-plain so data gets generated
el.plain = false
}
}
}
}
function getSlotName(binding) {
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
)
}
}
return dynamicArgRE.test(name)
// dynamic [name]
? { name: name.slice(1, -1), dynamic: true }
// static name
: { name: `"${name}"`, dynamic: false }
}
可以看到,在processSlotContent方法中,前面的两段代码是用来兼容2.6.0之前的逻辑,用来处理了scope、slot-scope、slot,之后的逻辑就是对新指令v-slot的处理。因为v-slot指令不仅可以添加到第一级<template>标签上,还可以添加到组件本身,所以Vue使用el.tag === 'template'来判断具体是哪一种情况,来做不同的处理。
-
如果
v-slot指令在<template>标签上,首先通过getAndRemoveAttrByRegex方法从AST.attrsList中获取v-slot对应的数据,如果存在v-slot指令,就通过getSlotName方法,从参数中提取对应的slotTarget和slotTargetDynamic,最后通过slotBinding.value取出对应的slotScope,如果v-slot指令中不包含插槽数据slotProps,就使用emptySlotScopeToken代替。例如<template v-slot:default="slotProps"></template>模板,取出的slotTarget就是default,slotTargetDynamic就是false,slotScope就是slotProps。 -
如果
v-slot指令在组件上,这时会创建一个新的AST节点slotContainer,将原先组件中的插槽内容移到slotContainer中,这样就如同前一种情况,在slotContainer上添加slotTarget、slotTargetDynamic、slotScope,不同的是,在构造完成后,会将slotContainer添加到组件AST的scopedSlots属性中,同时将组件AST的children置为空。
通过上面的处理,可以发现,只要使用了v-slot指令,此时的slotScope总是会取得一个值,在没有手动设置值时也会取得默认值emptySlotScopeToken,正是通过这个处理,Vue才可以统一使用作用域插槽。
调用完processElement方法后,此时AST节点上已经添加了slotTarget、slotTargetDynamic、slotScope等属性,回到最开始的closeElement方法:
/* compiler/parser/index.js */
function closeElement(element) {
// ...
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) {
// ...
} else {
if (element.slotScope) {
// scoped slot
// keep it in the children list so that v-else(-if) conditions can
// find it as the prev node.
const name = element.slotTarget || '"default"'
; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
}
currentParent.children.push(element)
element.parent = currentParent
}
}
// final children cleanup
// filter out scoped slots
element.children = element.children.filter(c => !(c: any).slotScope)
// ...
}
可以看到,当v-slot指令在<template>标签上时,template对应的AST节点会具有slotScope属性,那么就会在父AST节点,也就是组件AST节点的scopedSlots对象上,将当前template节点作为插槽添加进去。当所有的子节点都完成闭合操作后,就会执行父节点的闭合操作,由于插槽在本质上不属于子节点,而且在处理子节点的过程中,已经将插槽添加到父节点的scopedSlots中,所以在父节点闭合时会执行element.children.filter(c => !(c: any).slotScope),将所有的作用域插槽从父节点的children中移除,这样一来,作用域插槽就只能通过父节点的scopedSlots属性进行访问了。
在将模板转换成AST节点后,就会进行编译的generate阶段,用来将AST节点转换成最终的代码,在这个过程中,会调用genData方法,用来处理AST上的数据,当检测到AST上存在scopedSlots属性时,就会调用genScopedSlots方法来处理插槽,代码如下所示:
/* compiler/codegen/index.js */
export function genData(el: ASTElement, state: CodegenState): string {
// ...
// scoped slots
if (el.scopedSlots) {
data += `${genScopedSlots(el, el.scopedSlots, state)},`
}
// ...
}
function genScopedSlots(
el: ASTElement,
slots: { [key: string]: ASTElement },
state: CodegenState
): string {
// ...
const generatedSlots = Object.keys(slots)
.map(key => genScopedSlot(slots[key], state))
.join(',')
return `scopedSlots:_u([${generatedSlots}]${
needsForceUpdate ? `,null,true` : ``
}${
!needsForceUpdate && needsKey ? `,null,false,${hash(generatedSlots)}` : ``
})`
}
function genScopedSlot(
el: ASTElement,
state: CodegenState
): string {
const isLegacySyntax = el.attrsMap['slot-scope']
// ...
const slotScope = el.slotScope === emptySlotScopeToken
? ``
: String(el.slotScope)
const fn = `function(${slotScope}){` +
`return ${el.tag === 'template'
? el.if && isLegacySyntax
? `(${el.if})?${genChildren(el, state) || 'undefined'}:undefined`
: genChildren(el, state) || 'undefined'
: genElement(el, state)
}}`
// reverse proxy v-slot without scope on this.$slots
const reverseProxy = slotScope ? `` : `,proxy:true`
return `{key:${el.slotTarget || `"default"`},fn:${fn}${reverseProxy}}`
}
可以看到,在genScopedSlots方法中,就是遍历scopedSlots,然后调用genScopedSlot方法,将生成VNode的代码封装在一个函数中,同时,将slotScope的值作为此函数的第一个形参,所以在2.6.0中,只要使用了v-slot指令,插槽就会成为生成一个函数,也就是作用域插槽的形式,遍历完scopedSlots后,将生成的插槽数组用_u方法包裹,该方法会在运行时中再详细介绍。
经过parse和generate阶段的处理,Vue已经成功完成对父模板的编译过程,示例代码中对应的渲染函数如下所示:
function anonymous() {
with (this) {
return _c('div', {
staticClass: "parent"
}, [_c('child', {
scopedSlots: _u([{
key: "header",
fn: function () {
return [_c('div', [_v("Parent Heaader")])]
},
proxy: true
}, {
key: "default",
fn: function (slotProps) {
return [_c('div', [_v("Parent " + _s(slotProps.message))])]
}
}, {
key: "footer",
fn: function () {
return [_c('div', [_v("Parent Footer")])]
},
proxy: true
}])
})], 1)
}
}
上面的proxy属性表示虽然这里是作用域插槽的形式,但是在运行时需要将其代理到$slot中。
v-slot指令是在父组件中使用的,接下来就来看看在子组件中,Vue是如何处理<slot>标签的。
slot标签
同样是在编译的parse阶段,在处理结束标签时,会执行processElement方法,然后在其中又会执行processSlotOutlet方法,用来处理<slot>标签,代码如下所示:
/* compiler/parser/index.js */
export function processElement(
element: ASTElement,
options: CompilerOptions
) {
// ...
processSlotOutlet(element)
// ...
}
function processSlotOutlet(el) {
if (el.tag === 'slot') {
el.slotName = getBindingAttr(el, 'name')
if (process.env.NODE_ENV !== 'production' && el.key) {
warn(
`\`key\` does not work on <slot> because slots are abstract outlets ` +
`and can possibly expand into multiple elements. ` +
`Use the key on a wrapping element instead.`,
getRawBindingAttr(el, 'key')
)
}
}
}
可以看到,processSlotOutlet方法就是在<slot>标签上获取name选项,并赋值给AST的slotName属性。然后在编译的generate阶段,在genElement方法中,会对slot标签做特殊处理,代码如下所示:
/* compiler/codegen/index.js */
export function genElement(el: ASTElement, state: CodegenState): string {
// ...
if (el.tag === 'slot') {
return genSlot(el, state)
}
// ...
}
function genSlot(el: ASTElement, state: CodegenState): string {
const slotName = el.slotName || '"default"'
const children = genChildren(el, state)
let res = `_t(${slotName}${children ? `,${children}` : ''}`
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
const bind = el.attrsMap['v-bind']
if ((attrs || bind) && !children) {
res += `,null`
}
if (attrs) {
res += `,${attrs}`
}
if (bind) {
res += `${attrs ? '' : ',null'},${bind}`
}
return res + ')'
}
可以看到,当遇到slot标签时,会调用genSlot方法,在该方法中,首先会使用slotName作为插槽的名称,然后调用genChildren方法,生成此插槽对应的后备内容,然后调用genProps方法,从AST上获取待传入插槽函数的数据作为实参,最后使用_t方法包裹,该方法同样是在运行时中用来处理VNode节点的,之后再进行详细的分析。
经过上面的处理,Vue已经成功完成对子模板的编译过程,示例代码中对应的渲染函数如下所示:
function anonymous() {
with (this) {
return _c('div', {
staticClass: "child"
}, [
_t("header", [_c('div', [_v("Header")])]),
_v(" "),
_t("default", [_c('div', [_v("Main")])], {
"message": message
}),
_v(" "),
_t("footer", [_c('div', [_v("Footer")])])
], 2)
}
}
总结
在编译的过程中,Vue会对v-slot指令和<slot>标签做不同的处理,在处理v-slot指令时,会将插槽的内容构建成一个个的函数,然后使用_u函数包裹,最后赋值给组件AST的scopedSlots,<slot>标签会使用_t函数包裹,内部会传入对应的插槽名称、后备内容、参数等。编译的结果都是为运行时服务的,那么在下一章节中,我们再详细分析在运行时中Vue是如何处理插槽的。