组件插槽机制 -> 插槽系统与组件通信的实现
在之前的章节中,我们已经实现了组件的基本功能和 Props 传递机制。除了通过 Props 传递数据之外,Vue 还提供了强大的 插槽(Slots) 机制,允许父组件向子组件传递模板内容。
插槽类型概述
Vue 支持三种主要的插槽类型:
- 匿名插槽:对应
default插槽,用于传递默认内容 - 具名插槽:通过名称标识,如
header、footer等,用于传递具名内容 - 作用域插槽:通过函数传参,允许子组件向父组件传递数据
插槽使用示例
让我们先通过一个完整的示例来理解插槽的使用:
<div id="app"></div>
<script type="module">
import { h, createApp } from '../dist/vue.esm.js'
const Child = {
setup(props, { slots }) {
return () => {
return h('div', [
slots.header(),
slots.default(),
slots.footer({ a: 1 })
])
}
}
}
const Comp = {
setup() {
return () => {
return h('div', [
h('p', '父组件的标识'),
h(Child, null, {
header: () => h('div', 'header slot'),
default: () => h('div', 'default slot'),
footer: ({ a }) => h('div', `footer slot -> ${a}`)
})
])
}
}
}
createApp(Comp).mount('#app')
</script>
重构 children 处理逻辑
在之前的实现中,我们只处理了元素的 children,但组件的 children 只能是插槽。虽然功能上没有问题,但不符合单一职责原则。现在我们来重构代码,将 children 处理逻辑归类到 normalizeChildren 中。
createVNode 的重构
export function createVNode(type, props?, children?) {
let shapeFlag
if (isString(type)) {
shapeFlag = ShapeFlags.ELEMENT
}
else if (isObject(type)) {
shapeFlag = ShapeFlags.STATEFUL_COMPONENT
}
const vnode = {
__v_isVNode: true,
type,
props,
children: null,
key: props?.key,
el: null, // 虚拟节点要挂载的元素
shapeFlag
}
/**
* 标准化 children
* 设置 children 的 shapeFlag
*/
normalizeChildren(vnode, children)
return vnode
}
设计要点:
- 将
children标准化逻辑从createVNode中抽离 - 通过
normalizeChildren统一处理不同类型的children
normalizeChildren 的实现
function normalizeChildren(vnode, children) {
let { shapeFlag } = vnode
if (isArray(children)) {
/**
* [h('span', 'hello'), h('span', 'world')]
* 数组类型的 children,设置为数组子节点标志
*/
shapeFlag |= ShapeFlags.ARRAY_CHILDREN
}
else if (isObject(children)) {
/**
* { header: () => {}, footer: () => {}, default: () => {} }
* 对象类型的 children,可能是插槽
*/
if (shapeFlag & ShapeFlags.COMPONENT) {
shapeFlag |= ShapeFlags.SLOTS_CHILDREN
}
}
else if (isFunction(children)) {
/**
* children => () => h('div', 'hello world')
* 函数类型的 children,转换为默认插槽
*/
if (shapeFlag & ShapeFlags.COMPONENT) {
// 如果是组件,则是插槽
shapeFlag |= ShapeFlags.SLOTS_CHILDREN
children = { default: children }
}
}
else if (isNumber(children) || isString(children)) {
children = String(children)
shapeFlag |= ShapeFlags.TEXT_CHILDREN
}
/**
* 处理完重新赋值
*/
vnode.shapeFlag = shapeFlag
vnode.children = children
return children
}
逻辑说明:
- 数组类型:设置为数组子节点标志
- 对象类型:如果是组件,设置为插槽子节点标志
- 函数类型:如果是组件,转换为默认插槽对象
- 基本类型:转换为字符串,设置为文本子节点标志
组件初始化流程调整
现在我们需要调整 setupComponent 函数,添加插槽初始化:
export function setupComponent(instance) {
/**
* 1. 初始化属性
* 2. 初始化插槽
* 3. 初始化状态
*/
initProps(instance)
initSlots(instance)
setupStateFulComponent(instance)
}
设计要点:
- 将插槽初始化作为组件初始化的重要步骤
- 按照 Props → Slots → State 的顺序进行初始化
插槽初始化实现
现在来实现 initSlots 函数,在 packages/runtime-core/src/componentSlots.ts 中:
export function initSlots(instance) {
const { slots, vnode } = instance
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const { children } = vnode
/**
* children = { header: () => h('div', 'hello world') }
* 将父组件传递的插槽内容保存到子组件实例中
*/
for (const key in children) {
slots[key] = children[key]
}
}
}
插槽对象结构:
const slots = {
header: () => h('div', 'header slot'),
default: () => h('div', 'default slot'),
footer: ({ a }) => h('div', `footer slot -> ${a}`)
}
设计要点:
- 只在存在插槽子节点时进行初始化
- 遍历插槽对象,将每个插槽函数保存到组件实例的
slots属性中
更新 Setup Context
在子组件的 setup 函数中,可以通过第二个参数获取到 slots。现在修改 component.ts:
function createSetupContext(instance) {
return {
get attrs() {
return instance.attrs
},
emit(event, ...args) {
emit(instance, event, ...args)
},
slots: instance.slots
}
}
设计要点:
- 将
slots添加到 setup context 中 - 子组件可以通过解构
{ slots }获取插槽对象
插槽更新机制
插槽不仅需要在挂载时初始化,还需要在更新时处理。让我们来实现插槽的更新功能。
插槽更新实现
在 packages/runtime-core/src/componentSlots.ts 中添加 updateSlots 函数:
export function updateSlots(instance) {
const { slots, vnode } = instance
// 组件的资源是插槽
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const { children } = vnode
// 1. 更新新的插槽
for (const key in children) {
slots[key] = children[key]
}
// 2. 删除不再存在的插槽
for (const key in slots) {
if (children[key] == null) {
delete slots[key]
}
}
}
}
更新逻辑:
- 更新新插槽:将最新的插槽内容更新到
slots中 - 删除旧插槽:删除新虚拟节点中不再存在的插槽
集成到更新流程
插槽更新需要在组件更新时调用,在 updateComponentPreRender 中添加:
// render.ts
function updateComponentPreRender(instance, nextVNode) {
instance.vnode = nextVNode
instance.next = null
updateProps(instance, nextVNode)
updateSlots(instance)
}
设计要点:
- 在更新 Props 之后更新 Slots
- 确保组件更新时插槽内容能够同步更新
总结
至此,我们完成了 Vue 组件插槽机制的完整实现:
1. 插槽类型支持
- 匿名插槽:
default插槽,作为默认内容分发 - 具名插槽:通过名称标识,实现内容精确分发
- 作用域插槽:支持子组件向父组件传递数据,实现灵活的数据交互
2. children 标准化
- 将
children处理逻辑统一到normalizeChildren函数 - 根据不同类型设置相应的
shapeFlag - 将函数类型的 children 转换为插槽对象
3. 插槽初始化机制
initSlots:在组件挂载时初始化插槽- 遍历插槽对象,将每个插槽函数保存到组件实例
- 在 setup context 中暴露
slots属性
4. 插槽更新机制
updateSlots:在组件更新时处理插槽变更- 更新新的插槽内容
- 删除不再存在的插槽
- 集成到
updateComponentPreRender流程中
这套插槽机制与 Props 机制相结合,形成了 Vue 完整的组件通信体系,是 Vue 组件化开发的重要基础。通过插槽,我们可以创建更加灵活、可复用的组件,提升开发效率和代码质量。