上一篇:Vue2.x 源码 - 异步组件
Vue 的组件提供了⼀个⾮常有⽤的特性 —— slot 插槽,它让组件的实现变的更加灵活。我们平时在 开发组件库的时候,为了让组件更加灵活可定制,经常⽤插槽的⽅式让⽤户可以⾃定义内容。
插槽分为:默认插槽、匿名插槽 、命名插槽 和 作⽤域插槽,它们可以解决不同的场景,但它是怎么实现的呢,下⾯我们就从源码的⾓ 度来分析插槽的实现原理。
Vue 2.6.0之后采用全新v-slot语法取代之前的slot、slot-scope
源码
组件生命周期
slot 并不是独立的一个模块,而是贯穿整个编译、渲染的过程中;
1、Vue 组件渲染过程:父beforeCreate => 父created => 父beforeMount => 子beforeCreate => 子created => 子beforeMount => 子mounted => 父mounted
2、Vue 组件更新过程:父beforeUpdate=> 子beforeUpdate=> 子updated=> 父updated
3、Vue 组件销毁过程:父beforeDestroy=> 子beforeDestroy=> 子destroyed=> 父destroyed
4、Vue 组件挂载顺序:
1、父组件初始化,如果不需要编译则直接开始挂载;需要编译则先编译然后挂载;
2、挂载过程中会将render函数生成vnode;这个过程主要错了下面几件事:构造子类构造器、安装组件钩子函数、实例化vnode;
3、vnode之后会走_update,然后会_patch_,在patch 过程中执行createElm 创建真实节点的时候,遇到组件节点则 createComponent 来创建组件;
4、createComponent 会调用之前绑定的钩子函数 init,这里会创建子组件实例 child 然后执行 child.$mount;
5、接下来就开始子组件的初始化,编译,渲染;
整个挂载过程就是组件不断循环递归 render、update、patch
的过程,直到整颗树挂载完毕为止。
子组件初始化
在父组件 vnode 实例化之后,在进行 patch 的时候,发现子节点是一个组件节点,这个时候会进行子组件 vnode 的实例化操作;在 _init
中,先在子组件中拿到父组件保存的内容,记录在子组件实例$options._renderChildren
中;
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;
// componentOptions为子vnode记录的相关信息
var vnodeComponentOptions = parentVnode.componentOptions;
// 父组件需要分发的内容赋值给子选项配置的_renderChildren
opts._renderChildren = vnodeComponentOptions.children;
....
}
然后在 initRender
的时候,会将配置的_renderChildren
属性做规范化处理,并将他赋值给子实例上的$slot
属性,看看下面的操作:
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)
vm.$scopedSlots = emptyObject
这里定义了实例上的 $slots 、$scopedSlots
属性,$scopedSlots
定义为空对象,$slots
是通过 resolveSlots
返回的,在 src/core/instance/render-helpers/resolve-slots.js
中:
export function resolveSlots (
children: ?Array<VNode>, //slot 的 vnode 内容
context: ?Component //slot 所在实例
): { [key: string]: Array<VNode> } {
// children是父组件需要分发到子组件的Vnode节点,如果不存在,则没有分发内容
if (!children || !children.length) {
return {}
}
const slots = {}
// 遍历每一个子节点
for (let i = 0, l = children.length; i < l; i++) {
const child = children[i]
// 当前子节点的data属性
const data = child.data
// 如果节点被解析为Vue槽位节点,则删除槽位属性
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot
}
// 判断是否为具名插槽,如果为具名插槽,还需要 子组件 / 函数子组件 渲染上下文一致。
if ((child.context === context || child.fnContext === context) &&
data && data.slot != null
) {
// 获取slot的名称
const name = data.slot
// 如果slots[name]不存在,则初始化为一个空数组
const slot = (slots[name] || (slots[name] = []))
// 如果是tempalte元素 则把template的children添加进数组中
if (child.tag === 'template') {
slot.push.apply(slot, child.children || [])
} else {
slot.push(child)
}
} else {
// 返回匿名default插槽VNode数组
(slots.default || (slots.default = [])).push(child)
}
}
// 忽略仅仅包含whitespace的插槽
for (const name in slots) {
if (slots[name].every(isWhitespace)) {
delete slots[name]
}
}
return slots
}
1、两个入参:
第一个参数:是当前节点对应父节点的所有子节点;
第二个参数:父节点的上下文,也是父组件的实例;
2、循环子节点,拿到 data 属性;如果当前插槽已经被解析则删除这个插槽;
3、如果是命名插槽,获取插槽名称,将对应的 child 添加到 slots中,否则就加入匿名插槽的数组中;
4、从 slots 中删除掉所有名称为空格的插槽;
resolveSlots
方法主要是对 options._renderChildren
通过 name 将 <slot>
替换成相应的内容,并将解析的结果赋值到 vm.$slots;
父 / 子组件编译(parse)
在解析 AST 阶段,插槽和其他普通标签的处理是相同的,不同的是在编译阶段,在 AST 生成 render 函数的时候,对插槽的处理会用 _t 函数进行包裹;
编译父组件时,会执行 parseHTML
方法然后调用 processElement
方法,里面有个 processSlotContent
方法来处理插槽相关内容;
1、在 src/compiler/parser/index.js
里面:
const slotRE = /^v-slot(:|$)|^#/
export const emptySlotScopeToken = `_empty_`
function processSlotContent (el) {
let slotScope
// ...省略代码
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
}
}
// ...省略代码
}
1、首先调用
getAndRemoveAttrByRegex
方法并给第二个参数传入slotRE
正则表达式,用来获取并移除当前 ast 对象上的 v-slot 属性;
2、随后通过调用getSlotName
方法来获取插槽的名字以及获取是否为动态插槽名;
3、将插槽名字赋值给slotTarget
,slotTargetDynamic
赋值是否是动态组件;
4、最后如果正则解析到有作用域插槽,则赋值给slotScope
属性,如果没有则取一个默认的值_empty_
;
然后在 closeElement
方法中:
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
当 ast 对象存在 slotScope
属性的时候,把当前 ast 节点挂到父级的 scopedSlots
属性上面;然后又维护了父子 AST 的树形结构;
上面两步主要是给对应的 AST 元素节点添加 slotTarget
属性;然后在 codegen
阶段,在 genData
中会处理 slotTarget
,
2、在 src/compiler/codegen/index.js
中:
父组件的 AST 节点上会有 el.slot = xxx ,tag 是 component 或者 element都会走 genData
方法;
export function genData (el: ASTElement, state: CodegenState): string {
// ...省略代码
// slot target
// only for non-scoped slots
if (el.slotTarget && !el.slotScope) {
data += `slot:${el.slotTarget},`
}
// scoped slots
if (el.scopedSlots) {
data += `${genScopedSlots(el, el.scopedSlots, state)},`
}
// ...省略代码
}
会给 data 添加⼀个 slot 属性,并指向 slotTarget
,之后会⽤到;
由于父组件有 scopedSlots
属性,所以会调用 genScopedSlots
方法来处理:
function genScopedSlots (
el: ASTElement,
slots: { [key: string]: ASTElement },
state: CodegenState
): string {
// 默认情况下,有作用域的槽被认为是“稳定的”,这允许只有有作用域槽的子组件跳过来自父组件的强制更新。
// 但在某些情况下,我们必须摆脱这种优化,例如,如果槽包含动态名称,有v-if或v-for。
let needsForceUpdate = el.for || Object.keys(slots).some(key => {
const slot = slots[key]
return (
slot.slotTargetDynamic ||
slot.if ||
slot.for ||
containsSlotChild(slot) // 传递槽的父组件可能是动态的
)
})
// 如果具有限定范围槽位的组件位于条件分支中,可以使用不同的组件重用相同的组件编制内容。
// 为了避免这种情况,我们生成一个基于生成的所有槽位内容的代码。
let needsKey = !!el.if
// 遍历所有父组件,slotScope不为空或者存在for 则强制更新
if (!needsForceUpdate) {
let parent = el.parent
while (parent) {
if (
(parent.slotScope && parent.slotScope !== emptySlotScopeToken) ||
parent.for
) {
needsForceUpdate = true
break
}
if (parent.if) {
needsKey = true
}
parent = parent.parent
}
}
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)}` : ``
})`
}
genScopedSlot
会拼凑每个slots,对 fn 变量赋值 ,然后会在genScopedSlots
里面返回scopedSlots:_u([])
函数字符串;
子组件的编译(codegen)
parse
阶段跟上面一样,但是到了 codegen
阶段就有了差别:当调用genElement
方法时,子组件的 tag 为 slot 所以会调用 genSlot
方法:
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 + ')'
}
genSlot
从 AST 元素节点对应的属性上取 slotName,默认是 default ,⽽ children 对应的就是 slot 开始和闭合标签包裹的内容 ,返回一个字符串 _t(...略)
,这个_t函数最后在运行时会生成VNode;
"with(this){return _c('div',{staticClass:"child"},[_t("default")],2)}"
上面这些编译阶段代码可以理解成对 slot 数据格式的整理,方便后面使用,如果想看看数据的转换格式可以手动调试一步一步看看数据的变化。
render 生成 vnode 阶段
rebder 函数生成 vnode 的时候会调用 _c、_u、_v
以及 _t
这些函数,在这几个函数中我们重点关注_u
和_t
这两个函数,_u
函数的代码如下,它定义在src/core/instance/render-helpers/resolve-scoped-slots.js
文件中:
export function resolveScopedSlots (
fns: ScopedSlotsData, // see flow/vnode
res?: Object,
// the following are added in 2.6
hasDynamicKeys?: boolean,
contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
res = res || { $stable: !hasDynamicKeys }
for (let i = 0; i < fns.length; i++) {
const slot = fns[i]
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) {
slot.fn.proxy = true
}
res[slot.key] = slot.fn
}
}
if (contentHashKey) {
(res: any).$key = contentHashKey
}
return res
}
当resolveScopedSlots
函数调用的时候,我们传递了一个fns
数组,在这个方法中首先会遍历fns
,然后把当前遍历的对象赋值到res
对象中,其中slot.key
当做键,slot.fn
当做值,返回 res;主要作用是处理命名插槽;
_t
函数的代码如下,它定义在src/core/instance/render-helpers/render-slot.js
文件中:
// 渲染slot组件内容
export function renderSlot (
name: string,
fallback: ?Array<VNode>, // slot插槽后备内容(针对后备内容)
props: ?Object, // 子传给父的值
bindObject: ?Object
): ?Array<VNode> {
//拿到父组件插槽的执行函数,默认slotname为default
const scopedSlotFn = this.$scopedSlots[name]
let nodes
// 具名插槽分支
if (scopedSlotFn) {
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)
}
// 执行时将子组件传递给父组件的值传入fn
nodes = scopedSlotFn(props) || fallback
} else {
// 如果父占位符组件没有插槽内容,this.$slots不会有值,此时vnode节点为后备内容节点。
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
renderSlot
执行过程会拿到父组件需要分发的内容,最终Vnode
树将父元素的插槽替换掉子组件的slot
组件。
默认插槽
//子组件
<template>
<div>
<slot>默认的插槽</slot>
</div>
</template>
<script>
export default {
name:'child'
}
</script>
//父组件
<child>插槽内容</child>
子组件插槽中有默认值的情况,如果父组件不传入对应的值,则显示插槽默认值,如果传值了则显示传递过来的值,一般用以按钮的文字显示;
源码流程:
1、父组件渲染过程中没有需要分发的子节点,所以 s l o t 里 面 就 没 有 内 容 ; 2 、 子 组 件 在 t 方 法 中 , 该 方 法 第 二 个 参 数 以 数 组 的 方 式 传 入 s l o t 标 签 的 默 认 内 容 ; 3 、 渲 染 子 节 点 的 时 候 执 行 r e n d e r S l o t 第 二 个 参 数 有 值 , slot里面就没有内容; 2、子组件在 _t 方法中,该方法第二个参数以数组的方式传入 slot 标签的默认内容; 3、渲染子节点的时候执行 renderSlot 第二个参数有值, slot里面就没有内容;2、子组件在t方法中,该方法第二个参数以数组的方式传入slot标签的默认内容;3、渲染子节点的时候执行renderSlot第二个参数有值,slot 没值,就会返回默认内容作为渲染对象;
匿名插槽
//子组件
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
name:'child'
}
</script>
//父组件
<child>插槽内容</child>
这里会将 <child>
标签包裹的内容渲染的子组件的 <slot>
标签里面,一般匿名插槽是在只有一个插槽的时候使用,当然也可以配合名称插槽一起使用;可以用 v-slot="default"
来表示;
源码流程:
1、父组件渲染的时候拿到分发内容存到 $slot.default (resolveSlots 方法中处理)中;
2、子组件在 _t 方法中第一个参数将slotName 传入,这里传入的是 default;
3、 渲染子节点的时候执行 renderSlot 第一个参数 $slot[name] 有值,就会返回 $slot[name] 对应的插槽内容作为渲染对象;
命名插槽
//子组件
<template>
<div>
<slot name=“header”></slot>
<slot></slot>
<slot name=“footer”></slot>
</div>
</template>
<script>
export default {
name:'child'
}
</script>
//父组件
<child>
<template v-slot:header>插槽头部</template>
<template v-slot>插槽头部</template>
<template v-slot:footer>插槽尾部</template>
</child>
父组件里的插槽内容会根据 name 和 v-slot 对应的值匹配,根据匹配到的名称将对应的内容渲染到对应的位置;
源码流程:
1、在父组件编译阶段用 scopedSlots 来记录命名插槽的内容;
2、在父组件生成 vnode 阶段,在父组件的 vnode 节点的 data 对象上增加 scopedSlots 数组;
3、子组件解析时,在子组件的实例上的 s c o p e d S l o t s 属 性 保 存 父 组 件 插 槽 相 关 内 容 , 这 时 的 内 容 是 一 函 数 的 方 式 保 存 ; 4 、 子 组 件 在 r e n d e r S l o t 方 法 时 , scopedSlots 属性保存父组件插槽相关内容,这时的内容是一函数的方式保存; 4、子组件在 renderSlot 方法时, scopedSlots属性保存父组件插槽相关内容,这时的内容是一函数的方式保存;4、子组件在renderSlot方法时,scopedSlots 有值,会执行nodes = scopedSlotFn(props)
, 执行对应名称的方法;
命名插槽和普通插槽实现上有明显的不同,普通插槽是以 componentOptions.children
的形式保留在父组件中,而具名插槽是以 scopedSlots
属性的形式存储到 data
属性中。
作用域插槽
//子组件
<template>
<div>
<slot v-bind:user="user">{{user.a}}</slot>
</div>
</template>
<script>
export default {
name:'child',
data(){
return {
user:{
a:'1',
b:'2'
}
}
}
}
</script>
//父组件
<child>
<template v-slot:default="slotProps">{{slotProps.user.b}}</template>
</child>
让父组件插槽能访问子组件里面的数据;
1、在子组件中,将 user
作为 slot
标签的属性绑定来;
2、父组件用 v-slot:default
来定义绑定的名称,通过这个名称可以拿到子组件绑定在 slot
上的对象的值;
3、这样就可以在父组件的作用域下使用子组件的属性了。
源码流程:
1、作用域插槽和具名插槽在父组件的用法基本相同,区别在于 v-slot 定义了一个插槽 props 的名字,参考对于具名插槽的分析,生成render 函数阶段(resolveScopedSlots) fn 函数会携带 props 参数传入;
2、子组件编译阶段(genSlot),会将子组件的 props 以属性的形式解析,在 render 函数生成的时候以对象参数的形式传入给 _t 函数,第三个参数;
3、子组件渲染阶段,分析 renderSlot 的时候,会拿到第三个参数 props ,并传递给执行函数执行;
插槽的更新
上面说的是插槽的编译、渲染过程,那么插槽是怎么更新的呢?下面来看看。
在父组件上响应式变量发生变化时,父组件会开始更新,在更新过程中触发 _patch_
方法,其中核心部分在 prepatch
钩子函数中调用的 updateChildComponent
方法,在 src/core/instance/lifeCycle.js
文件中:
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
// ...省略代码
const newScopedSlots = parentVnode.data.scopedSlots
const oldScopedSlots = vm.$scopedSlots
const hasDynamicScopedSlot = !!(
(newScopedSlots && !newScopedSlots.$stable) ||
(oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||
(newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key)
)
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
)
// ...省略代码
// resolve slots + force update if has children
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
}
needsForceUpdate
为真则强制进行子组件的重新渲染,可以发现 needsForceUpdate
是受到 $stable
或$key
这两个属性影响;
除了父组件响应式变量的变化会造成强制子组件重新渲染外,v-if 、v-for
以及动态插槽名在父组件上使用时,数据变化时也会触发子组件的重新渲染(可参考 genScopedSlots
方法中 needsForceUpdate
取值的部分)。