识别并初始化组件插槽,并通过 setupContext 暴露给子组件

2025-10-29 11:35:33

之前实现了组件事件的触发。

除了通过 props 传递数据,父组件还需要向子组件传递一段可以自定义的内容,这就是插槽。

import { createApp, h } from '../dist/vue.esm.js'

const Child = {
  setup(_, { slots }) {
    return () => {
      return h('div', [
        slots.header(),
        slots.default(),
        slots.footer({ c: 1 })
      ])
    }
  }
}

const Com = {
  setup() {
    return () => {
      return h(Child, null, {
        header: () => h('div', 'header'),
        default: () => h('div', 'default'),
        footer: ({ c }) => h('div', `footer-${c}`)
      })
    }
  }
}

createApp(Com).mount('#app')

传递了三种插槽:

  • header:具名插槽
  • default:默认插槽
  • footer:作用域插槽

父组件通过 h 的第三个参数传递插槽,子组件通过 setup 的第二个参数访问 slots。

slots = {
  header: () => h('div', 'header'),
  default: () => h('div', 'default'),
  footer: ({ c }) => h('div', `footer-${c}`)
}

所以插槽的本质就是一组返回虚拟节点的函数。

实现插槽

1. 识别插槽

之前处理 children 时,主要考虑了元素节点的文本和数组子节点。

但当 vnode 表示组件时,组件的 children 不再是普通的子节点,而是父组件传入的插槽。

因此,需要在 normalizeChildren 中根据 vnode 的类型标记插槽:

// packages/runtime-core/src/vnode.ts
function normalizeChildren(vnode, children) {
  let { shapeFlag } = vnode

  if (isArray(children)) {
    shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  }
  else if (isObject(children)) {
    if (shapeFlag & ShapeFlags.COMPONENT) {
      shapeFlag |= ShapeFlags.SLOTS_CHILDREN
    }
  }
  else if (isFunction(children)) {
    if (shapeFlag & ShapeFlags.COMPONENT) {
      shapeFlag |= ShapeFlags.SLOTS_CHILDREN
      children = {
        default: children
      }
    }
  }
  else if (isString(children) || isNumber(children)) {
    children = String(children)
    shapeFlag |= ShapeFlags.TEXT_CHILDREN
  }

  vnode.children = children
  vnode.shapeFlag = shapeFlag

  return children
}

对于组件类型的 vnode:

  • 如果 children 是对象,直接将它标记为插槽
  • 如果 children 是函数,将它转换成 default 插槽

比如这种

h(Child, null, () => h('div', 'hello world'))

将转换成

{
  default: () => h('div', 'hello world')
}

这样,无论父组件使用具名插槽还是默认插槽,内部都可以统一通过对象形式访问。

2. 初始化插槽

在 setupComponent 中初始化插槽:

// packages/runtime-core/src/component.ts
export function setupComponent(instance) {
  initProps(instance)
  initSlots(instance)
  setupStatefulComponent(instance)
}

initSlots 只需要处理带有 SLOTS_CHILDREN 标记的组件:

// packages/runtime-core/src/componentSlots.ts
import { ShapeFlags } from '@vue/shared'

export function initSlots(instance) {
  const { vnode, slots } = instance

  if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const { children } = vnode

    for (const key in children) {
      slots[key] = children[key]
    }
  }
}

初始化完成后,组件实例中的 slots 大致是:

slots = {
  header: () => h('div', 'header'),
  default: () => h('div', 'default'),
  footer: ({ c }) => h('div', `footer-${c}`)
}

3. 暴露给 setup

前面已经通过 setupContext 暴露了 attrs 和 emit。

现在把组件实例上的 slots 也放到 setupContext 中:

// packages/runtime-core/src/component.ts
function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    },
    emit(event, ...args) {
      emit(instance, event, ...args)
    },
    slots: instance.slots
  }
}

这样,子组件就可以在 setup 中访问插槽:

const Child = {
  setup(_, { slots }) {
    return () => {
      return h('div', [
        slots.header(),
        slots.default(),
        slots.footer({ c: 1 })
      ])
    }
  }
}

调用插槽函数时,可以传入参数。父组件中的作用域插槽会接收到这些参数:

footer: ({ c }) => h('div', `footer-${c}`)

这就是作用域插槽的基本原理:数据由子组件提供,内容由父组件定义。