Appearance
插槽 Slot:内容分发的实现原理
插槽(Slot)是 Vue 中实现“内容分发”的机制。它允许父组件定义一段模板内容,并将其“插入”到子组件的指定位置。
<slot> 的实现原理横跨了编译时和运行时。其流程是:
- 父组件(编译时):将
<template v-slot:header>编译成一个“插槽函数”。 - 父组件(运行时):执行
render,将“插槽函数”作为children传递给子组件。 - 子组件(运行时): a.
initSlots:接收并“规范化”父组件传来的“插槽函数”。 b.renderSlot:在子组件需要渲染插槽时,执行这个“插槽函数”并渲染其返回的 VNode。
“编译时”:buildSlots 如何将模板变为“插槽函数”
插槽的实现始于父组件的 transform(转换)阶段。当编译器遇到一个包含 v-slot 的组件时,会调用 buildSlots 函数。
父组件的模板:
vue
<MyComponent>
<template #header="{ user }">
<h1>Hello, {{ user.name }}</h1>
</template>
<p>Default content</p>
</MyComponent>buildSlots 会将这些 <template> 标签和“游离”的节点(如 <p> 标签)转换成一个JS 对象,这个对象将作为 children 属性传递给 MyComponent。
typescript
// core/packages/compiler-core/src/transforms/vSlot.ts
export function buildSlots(
node: ElementNode,
context: TransformContext,
): {
slots: SlotsExpression // 这是一个 JS 对象表达式
hasDynamicSlots: boolean
} {
const slotsProperties: Property[] = [] // 存储 { key: value, ... }
// 1. 遍历子节点,查找 <template v-slot>
for (let i = 0; i < node.children.length; i++) {
const slotElement = node.children[i]
// 2. 找到一个 <template v-slot:header="{ user }">
if (isTemplateNode(slotElement) && (slotDir = findDir(slotElement, 'slot'))) {
const { children: slotChildren, loc: slotLoc } = slotElement
const {
arg: slotName, // #header -> { content: 'header' }
exp: slotProps // { user } -> { content: '{ user }' }
} = slotDir
// 3. 【核心】创建“插槽函数”
const slotFunction = buildSlotFn(slotProps, vFor, slotChildren, slotLoc)
// 4. 添加到对象属性
slotsProperties.push(
createObjectProperty(slotName, slotFunction)
)
} else {
// 5. 处理 "p" 这样的游离节点
implicitDefaultChildren.push(slotElement)
}
}
// 6. 处理默认插槽
if (implicitDefaultChildren.length) {
slotsProperties.push(
createObjectProperty(
'default',
buildSlotFn(undefined, vFor, implicitDefaultChildren, loc)
)
)
}
// 7. 返回最终的 JS 对象表达式
return {
slots: createObjectExpression(slotsProperties), // { header: fn, default: fn }
hasDynamicSlots
}
}
// buildSlotFn (简化后)
function buildSlotFn(slotProps, vFor, children, loc) {
// 返回一个函数表达式:(slotProps) => [ ...children VNodes... ]
return createFunctionExpression(slotProps, children, ...)
}buildSlots 的编译产物(概念上):
javascript
{
header: ({ user }) => [
_createElementVNode("h1", null, "Hello, " + _toDisplayString(user.name))
],
default: () => [
_createElementVNode("p", null, "Default content")
]
}为什么必须是函数?transform 阶段将插槽内容编译为函数,这是 Slot 机制的核心:
- 懒执行 (Lazy Execution):父组件只是“定义”了这个函数,但不执行它。插槽内容(
h1,p)的 VNode 只在子组件renderSlot时才被创建,这优化了性能。 - 作用域插槽 (Scoped Slots):
{ user }成为了这个函数的参数。这使得子组件在“执行”这个函数时,能把自己的数据(user)“传递”给父组件的作用域。
“运行时 (子)”:initSlots 接收并规范化插槽
在父组件的 render 函数执行后,上面那个 { header: fn, default: fn } 对象就作为 children 传递给了 MyComponent。
现在,子组件(MyComponent)开始初始化。initSlots 函数被调用,负责接收 children 并将其存入 instance.slots。
typescript
// core/packages/runtime-core/src/componentSlots.ts
export const initSlots = (
instance: ComponentInternalInstance,
children: VNodeNormalizedChildren, // 父组件传来的 { header: fn, ... }
): void => {
// 1. 检查 vnode 是否标记为 SLOTS_CHILDREN
if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const slots = (instance.slots = createInternalObject())
// 2. 【核心】规范化对象插槽
normalizeObjectSlots(children as RawSlots, slots, instance)
} else if (children) {
// 3. (降级处理) 如果 children 不是对象(如 VNode 数组),
// 则将其规范化为“默认插槽”
normalizeVNodeSlots(instance, children)
}
}
// 规范化,本质是遍历 + 包装
const normalizeObjectSlots = (
rawSlots: RawSlots, // { header: fn, ... }
slots: InternalSlots, // instance.slots
instance: ComponentInternalInstance,
) => {
const ctx = rawSlots._ctx // 上下文
for (const key in rawSlots) {
if (isInternalKey(key)) continue // 跳过 _ctx, $stable 等
const value = rawSlots[key]
if (isFunction(value)) {
// 【核心】使用 normalizeSlot 包装
slots[key] = normalizeSlot(key, value, ctx)
} else {
// (降级处理) 如果提供了非函数内容(静态内容)
// 也将其包装成一个函数
const normalized = normalizeSlotValue(value)
slots[key] = () => normalized
}
}
}normalizeSlot 的主要工作是使用 withCtx 包装原始的插槽函数,以确保它在执行时能绑定到正确的渲染上下文(ctx),从而确保依赖追踪等功能正常工作。
“运行时 (子)”:renderSlot 执行插槽函数
插槽已经被“注册”到了 instance.slots 中。现在,子组件 MyComponent 开始 render。
子组件的模板:
vue
<div class="container">
<header>
<slot name="header" :user="currentUser" />
</header>
<main>
<slot />
</main>
</div>编译时,<slot> 标签会被 transformSlotOutlet 插件转换为 renderSlot 函数调用。
子组件的 render 函数 (概念):
javascript
function render() {
return _createElementVNode("div", { class: "container" }, [
_createElementVNode("header", null, [
// 1. 转换 <slot name="header" ... />
_renderSlot(
_ctx.$slots, // instance.slots
"header", // name
{ user: _ctx.currentUser } // props
)
]),
_createElementVNode("main", null, [
// 2. 转换 <slot />
_renderSlot(
_ctx.$slots,
"default"
)
])
])
}renderSlot 的实现:
renderSlot 是插槽机制的执行步骤。它负责查找并执行“插槽函数”。
typescript
// core/packages/runtime-core/src/helpers/renderSlot.ts
export function renderSlot(
slots: Slots, // instance.slots
name: string, // 'header'
props: Data = {}, // { user: currentUser }
fallback?: () => VNodeArrayChildren, // (插槽的默认内容)
): VNode {
// 1. 查找:从 slots 对象中找到 'header' 对应的函数
let slot = slots[name]
// ... (省略 DEV 警告和自定义元素处理) ...
// 2. 执行:
// 如果找到了插槽函数,就执行它,
// 并将 props ({ user: ... }) 作为参数传递进去。
const validSlotContent = slot && ensureValidVNode(slot(props))
// 3. 创建 Fragment:
// 将插槽函数返回的 VNode 数组,
// 或 fallback (后备内容) 返回的 VNode 数组,
// 包装在一个 Fragment (片段) VNode 中并返回。
return createBlock(
Fragment,
{ key: props.key || `_${name}` },
validSlotContent || (fallback ? fallback() : []),
// ... (处理 PatchFlags) ...
)
}总结
Vue 3 的插槽系统是一个编译时和运行时协同的机制:
- 编译时 (父):
buildSlots将<template v-slot>转换成一个“插槽函数对象”({ header: (props) => [...] })。使用函数是实现懒执行和作用域传参的关键。 - 运行时 (父):父组件
render,将这个“插槽函数对象”作为children传递给子组件。 - 运行时 (子
initSlots):子组件在初始化时,接收这个对象,通过normalizeObjectSlots将其包装并存入instance.slots。 - 运行时 (子
renderSlot):子组件模板中的<slot>标签被编译成renderSlot(...)调用。 - 执行:
renderSlot从instance.slots中查找对应的“插槽函数”,执行它(同时传递props),并最终渲染函数返回的 VNode 数组。
