废弃的 API 不在文章内容范围内。
基本用法
<!-- 组件定义 -->
<template>
<div id="slot-component">
<slot></slot>
<slot name="hasNameSlot"></slot>
<slot name="hasNameAndScopeSlot" :text="text"></slot>
</div>
</template>
<!-- 使用插槽 -->
<template>
<slot-component>
<span>我会去默认插槽</span>
<tempalte #hasNameSlot>我会去第一个具名插槽</tempalte>
<template #hasNameAndScopeSlot="{text}">{{text}}</template>
</slot-component>
</template>
原理解析
AST
插槽 与 其他正常的写法组件,最大的区别的起点实际是在 生成 AST 阶段 开始的,我们可以查看一下上述示例中 使用插槽 部分生成 AST 内容(忽略了没有意义的内容)。
主要特点 是,两个明确为具名插槽的节点,并不在 slot-component
的 children
里面,而是在其 scopedSlots
内以 key-value 的形式存储着。
原因 是解析器会在每个节点词法分析完后,会对其进行语法分析,其中有一个步骤就是对节点有可能有的插槽相关属性进行分析。具体执行栈大约是这样的:baseCompile -> parse -> parseHTML -> options.start -> options.end -> closeElement -> processSlotContent
{
"tag": "slot-component",
"children": [
{
"type": 1,
"tag": "div",
"children": [
{
"type": 3,
"text": "我会去默认插槽"
}
],
}
],
"scopedSlots": {
"\"hasNameSlot\"": {
"tag": "template",
"attrsMap": {
"#hasNameSlot": ""
},
"children": [
{
"type": 1,
"tag": "div",
"children": [
{
"type": 3,
"text": "我会去第一个具名插槽"
}
],
}
],
"slotScope": "_empty_",
"slotTarget": "\"hasNameSlot\"",
"slotTargetDynamic": false
},
"\"hasNameAndScopeSlot\"": {
"tag": "template",
"attrsMap": {
"#hasNameAndScopeSlot": "{ text }"
},
"children": [
{
"tag": "div",
"children": [
{
"expression": "_s(text)",
"tokens": [
{
"@binding": "text"
}
],
"text": "{{ text }}"
}
],
"plain": true
}
],
"slotScope": "{ text }",
"slotTarget": "\"hasNameAndScopeSlot\"",
"slotTargetDynamic": false
}
},
}
Code generate
AST 生成后紧接着就是 code generate,生成是当前 AST 对应的 render 函数,让我们看看插槽部分和其他节点有什么不同。
从结构上来说,大体与 AST 保持一致,插槽的内容并没有到 children
部分,二是跑到了 节点属性 上,同时插槽内的内容变成的 函数式组件,相应的我们可以发现,作用域插槽就是通过调用这个 函数式组件,并 传入参数 而完成的。由于作用域的关系,函数内的变量会临时覆盖 this 上相同 key 的值,所以保证的语法的一致性。
具体的调用栈大约是这样的:baseCompiler -> generate -> genElement -> genData -> genScopedSlots
with (this) {
return _c(
"slot-component",
{
scopedSlots: _u([
{
key: "hasnameslot",
fn: function () {
return [_c("div", [_v("我会去第一个具名插槽")])];
},
proxy: true,
},
{
key: "hasnameandscopeslot",
fn: function ({ text }) {
return [_c("div", [_v(_s(text))])];
},
},
]),
},
[_c("div", [_v("我会去默认插槽")])]
)
}
Render
接下来一步是 Vue 是如何生成对应的虚拟 DOM 的呢,我们先需要去尝试解读一下生成的 render 函数里面的比较重要的函数 _c
、 _u
_c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// 入参格式化
export function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
// 函数重载处理,如果 data 为数组或者基本数据类型,则视 data 实际缺省
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
// 开发者手动写的 render 函数才会为 true,此参数决定以什么模式格式化内容
if (isTrue(alwaysNormalize)) {
normalizationType = ALWAYS_NORMALIZE
}
return _createElement(context, tag, data, children, normalizationType)
}
export function _createElement ( context, tag, data, children, normalizationType) {
// 如果 data(属性)为响应式的数据则抛出错误,返回空的虚拟 DOM
if (isDef(data) && isDef((data: any).__ob__)) {
process.env.NODE_ENV !== 'production' && warn(
`Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
'Always create fresh vnode data objects in each render!',
context
)
return createEmptyVNode()
}
// component is 的写法处理
if (isDef(data) && isDef(data.is)) {
tag = data.is
}
// 如果 tag 为空则返回空的虚拟 DOM
if (!tag) {
return createEmptyVNode()
}
// 如果 key 值不是基本数据类型则抛出错误
if (process.env.NODE_ENV !== 'production' &&
isDef(data) && isDef(data.key) && !isPrimitive(data.key)
) {
if (!__WEEX__ || !('@binding' in data.key)) {
warn(
'Avoid using non-primitive value as key, ' +
'use string/number value instead.',
context
)
}
}
// 如果有 children 并且第一个是函数的话,则将第一个 child 转移到 scopedSlots.default 中,并清空 children
if (Array.isArray(children) &&
typeof children[0] === 'function'
) {
data = data || {}
data.scopedSlots = { default: children[0] }
children.length = 0
}
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children) // 复杂的规范化处理,因为 render 是开发者写的
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children) // 简单的规范化处理(拍平可能出现的嵌套数组 children)
}
let vnode, ns
if (typeof tag === 'string') {
// 标签为 string 时
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// 如果是原生标签
// 则进行 v-on 的错误判断
if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
warn(
`The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
context
)
}
// 生成平台相对应的虚拟 DOM
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果不是原生标签同时相应的标签名称在 components 中定义了,则视为组件,并传入对应的构造函数,创建函数 VNode
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 未知的标签,不管三七二十一直接用 tag 生成
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// 不是字符串则视为组件的构造函数等,直接创建函数 VNode
vnode = createComponent(tag, data, context, children)
}
if (Array.isArray(vnode)) {
return vnode
} else if (isDef(vnode)) {
if (isDef(ns)) applyNS(vnode, ns)
if (isDef(data)) registerDeepBindings(data)
return vnode
} else {
return createEmptyVNode()
}
}
_u = resolveScopedSlots
// 将数值的 scopedSlots 转换成 key-value 的形式,同时加上一些渲染优化相关的属性
// 参数内容由 codegen 时的 genScopedSlots 来决定
// fns,就是插槽内容集合
// res,正常都是初始值都为空,只不过递归处理的时候需要传递下去
// hasDynamicKeys,通常情况下为 false(稳定的),如果相关节点有动态属性或者内容则为 true,这意味着需要在父节点更新的时候需要强制更新
// contentHashKey,与第三个参数互斥,如果祖父组件有 v-if,则会有 key 值
export function resolveScopedSlots (fns, res, hasDynamicKeys, contentHashKey) {
// 初始化时根据 hasDynamicKeys 来判断是否是稳定的
res = res || { $stable: !hasDynamicKeys }
// 遍历 scopedSlots 里的内容
for (let i = 0; i < fns.length; i++) {
const slot = fns[i]
if (Array.isArray(slot)) {
// 如果还是数组则递归处理
resolveScopedSlots(slot, res, hasDynamicKeys)
} else if (slot) {
// 如果是动态的则在渲染函数上也添加相关静态属性
if (slot.proxy) {
slot.fn.proxy = true
}
res[slot.key] = slot.fn
}
}
// 有则加
if (contentHashKey) {
(res: any).$key = contentHashKey
}
return res
}
VNode
基于上述讲解生成的虚拟DOM
{
tag: 'vue-component-1-slot-component',
componentOptions: {
Ctor: f VueComponent(options),
children: [divVNode],
tag: 'slot-component',
listeners: undefined,
propsData: undefined
},
context: {...VueInstance},
data: {
hook: {...VNodeHooks},
on: undefined,
scopedSlots: {
$stable: true,
hasnameandscopeslot: f({ text }),
hasnameslot: f()
}
},
...otherKeys,
}
组件如何处理父组件传下来的插槽内容
经过上面的讲解,我们可以直接跳过 AST,查看 render 函数
with (this) {
return _c(
"div",
{ attrs: { id: "slot-component" } },
[
_t("default"),
_v(" "),
_t("hasNameSlot"),
_v(" "),
_t("hasNameAndScopeSlot", null, { text: text }),
],
2
);
}
很明显关键点就在 _t
_t = renderSlot
export function renderSlot (name, fallback, props, bindObject) {
// this 指向的就是上面生成的组件的实例,$scopedSlots 指向的就是我们上面折腾半天的内容
// 先查看有没有传递下来相关的插槽
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) {
// 如果有对应插槽
// props 便是作用域内容
props = props || {}
// 如果绑定的是个对象则抛出错误并合并
if (bindObject) {
if (process.env.NODE_ENV !== 'production' && !isObject(bindObject)) {
warn(
'slot v-bind without argument expects an Object',
this
)
}
props = extend(extend({}, bindObject), props)
}
// 调用渲染函数,传入参数,得到虚拟 VNode
nodes = scopedSlotFn(props) || fallback
} else {
// 退而求其次去组件的插槽找
nodes = this.$slots[name] || fallback
}
// 嵌套插槽处理
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
默认插槽是如何处理的
根据上面的分析,之后其实最大的问题就是,children 如何加入到 $scopeSlots 中,这个其实分两步走
- 第一步:实例化 slot-component 组件阶段
- 在 initRender 阶段通过 resloveSlots 初始化组件的 this.$slots
- 这个时候 this.$slots = {default: children}
- 第二步:slot-component 生成 VNode 之前的准备工作时
- 在 Vue.proptotype._render 回先判断有没有父级节点,如果有则初始化 $scopeSlots
- $scopeSlots 的初始化是通过 normalizeScopedSlots 函数将
_parentVnode.data.scopedSlots
、this.$slot
、this.$scopedSlots(一般为空)
合并 - 这个时候 this.$scopedSlots 就会有 default 指向父节点的 children,以及父组件的获得的具名插槽
在初始化完完成后,通过获取 this.$scopedSlots.default 就可以获取到默认插槽的内容啦!