Skip to content

实现 Slots 机制

本节对标 Vue 3 源码 @vue/runtime-core 中的 componentSlots.tshelpers/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 SlotsReact
默认内容传递默认插槽 slots.defaultprops.children
具名内容传递具名插槽 slots.header具名 props props.header
数据回传作用域插槽 slot(props)Render Props props.render(data)
编译产物{ name: (props) => VNode[] } 对象JSX 表达式
类型检查插槽函数签名Render Props 类型
默认值Fallback contentchildren || 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>')
  })
})

本节小结

  1. 编译产物 — 插槽在编译后是一个 { name: (props) => VNode[] } 的对象
  2. initSlots — 将 children 标准化为 slots 对象,每个 slot 是返回 VNode 数组的函数
  3. ShapeFlags.SLOTS_CHILDREN — 标识组件的 children 是插槽类型
  4. renderSlots — 子组件调用插槽函数,用 Fragment 包裹返回值
  5. 默认插槽slots.default(),最常用的插槽形式
  6. 具名插槽slots[name](),通过名称区分不同的插槽位置
  7. 作用域插槽slots[name](props),子组件向父组件传递数据,父组件控制渲染

下一节实现组件更新流程。

用心学习,用代码说话 💻