主题
实现 Slots 机制
本节对标 Vue 3 源码
@vue/runtime-core中的componentSlots.ts、helpers/renderSlot.ts
Slots 的本质
插槽(Slots)是 Vue 中实现内容分发的机制。父组件可以向子组件传递模板片段,子组件决定在哪里渲染这些片段。
在 Vue 3 的编译产物中,插槽被编译为一个对象,其中每个属性是一个返回 VNode 数组的函数:
ts
// 模板
// <Child>
// <template #header>Header Content</template>
// <template #default>Default Content</template>
// </Child>
// 编译产物
h(Child, null, {
header: () => [h('h1', {}, 'Header Content')],
default: () => [h('p', {}, 'Default Content')],
})插槽本质上是延迟执行的 render 函数:父组件传递"如何渲染"的函数,子组件在适当位置调用它们。
插槽的编译产物
默认插槽
html
<!-- 父组件模板 -->
<Child>hello world</Child>
<!-- 编译为 -->
h(Child, null, {
default: () => [createTextVNode('hello world')],
})具名插槽
html
<!-- 父组件模板 -->
<Child>
<template #header>
<h1>Title</h1>
</template>
<template #footer>
<p>Footer</p>
</template>
</Child>
<!-- 编译为 -->
h(Child, null, {
header: () => [h('h1', {}, 'Title')],
footer: () => [h('p', {}, 'Footer')],
})作用域插槽
html
<!-- 父组件模板 -->
<Child>
<template #default="{ item, index }">
<span>{{ index }}: {{ item }}</span>
</template>
</Child>
<!-- 编译为 -->
h(Child, null, {
default: ({ item, index }) => [
h('span', {}, `${index}: ${item}`),
],
})作用域插槽的关键:父组件的插槽函数接收子组件传递的参数,从而可以使用子组件的数据进行渲染。
initSlots —— 插槽初始化
对标
packages/runtime-core/src/componentSlots.ts——initSlots
ts
// 初始化插槽:根据 VNode 的 shapeFlag 判断 children 是否为插槽类型并进行处理
export function initSlots(
instance: ComponentInternalInstance,
children: any, // VNode 的 children,如果是插槽则为 { name: () => VNode[] } 形式的对象
) {
const { vnode } = instance
// 通过位运算检查 shapeFlag 是否包含 SLOTS_CHILDREN 标记
// 只有组件类型的 VNode 且 children 为对象时才走插槽初始化逻辑
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
normalizeObjectSlots(children, instance.slots)
}
}
// 将原始插槽对象标准化:遍历每个插槽函数,包装后存入 instance.slots
function normalizeObjectSlots(
rawSlots: Record<string, Function>, // 父组件传入的原始插槽对象 { header: () => [...], default: () => [...] }
slots: Record<string, Function>, // 目标:instance.slots
) {
for (const key in rawSlots) {
const value = rawSlots[key]
if (typeof value === 'function') {
// 包装插槽函数:调用原始函数并确保返回值为 VNode 数组
// props 参数支持作用域插槽,子组件可以传递数据给插槽函数
slots[key] = (props: any) => normalizeSlotValue(value(props))
}
}
}
// 确保插槽函数返回值为数组格式(因为插槽可以返回单个 VNode 或数组)
function normalizeSlotValue(value: any): VNode[] {
return Array.isArray(value) ? value : [value] // 单个 VNode 包装为数组
}初始化流程
h(Child, null, { default: () => [...], header: () => [...] })
│
▼
createVNode → normalizeChildren
│ children 是对象 && type 是组件
▼
vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN
│
▼
setupComponent → initSlots
│
▼
instance.slots = {
default: (props) => normalizeSlotValue(rawSlots.default(props)),
header: (props) => normalizeSlotValue(rawSlots.header(props)),
}ShapeFlags.SLOTS_CHILDREN 标记
在 normalizeChildren 中,需要识别出插槽类型的 children:
ts
// 标准化 VNode 的 children:根据 children 的类型设置对应的 shapeFlag 标记
function normalizeChildren(vnode: VNode, children: unknown) {
if (typeof children === 'string' || typeof children === 'number') {
// 文本子节点:直接存为字符串
vnode.children = String(children)
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
} else if (Array.isArray(children)) {
// 数组子节点:多个 VNode 组成的数组
vnode.children = children as VNode[]
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
} else if (typeof children === 'object' && children !== null) {
// 对象子节点:当 VNode 类型是组件时,对象 children 被视为插槽
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
vnode.children = children as any
// 添加 SLOTS_CHILDREN 标记,后续 initSlots 会根据此标记处理插槽
vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN
}
}
}判断逻辑:当 VNode 的 type 是组件(非 DOM 元素)且 children 是对象时,将 children 识别为插槽。
renderSlots 辅助函数
对标
packages/runtime-core/src/helpers/renderSlot.ts
子组件在 render 函数中通过 renderSlots 渲染插槽内容:
ts
// renderSlots:在子组件的 render 中渲染指定名称的插槽内容
export function renderSlots(
slots: Record<string, Function>, // 组件实例的 slots 对象
name: string = 'default', // 插槽名称,默认为 'default'
props: any = {}, // 传递给作用域插槽的数据
): VNode {
const slot = slots[name] // 根据名称查找对应的插槽函数
if (slot) {
if (typeof slot === 'function') {
// 调用插槽函数并传入 props(支持作用域插槽)
// 用 Fragment 包裹返回的 VNode 数组,因为插槽可能返回多个根节点
return createVNode(Fragment, {}, slot(props))
}
}
// 如果指定的插槽不存在,返回一个空的 Fragment(不渲染任何内容)
return createVNode(Fragment, {}, [])
}使用 Fragment 包裹插槽内容的原因:插槽可能返回多个 VNode(数组),Fragment 可以作为"无容器"的包裹节点。
renderSlots 的使用
ts
// 子组件 Layout:定义三个插槽位置(header、default、footer)
const Layout = {
setup(_, { slots }) {
return () =>
h('div', { class: 'layout' }, [
// 分别渲染 header、default、footer 三个具名插槽
h('header', {}, renderSlots(slots, 'header')),
h('main', {}, renderSlots(slots, 'default')),
h('footer', {}, renderSlots(slots, 'footer')),
])
},
}
// 父组件:向 Layout 的三个插槽位置传入不同的内容
const App = {
setup() {
return () =>
h(Layout, null, {
header: () => h('h1', {}, 'Page Title'), // header 插槽内容
default: () => h('p', {}, 'Main Content'), // 默认插槽内容
footer: () => h('span', {}, '© 2024'), // footer 插槽内容
})
},
}渲染结果:
html
<div class="layout">
<header><h1>Page Title</h1></header>
<main><p>Main Content</p></main>
<footer><span>© 2024</span></footer>
</div>默认插槽的实现
最简单的情况 —— 默认插槽:
ts
// 子组件
const Child = {
setup(_, { slots }) {
return () => h('div', {}, renderSlots(slots, 'default'))
},
}
// 父组件 —— 使用默认插槽
h(Child, null, {
default: () => [h('span', {}, 'hello')],
})对于更简洁的写法(children 直接是数组),也需要兼容:
ts
// 如果 children 是数组,需要转为 { default: () => children }
h(Child, null, [h('span', {}, 'hello')])在 initSlots 中处理这种情况:
ts
// 增强版 initSlots:同时处理对象形式(具名插槽)和数组形式(默认插槽简写)
export function initSlots(
instance: ComponentInternalInstance,
children: any,
) {
const { vnode } = instance
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
// children 是对象 → 包含具名插槽定义
normalizeObjectSlots(children, instance.slots)
} else if (children) {
// 兼容直接传数组子节点的情况(如 h(Child, null, [h('span')]))
// 将数组子节点转为默认插槽
normalizeVNodeSlots(instance, children)
}
}
// 将 VNode 数组形式的 children 转为默认插槽函数
function normalizeVNodeSlots(
instance: ComponentInternalInstance,
children: any,
) {
// 确保 children 是数组格式
const normalized = Array.isArray(children) ? children : [children]
// 创建一个默认插槽函数,返回这些子节点
instance.slots.default = () => normalized
}具名插槽的实现
具名插槽通过 slots 对象的不同 key 区分:
ts
// 子组件 —— 通过 name 参数指定渲染哪个插槽
renderSlots(slots, 'header') // 渲染 header 插槽
renderSlots(slots, 'default') // 渲染 default 插槽
renderSlots(slots, 'footer') // 渲染 footer 插槽当指定的插槽不存在时,renderSlots 返回空 Fragment。
插槽的回退内容(Fallback)
ts
// 增强版 renderSlots:支持 fallback 回退内容
export function renderSlots(
slots: Record<string, Function>,
name: string = 'default',
props: any = {},
fallback?: () => VNode[], // 可选的回退内容生成函数
): VNode {
const slot = slots[name]
if (slot) {
// 插槽存在:调用插槽函数渲染内容
return createVNode(Fragment, {}, slot(props))
}
// 如果插槽不存在但提供了 fallback,使用 fallback 内容作为默认显示
if (fallback) {
return createVNode(Fragment, {}, fallback())
}
// 既无插槽也无 fallback,返回空 Fragment
return createVNode(Fragment, {}, [])
}ts
// 子组件:提供默认内容
renderSlots(slots, 'header', {}, () => [h('h1', {}, 'Default Title')])作用域插槽的实现
作用域插槽是最强大的插槽形式。子组件向插槽函数传递数据,父组件的插槽模板可以使用这些数据:
ts
// 子组件 List:通过作用域插槽将列表项数据传递给父组件控制渲染
const List = {
props: ['items'],
setup(props, { slots }) {
return () =>
h(
'ul',
{},
// 遍历 items 数组,为每一项渲染一个 <li>
props.items.map((item: any, index: number) =>
h(
'li',
{ key: item.id }, // 使用 item.id 作为 key,优化 diff 性能
// 关键:将 { item, index } 作为 props 传递给作用域插槽函数
// 这样父组件的插槽模板就可以使用子组件的数据
renderSlots(slots, 'default', { item, index }),
),
),
)
},
}
// 父组件:使用作用域插槽,定义如何渲染每一项
const App = {
setup() {
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
]
return () =>
h(List, { items }, {
// 接收子组件通过 renderSlots 传递的 { item, index } 参数
// 使用这些数据自定义渲染每一项的内容
default: ({ item, index }) =>
h('span', {}, `${index + 1}. ${item.name}`),
})
},
}渲染结果:
html
<ul>
<li><span>1. Apple</span></li>
<li><span>2. Banana</span></li>
<li><span>3. Cherry</span></li>
</ul>数据流向
子组件 List
│
│ renderSlots(slots, 'default', { item, index })
│ │
▼ ▼
slots.default({ item, index })
│
│ 父组件定义的插槽函数
│ ({ item, index }) => h('span', ...)
│
▼
VNode[] —— 父组件用子组件的数据渲染出 VNode作用域插槽的精髓:渲染逻辑由父组件定义,渲染数据由子组件提供。
插槽更新
当组件更新时,需要同步更新 slots。在 updateComponentPreRender 中:
ts
// 组件更新前的预处理:更新 props 和 slots
function updateComponentPreRender(
instance: ComponentInternalInstance,
nextVNode: VNode, // 新的组件 VNode
) {
instance.vnode = nextVNode // 替换为新的 VNode
instance.next = null // 清除待更新标记
updateProps(instance, nextVNode.props) // 更新 props
updateSlots(instance, nextVNode.children) // 更新 slots
}
// 更新插槽:用新的 children 重新标准化 slots 对象
function updateSlots(
instance: ComponentInternalInstance,
children: any, // 新 VNode 的 children(可能包含更新后的插槽函数)
) {
const { slots } = instance
// 只有 SLOTS_CHILDREN 类型才需要更新插槽
if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
// 重新标准化插槽对象,用新的插槽函数替换旧的
normalizeObjectSlots(children, slots)
}
}对比 React
| 维度 | Vue 3 Slots | React |
|---|---|---|
| 默认内容传递 | 默认插槽 slots.default | props.children |
| 具名内容传递 | 具名插槽 slots.header | 具名 props props.header |
| 数据回传 | 作用域插槽 slot(props) | Render Props props.render(data) |
| 编译产物 | { name: (props) => VNode[] } 对象 | JSX 表达式 |
| 类型检查 | 插槽函数签名 | Render Props 类型 |
| 默认值 | Fallback content | children || defaultContent |
React 对应写法
tsx
// Vue 3 的作用域插槽
<List>
<template #default="{ item }">
<span>{{ item.name }}</span>
</template>
</List>
// React 的 Render Props
<List renderItem={({ item }) => (
<span>{item.name}</span>
)} />
// 或者用 children as function
<List>
{({ item }) => <span>{item.name}</span>}
</List>Vue 的插槽语法更加声明式和直觉化(尤其在模板中),而 React 的 Render Props 更加显式和函数化。两者实现的目的相同:父组件控制渲染逻辑,子组件提供数据。
测试用例
ts
describe('slots', () => {
// 测试默认插槽:子组件能正确渲染父组件传入的默认插槽内容
it('should render default slot', () => {
const Child = {
setup(_: any, { slots }: any) {
// 在子组件中渲染默认插槽
return () => h('div', {}, renderSlots(slots, 'default'))
},
}
const root = document.createElement('div')
render(
h(Child, null, {
default: () => h('span', {}, 'slot content'), // 父组件提供的插槽内容
}),
root,
)
// 断言插槽内容被正确渲染到子组件的 div 中
expect(root.innerHTML).toBe('<div><span>slot content</span></div>')
})
// 测试具名插槽:多个插槽位置能各自渲染对应的内容
it('should render named slots', () => {
const Layout = {
setup(_: any, { slots }: any) {
return () =>
h('div', {}, [
h('header', {}, renderSlots(slots, 'header')), // 渲染 header 插槽
h('main', {}, renderSlots(slots, 'default')), // 渲染 default 插槽
])
},
}
const root = document.createElement('div')
render(
h(Layout, null, {
header: () => h('h1', {}, 'Title'), // header 插槽内容
default: () => h('p', {}, 'Content'), // default 插槽内容
}),
root,
)
// 断言两个具名插槽都被正确渲染到对应位置
expect(root.innerHTML).toBe(
'<div><header><h1>Title</h1></header><main><p>Content</p></main></div>',
)
})
// 测试作用域插槽:子组件传递数据给父组件的插槽函数
it('should render scoped slots', () => {
const List = {
setup(_: any, { slots }: any) {
const items = ['a', 'b', 'c']
return () =>
h(
'ul',
{},
// 遍历 items,将 { item, index } 传递给作用域插槽
items.map((item, index) =>
h('li', { key: index }, renderSlots(slots, 'default', { item, index })),
),
)
},
}
const root = document.createElement('div')
render(
h(List, null, {
// 父组件的插槽函数接收子组件传递的 { item, index }
default: ({ item, index }: any) =>
h('span', {}, `${index}-${item}`),
}),
root,
)
// 断言作用域插槽正确使用了子组件传递的数据
expect(root.innerHTML).toBe(
'<ul><li><span>0-a</span></li><li><span>1-b</span></li><li><span>2-c</span></li></ul>',
)
})
// 测试插槽不存在时的回退行为:应渲染空 Fragment(不显示任何内容)
it('should render empty fragment when slot not provided', () => {
const Child = {
setup(_: any, { slots }: any) {
// 尝试渲染一个未提供的 'missing' 插槽
return () => h('div', {}, renderSlots(slots, 'missing'))
},
}
const root = document.createElement('div')
render(h(Child, null, {}), root)
// 断言未提供的插槽渲染为空(Fragment 不产生额外 DOM 元素)
expect(root.innerHTML).toBe('<div></div>')
})
// 测试 slots 对象的访问:验证 setup 中接收到的 slots 包含正确的插槽函数
it('should access slots via this.$slots', () => {
let slotsRef: any = null
const Child = {
setup(_: any, { slots }: any) {
slotsRef = slots // 捕获 slots 引用以便测试
return () => h('div')
},
}
render(
h(Child, null, {
default: () => h('span'),
header: () => h('h1'),
}),
document.createElement('div'),
)
// 断言 slots 中包含 default 和 header 两个插槽函数
expect(typeof slotsRef.default).toBe('function')
expect(typeof slotsRef.header).toBe('function')
})
// 测试插槽返回值的规范化:单个 VNode 应被自动包装为数组
it('should normalize slot return value to array', () => {
const Child = {
setup(_: any, { slots }: any) {
// 直接调用 slots.default() 获取 VNode 数组
const vnodes = slots.default()
return () => h('div', {}, vnodes)
},
}
const root = document.createElement('div')
render(
h(Child, null, {
// 返回单个 VNode(非数组),应被 normalizeSlotValue 自动包装为数组
default: () => h('span', {}, 'single'),
}),
root,
)
// 断言即使插槽函数返回单个 VNode,也能正确渲染
expect(root.innerHTML).toBe('<div><span>single</span></div>')
})
})本节小结
- 编译产物 — 插槽在编译后是一个
{ name: (props) => VNode[] }的对象 - initSlots — 将 children 标准化为 slots 对象,每个 slot 是返回 VNode 数组的函数
- ShapeFlags.SLOTS_CHILDREN — 标识组件的 children 是插槽类型
- renderSlots — 子组件调用插槽函数,用 Fragment 包裹返回值
- 默认插槽 —
slots.default(),最常用的插槽形式 - 具名插槽 —
slots[name](),通过名称区分不同的插槽位置 - 作用域插槽 —
slots[name](props),子组件向父组件传递数据,父组件控制渲染
下一节实现组件更新流程。