Skip to content

Vue 插槽实现原理笔记

一、核心思想

插槽的本质是父组件传递给子组件的、用于渲染 VNode 的函数

  • 父组件定义 如何渲染 (提供函数体)。
  • 子组件决定 何时何地渲染 (调用函数)。

二、插槽的定义与调用

1. 子组件:定义插槽(挖坑)

在子组件的模板中,使用 <slot> 标签定义插槽的渲染位置。

  • 具名作用域插槽:向父组件暴露数据。
    vue
    <slot name="header" :title="'Hello Slot'"></slot>
  • 默认插槽:可以包含默认内容,在父组件未提供内容时显示。
    vue
    <slot>
      <p>如果你没传,就会显示我这个默认内容</p>
    </slot>
  • 具名插槽:普通的命名插槽。
    vue
    <slot name="footer"></slot>

2. 父组件:填充插槽(填坑)

在父组件中使用子组件时,通过 <template> 标签和 v-slot (简写 #) 指令来提供插槽内容。

vue
<HelloWorld>
  <template #header="{ title }">
    <h1>{{ title }} - 这是父组件渲染的内容</h1>
  </template>

  <template #default>
    <div>这是默认插槽的内容</div>
  </template>

  <template #footer>
    <p>这是 Footer 的内容</p>
  </template>
</HelloWorld>

三、原理剖析:插槽即函数

1. 子组件如何“接收”插槽

在子组件的 setup 函数中,可以通过 useSlots (Composition API) 或 this.$slots (Options API) 来访问父组件传递过来的所有插槽。

slots 是一个对象,其 key 是插槽名,value 是一个函数

javascript
import { useSlots } from 'vue';

export default {
  setup() {
    const slots = useSlots();
    
    // 打印 slots 对象
    console.log(slots); 
    // 输出:
    // {
    //   header: (props) => [ VNode, ... ],
    //   default: () => [ VNode, ... ],
    //   footer: () => [ VNode, ... ]
    // }
  }
}

2. 子组件如何“调用”插槽

子组件的模板最终会被编译成渲染函数。在渲染函数中,原本 <slot> 标签所在的位置会变成对 slots 对象中相应函数的调用

  • <slot name="header" :title="..."></slot> 编译为 slots.header({ title: ... })
  • <slot></slot> 编译为 slots.default()
  • <slot name="footer"></slot> 编译为 slots.footer()

处理默认内容的逻辑

javascript
// 如果父组件提供了 default 插槽,则调用它;否则渲染默认内容。
slots.default ? slots.default() : [ h('p', '这是默认内容') ];

每个插槽函数执行后,都会返回一个由 VNode (虚拟节点) 组成的数组。


四、模板编译的视角

Vue 模板无法直接在浏览器中运行,它必须先被编译成 JavaScript(通常是 h() 函数或 createVNode 函数的调用)。

1. 父组件的编译结果

父组件在使用子组件并为其提供插槽时,会编译成类似下面的代码。本质是:渲染子组件时,将一个描述所有插槽的对象作为第三个参数传递进去。

javascript
import { h } from 'vue';
import HelloSlot from './HelloSlot.vue';

// 原始模板 <HelloWorld>...</HelloWorld>
// 编译后大致如下:
export default {
  render() {
    return h(
      HelloSlot,  // 1. 要渲染的子组件
      null,       // 2. Props (此处为 null)
      {           // 3. Slots 对象
        // header 插槽是一个函数,接收 title,返回一个 h1 VNode
        header: ({ title }) => h('h1', null, title + ' - JS 组件'),

        // default 插槽是一个函数,返回一个 div VNode
        default: () => h('div', null, '这是默认插槽的内容'),
        
        // footer 插槽是一个函数,返回一个 p VNode
        footer: () => h('p', null, '这是 Footer 的内容')
      }
    );
  }
}

2. 子组件的编译结果

子组件的模板会编译成一个接收 propscontext (包含slots) 的函数。它在自己的渲染逻辑中调用context.slots 中获取的函数。

javascript
import { h } from 'vue';

// 原始模板 <div class="child"> <slot name="header" ... /> ... </div>
// 编译后大致如下:
export default {
  // context 对象里包含 attrs, emit, slots 等
  setup(props, context) { 
    const { slots } = context;

    return () => h('div', null, [
      // 调用 header 函数并传入数据
      slots.header({ title: 'Hello Slot' }),
      
      // 调用 default 函数
      slots.default ? slots.default() : h('p', '默认内容'),
      
      // 调用 footer 函数
      slots.footer()
    ]);
  }
}

五、延伸与对比

结论:任何能返回 VNode 的函数,都可以被 Vue 渲染。

插槽是父组件向子组件传递“渲染逻辑”(函数)的一种语法糖。我们也可以通过 props 传递一个函数来实现类似的效果。

示例:通过 Prop 传递一个返回 VNode 的函数。

javascript
// 父组件
<ChildComponent :renderFunc="() => h('div', null, '我是通过 Prop 渲染的')" />

// 子组件
export default {
  props: {
    renderFunc: Function
  },
  setup(props) {
    // 直接调用 prop 里的函数即可渲染
    return () => props.renderFunc();
  }
}

这证明了插槽的本质就是函数传递,无论是通过 slots 机制还是 props 机制,只要最终能拿到一个返回 VNode 的函数并执行它,Vue 就能将其渲染出来。