欢迎关注公众号:《前端 Talkking》
1、插槽使用
在 Vuejs 中,如何实现子组件接收父组件的模版内容然后渲染呢?答案是我们可以使用插槽 slot
。
举例来说,这里有一个 <FancyButton>
组件,可以像这样使用:
<FancyButton>
Click me! <!-- 插槽内容 -->
</FancyButton>
而 <FancyButton>
的模板是这样的:
<button class="fancy-btn">
<slot></slot> <!-- 插槽出口 -->
</button>
<slot>
元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。
最终渲染出的 DOM 是这样:
<button class="fancy-btn">Click me!</button>
如果我们希望子组件有多个插槽,则可以使用具名插槽。举一个例子:
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
我们在 BaseLayout 组件中定义了多个插槽,并且其中两个插槽标签还添加了 name 属性(没有设置 name 属性则默认 name 是 default),然后我们在父组件中可以这么使用 BaseLayout 组件:
<template>
<div class="container">
<template v-slot:header>
<h1>{{ header }}</h1>
</template>
<template v-slot:default>
<p>{{ main }}</p>
</template>
<template v-slot:footer>
<p>{{ footer }}</p>
</template>
</layout>
</template>
<script>
export default {
data (){
return {
header: 'Here might be a page title',
main: 'A paragraph for the main content.',
footer: 'Here\'s some contact info'
}
}
}
</script>
最终 BaseLayout 组件渲染的 HTML 如下:
<div class="layout">
<header>
<h1>Here might be a page title</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>
同时,Vuejs 还提供的作用域插槽的用法,具体用法可以参见:作用域插槽
以上就是插槽的常见使用方法,接下来,我们一起来看看插槽背后实现的原理吧。
2、插槽源码实现
通过以上两个示例,我们得知插槽其实就是在父组件中去编写子组件插槽部分的模版,然后在子组件渲染的时候,将这部分模版内容填充到子组件的插槽中。
因此,在父组件渲染阶段,子组件插槽部分的 DOM 是不能渲染的,需要将这部分模版内容保存下来,等到子组件渲染的时候再渲染。顺着这个思路,我们来看源码具体实现。
2.1 父组件渲染流程实现
我们有父组件:
<layout>
<template v-slot:header>
<h1>{{ header }}</h1>
</template>
<template v-slot:default>
<p>{{ main }}</p>
</template>
<template v-slot:footer>
<p>{{ footer }}</p>
</template>
</layout>
借助Vue SFC Playground,我们可以看它编译后的代码:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_layout = _resolveComponent("layout")
return (_openBlock(), _createBlock(_component_layout, null, {
header: _withCtx(() => [
_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
]),
default: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString($data.main), 1 /* TEXT */)
]),
footer: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString($data.footer), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}))
}
查看编译后的代码,我们发现 createBlock
第三个参数是一个对象,它表示创建的 vnode
子节点。createBlock
内部调用了 _createVNode
函数创建 vnode
节点。此时传入的 children
就是一个对象,needFullChildrenNormalization
为 true。
// packages/runtime-core/src/vnode.ts
function _createVNode(
// 编译后的.vue文件形成的对象
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
// 给组件传递的props
props: (Data & VNodeProps) | null = null,
// 子组件
children: unknown = null,
// patch的类型
patchFlag: number = 0,
// 动态的props
dynamicProps: string[] | null = null,
// 是否是block节点
isBlockNode = false
): VNode {
// 省略部分代码
// 调用更基层的方法处理
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
// needFullChildrenNormalization是true,还会执行normlizeChildren去标准化子节点
true
)
}
// packages/runtime-core/src/vnode.ts
function createBaseVNode(
// 创建的虚拟节点的类型
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag = 0,
dynamicProps: string[] | null = null,
shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
isBlockNode = false,
needFullChildrenNormalization = false
) {
const vnode = {
// 这是一个vnode
__v_isVNode: true,
// 不进行响应式处理
__v_skip: true,
// .vue文件编译后的对象
type,
// 组件收到的props
props,
// 组件key
key: props && normalizeKey(props),
// 收集到的ref
ref: props && normalizeRef(props),
// 当前作用域ID
scopeId: currentScopeId,
// 插槽ID
slotScopeIds: null,
// 子节点
children,
// 组件实例
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
// 当前虚拟节点的类型
shapeFlag,
// patch类型
patchFlag,
// 动态props
dynamicProps,
dynamicChildren: null,
appContext: null,
ctx: currentRenderingInstance
} as VNode
// 是否需要对children进行标准化
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
// 处理SUSPENSE逻辑
// normalize suspense children
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).normalize(vnode)
}
} else if (children) {
// compiled element vnode - if children is passed, only possible types are
// string or Array.
// 设置shapeFlags
vnode.shapeFlag |= isString(children)
? ShapeFlags.TEXT_CHILDREN
: ShapeFlags.ARRAY_CHILDREN
}
// 省略部分代码
return vnode
}
在 createBaseVNode 内部中判断了 needFullChildrenNormalization
为 true,此时会执行 normalizeChildren
函数去标准化 children 子节点,继续深入 normalizeChildren
函数看一下它的实现:
export function normalizeChildren(vnode: VNode, children: unknown) {
let type = 0
const { shapeFlag } = vnode
if (children == null) {
children = null
} else if (isArray(children)) {
type = ShapeFlags.ARRAY_CHILDREN
} else if (typeof children === 'object') {
if (shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.TELEPORT)) {
// Normalize slot to plain children for plain element and Teleport
const slot = (children as any).default
if (slot) {
// _c marker is added by withCtx() indicating this is a compiled slot
slot._c && (slot._d = false)
normalizeChildren(vnode, slot())
slot._c && (slot._d = true)
}
return
} else {
type = ShapeFlags.SLOTS_CHILDREN
const slotFlag = (children as RawSlots)._
if (!slotFlag && !(InternalObjectKey in children!)) {
// if slots are not normalized, attach context instance
// (compiled / normalized slots already have context)
;(children as RawSlots)._ctx = currentRenderingInstance
} else if (slotFlag === SlotFlags.FORWARDED && currentRenderingInstance) {
// a child component receives forwarded slots from the parent.
// its slot type is determined by its parent's slot type.
if (
(currentRenderingInstance.slots as RawSlots)._ === SlotFlags.STABLE
) {
;(children as RawSlots)._ = SlotFlags.STABLE
} else {
;(children as RawSlots)._ = SlotFlags.DYNAMIC
vnode.patchFlag |= PatchFlags.DYNAMIC_SLOTS
}
}
}
} else if (isFunction(children)) {
children = { default: children, _ctx: currentRenderingInstance }
type = ShapeFlags.SLOTS_CHILDREN
} else {
children = String(children)
// force teleport children to array so it can be moved around
if (shapeFlag & ShapeFlags.TELEPORT) {
type = ShapeFlags.ARRAY_CHILDREN
children = [createTextVNode(children as string)]
} else {
type = ShapeFlags.TEXT_CHILDREN
}
}
vnode.children = children as VNodeNormalizedChildren
vnode.shapeFlag |= type
}
分析以上代码,normalizeChildren
函数的主要作用就是标准化 children
以及 vnode
的节点类型 shapeFlag
。
这里,我们重点关注以下两行代码的值:
vnode.children = children as VNodeNormalizedChildren
vnode.shapeFlag |= type
此时,children
是 object
类型,经过处理,vnode.children
是插槽对象,而 vnode.shapeFlag
会与 slot 子节点类型 SLOTS_CHILDREN
进行或运算,由于 vnode
本身的 shapFlag
是 STATEFUL_COMPONENT
,所以运算后的 shapeFlag
是 SLOTS_CHILDREN | STATEFUL_COMPONENT
(运算后的值为 6)。
确定了 shapeFlag
后会影响 patch
流程,我们来看看 patch
函数的实现:
const patch: PatchFn = (
// 旧vnode
n1,
// 新vnode
n2,
// 挂载的容器
container,
// 挂载参考的锚点
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 省略部分代码
const { type, ref, shapeFlag } = n2
// 根据不同的节点类型进行不同的处理规则
switch (type) {
case Text:
// 处理文本节点
break
case Comment:
// 处理注释节点
break
case Static:
// 处理静态节点
break
case Fragment:
// 处理Fragment节点
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通DOM元素
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理普通DOM元素
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 处理TELEPORT
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// 处理SUSPENSE
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
// set ref
// 设置ref引用
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}
在上面分析得知,shapeFlag
的值是6,因此会走到 processComponent
逻辑,递归渲染子组件。
到目前为止,带有子节点插槽的组件渲染与普通组件的渲染并没有任何区别,还是通过递归的方式去进行渲染的,组件中插槽对象则保留在组件的 vnode
的 children
属性中。
2.2 子组件渲染流程实现
渲染子组件会调用 processComponent
函数,调用流程如下:processComponent->mountComponent->setupComponent->initSlots
。我们沿着这条主线继续分析子组件的渲染流程。
在 setupComponent
函数中,调用了 initSlots
函数来初始化插槽,并传入 instance 和 children。
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
isInSSRComponentSetup = isSSR
const { props, children } = instance.vnode
const isStateful = isStatefulComponent(instance)
// 初始化props
initProps(instance, props, isStateful, isSSR)
// 初始化插槽
initSlots(instance, children)
// 设置有状态的组件实例(通常,我们写的组件就是一个有状态的组件,所谓有状态,就是组件会在渲染过程中把一些状态挂载到组件实例对应的属性上)
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
isInSSRComponentSetup = false
return setupResult
}
我们来看看 initSlots
函数的实现:
export const initSlots = (
instance: ComponentInternalInstance,
children: VNodeNormalizedChildren
) => {
if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const type = (children as RawSlots)._
if (type) {
instance.slots = toRaw(children as InternalSlots)
def(children as InternalSlots, '_', type)
} else {
normalizeObjectSlots(
children as RawSlots,
(instance.slots = {}),
instance
)
}
} else {
instance.slots = {}
if (children) {
normalizeVNodeSlots(instance, children)
}
}
def(instance.slots, InternalObjectKey, 1)
}
initSlots
其实就是将插槽对象保留到了 instance.slots
对象中,这样后面的程序就可以从 instance.slots
拿到插槽对象了。接下来,我们来看子组件是如何将这些插槽数据渲染到页面上。
子组件模版如下:
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
借助Vue SFC Playground,我们可以看它编译后的代码:
import { renderSlot as _renderSlot, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "layout" }
function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("header", null, [
_renderSlot(_ctx.$slots, "header")
]),
_createElementVNode("main", null, [
_renderSlot(_ctx.$slots, "default")
]),
_createElementVNode("footer", null, [
_renderSlot(_ctx.$slots, "footer")
])
]))
}
通过编译后的代码我们可以看出,子组件插槽部分的DOM是通过 renderSlot
函数渲染的,我们来看看 renderSlot
函数的实现:
export function renderSlot(
slots: Slots,
name: string,
props: Data = {},
// this is not a user-facing function, so the fallback is always generated by
// the compiler and guaranteed to be a function returning an array
fallback?: () => VNodeArrayChildren,
noSlotted?: boolean
): VNode {
if (
currentRenderingInstance!.isCE ||
(currentRenderingInstance!.parent &&
isAsyncWrapper(currentRenderingInstance!.parent) &&
currentRenderingInstance!.parent.isCE)
) {
if (name !== 'default') props.name = name
return createVNode('slot', props, fallback && fallback())
}
let slot = slots[name]
if (__DEV__ && slot && slot.length > 1) {
warn(
`SSR-optimized slot function detected in a non-SSR-optimized render ` +
`function. You need to mark this component with $dynamic-slots in the ` +
`parent template.`
)
slot = () => []
}
// a compiled slot disables block tracking by default to avoid manual
// invocation interfering with template-based block tracking, but in
// `renderSlot` we can be sure that it's template-based so we can force
// enable it.
if (slot && (slot as ContextualRenderFn)._c) {
;(slot as ContextualRenderFn)._d = false
}
openBlock()
// 如果slot内部全部是注释节点,则不是一个合法的插槽
const validSlotContent = slot && ensureValidVNode(slot(props))
const rendered = createBlock(
Fragment,
{
key:
props.key ||
// slot content array of a dynamic conditional slot may have a branch
// key attached in the `createSlots` helper, respect that
(validSlotContent && (validSlotContent as any).key) ||
`_${name}`
},
validSlotContent || (fallback ? fallback() : []),
validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
? PatchFlags.STABLE_FRAGMENT
: PatchFlags.BAIL
)
if (!noSlotted && rendered.scopeId) {
rendered.slotScopeIds = [rendered.scopeId + '-s']
}
if (slot && (slot as ContextualRenderFn)._c) {
;(slot as ContextualRenderFn)._d = true
}
return rendered
}
参数含义如下:
- slots:子组件初始化时获取的插槽对象,即
instance.slots
; - name:插槽名称;
- props:插槽数据,用于作用域插槽。
renderSlots
函数的拆解过程如下:
- 通过
name
获取对应的插槽函数; - 执行
slot
函数获取对应的插槽内容,这里同时会执行ensureValidVNode
校验插槽内容的合法性(全部为注释节点,则不合法); - 通过
createBlock
函数创建Fragment
类型的vnode
节点并返回,children
是执行slot
插槽函数的返回值
也就是说,在子组件执行 renderSlot
的时候,创建了与插槽内容对应的 vnode
节点,候选在 patch
的过程中就可以渲染并生成对应的DOM了。
在上文中,我们知道父组件编译后的内容为:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_layout = _resolveComponent("layout")
return (_openBlock(), _createBlock(_component_layout, null, {
header: _withCtx(() => [
_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
]),
default: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString($data.main), 1 /* TEXT */)
]),
footer: _withCtx(() => [
_createElementVNode("p", null, _toDisplayString($data.footer), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}))
}
如果此时 name
为 header
,则对应的 slot
的值就是:
_withCtx(() => [
_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
])
它是执行 _withCtx
函数返回的值,接着我们来分析下 _witchCtx
的实现。
export function withCtx(
fn: Function,
ctx: ComponentInternalInstance | null = currentRenderingInstance,
isNonScopedSlot?: boolean // __COMPAT__ only
) {
if (!ctx) return fn
// already normalized
if ((fn as ContextualRenderFn)._n) {
return fn
}
const renderFnWithContext: ContextualRenderFn = (...args: any[]) => {
// If a user calls a compiled slot inside a template expression (#1745), it
// can mess up block tracking, so by default we disable block tracking and
// force bail out when invoking a compiled slot (indicated by the ._d flag).
// This isn't necessary if rendering a compiled `<slot>`, so we flip the
// ._d flag off when invoking the wrapped fn inside `renderSlot`.
if (renderFnWithContext._d) {
setBlockTracking(-1)
}
const prevInstance = setCurrentRenderingInstance(ctx)
let res
try {
res = fn(...args)
} finally {
setCurrentRenderingInstance(prevInstance)
if (renderFnWithContext._d) {
setBlockTracking(1)
}
}
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentUpdated(ctx)
}
return res
}
// mark normalized to avoid duplicated wrapping
renderFnWithContext._n = true
// mark this as compiled by default
// this is used in vnode.ts -> normalizeChildren() to set the slot
// rendering flag.
renderFnWithContext._c = true
// disable block tracking by default
renderFnWithContext._d = true
// compat build only flag to distinguish scoped slots from non-scoped ones
if (__COMPAT__ && isNonScopedSlot) {
renderFnWithContext._ns = true
}
return renderFnWithContext
}
withCtx
的主要作用就是给执行函数 fn
做一层封装,当 fn
执行时当前组件实例指向上下文变量 ctx
。通过 withCtx
的封装,保证了子组件渲染插槽内容是,渲染组件实例仍然是父组件实例,这样也就保证了数据作用域来源于父组件。
所以对于header这个slot,他的slot函数返回值就是一个数组,如下所示:
[
_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
]
我们回到renderSlot函数,最终header插槽对应的vnode函数变成了如下函数:
export function renderSlot(
slots: Slots,
name: string,
props: Data = {},
// this is not a user-facing function, so the fallback is always generated by
// the compiler and guaranteed to be a function returning an array
fallback?: () => VNodeArrayChildren,
noSlotted?: boolean
): VNode {
// 省略部分代码
const rendered = createBlock(
Fragment,
{
key:
props.key
},
[
_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
],
64 /*PatchFlags.STABLE_FRAGMENT*/
)
return rendered
}
createBlock
内部会调用 createVnode
创建 vnode
,vnode
创建完毕后,会调用 patch
函数将 vnode
渲染到页面上,由于此时 vnode
的 type
是 Fragement
,我们来看 patch
函数对插槽的渲染实现:
const processFragment = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
let { patchFlag, dynamicChildren, slotScopeIds: fragmentSlotScopeIds } = n2
// 删除部分代码
if (n1 == null) {
// 首次挂载时插入Fragment
// 先在前后插入两个空文本节点
hostInsert(fragmentStartAnchor, container, anchor)
hostInsert(fragmentEndAnchor, container, anchor)
// 断言片段节点的子节点为数组类型,因为片段节点只能包含数组子节点。
// 挂载子节点,这里只能是数组的子集
mountChildren(
n2.children as VNodeArrayChildren,
container,
fragmentEndAnchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 更新节点
}
}
在插入节点流程中,首先会通过 hostInsert
在容器的前后插入两个空文本节点,然后在以尾文本节点作为参考锚点,通过 mountChildren
把 children
挂载到 container
容器中。
经过以上步骤的处理,就完成了子组件插槽内容的渲染。
3、总结
插槽的实现实际上就是一种延时渲染,把父组件中编写的插槽内容保存到一个对象上,并且把具体渲染 DOM 的代码用函数的方式封装,然后在子组件渲染的时候,根据插槽名在对象中找到对应的函数,然后执行这些函数做真正的渲染。
4、参考资料
[1]vue官网
[2]vuejs设计与实现
[3]vue3源码