2026.01.27

组件插槽机制 -> 插槽系统与组件通信的实现

在之前的章节中,我们已经实现了组件的基本功能和 Props 传递机制。除了通过 Props 传递数据之外,Vue 还提供了强大的 插槽(Slots) 机制,允许父组件向子组件传递模板内容。

在之前的章节中,我们已经实现了组件的基本功能和 Props 传递机制。除了通过 Props 传递数据之外,Vue 还提供了强大的 插槽(Slots) 机制,允许父组件向子组件传递模板内容。

插槽类型概述

Vue 支持三种主要的插槽类型:

  • 匿名插槽:对应 default 插槽,用于传递默认内容
  • 具名插槽:通过名称标识,如 headerfooter 等,用于传递具名内容
  • 作用域插槽:通过函数传参,允许子组件向父组件传递数据

插槽使用示例

让我们先通过一个完整的示例来理解插槽的使用:

<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
}

逻辑说明:

  1. 数组类型:设置为数组子节点标志
  2. 对象类型:如果是组件,设置为插槽子节点标志
  3. 函数类型:如果是组件,转换为默认插槽对象
  4. 基本类型:转换为字符串,设置为文本子节点标志

组件初始化流程调整

现在我们需要调整 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]
      }
    }
  }
}

更新逻辑:

  1. 更新新插槽:将最新的插槽内容更新到 slots
  2. 删除旧插槽:删除新虚拟节点中不再存在的插槽

集成到更新流程

插槽更新需要在组件更新时调用,在 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 组件化开发的重要基础。通过插槽,我们可以创建更加灵活、可复用的组件,提升开发效率和代码质量。