Skip to content

插槽 Slot:内容分发的实现原理

插槽(Slot)是 Vue 中实现“内容分发”的机制。它允许父组件定义一段模板内容,并将其“插入”到子组件的指定位置。

<slot> 的实现原理横跨了编译时运行时。其流程是:

  1. 父组件(编译时):将 <template v-slot:header> 编译成一个“插槽函数”。
  2. 父组件(运行时):执行 render,将“插槽函数”作为 children 传递给子组件。
  3. 子组件(运行时): a. initSlots:接收并“规范化”父组件传来的“插槽函数”。 b. renderSlot:在子组件需要渲染插槽时,执行这个“插槽函数”并渲染其返回的 VNode。

“编译时”:buildSlots 如何将模板变为“插槽函数”

插槽的实现始于父组件transform(转换)阶段。当编译器遇到一个包含 v-slot 的组件时,会调用 buildSlots 函数。

父组件的模板:

vue
<MyComponent>
  <template #header="{ user }">
    <h1>Hello, {{ user.name }}</h1>
  </template>
  
  <p>Default content</p>
</MyComponent>

buildSlots 会将这些 <template> 标签和“游离”的节点(如 <p> 标签)转换成一个JS 对象,这个对象将作为 children 属性传递给 MyComponent

typescript
// core/packages/compiler-core/src/transforms/vSlot.ts
export function buildSlots(
  node: ElementNode,
  context: TransformContext,
): {
  slots: SlotsExpression // 这是一个 JS 对象表达式
  hasDynamicSlots: boolean
} {
  const slotsProperties: Property[] = [] // 存储 { key: value, ... }

  // 1. 遍历子节点,查找 <template v-slot>
  for (let i = 0; i < node.children.length; i++) {
    const slotElement = node.children[i]
    
    // 2. 找到一个 <template v-slot:header="{ user }">
    if (isTemplateNode(slotElement) && (slotDir = findDir(slotElement, 'slot'))) {
      const { children: slotChildren, loc: slotLoc } = slotElement
      const {
        arg: slotName,  // #header -> { content: 'header' }
        exp: slotProps  // { user } -> { content: '{ user }' }
      } = slotDir

      // 3. 【核心】创建“插槽函数”
      const slotFunction = buildSlotFn(slotProps, vFor, slotChildren, slotLoc)

      // 4. 添加到对象属性
      slotsProperties.push(
        createObjectProperty(slotName, slotFunction)
      )
    } else {
      // 5. 处理 "p" 这样的游离节点
      implicitDefaultChildren.push(slotElement)
    }
  }

  // 6. 处理默认插槽
  if (implicitDefaultChildren.length) {
    slotsProperties.push(
      createObjectProperty(
        'default',
        buildSlotFn(undefined, vFor, implicitDefaultChildren, loc)
      )
    )
  }

  // 7. 返回最终的 JS 对象表达式
  return {
    slots: createObjectExpression(slotsProperties), // { header: fn, default: fn }
    hasDynamicSlots
  }
}

// buildSlotFn (简化后)
function buildSlotFn(slotProps, vFor, children, loc) {
  // 返回一个函数表达式:(slotProps) => [ ...children VNodes... ]
  return createFunctionExpression(slotProps, children, ...)
}

buildSlots 的编译产物(概念上):

javascript
{
  header: ({ user }) => [ 
    _createElementVNode("h1", null, "Hello, " + _toDisplayString(user.name)) 
  ],
  default: () => [ 
    _createElementVNode("p", null, "Default content") 
  ]
}

为什么必须是函数?transform 阶段将插槽内容编译为函数,这是 Slot 机制的核心

  1. 懒执行 (Lazy Execution):父组件只是“定义”了这个函数,但不执行它。插槽内容(h1, p)的 VNode 只在子组件 renderSlot 时才被创建,这优化了性能。
  2. 作用域插槽 (Scoped Slots){ user } 成为了这个函数的参数。这使得子组件在“执行”这个函数时,能把自己的数据(user)“传递”给父组件的作用域。

“运行时 (子)”:initSlots 接收并规范化插槽

父组件render 函数执行后,上面那个 { header: fn, default: fn } 对象就作为 children 传递给了 MyComponent

现在,子组件MyComponent)开始初始化。initSlots 函数被调用,负责接收 children 并将其存入 instance.slots

typescript
// core/packages/runtime-core/src/componentSlots.ts
export const initSlots = (
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren, // 父组件传来的 { header: fn, ... }
): void => {
  // 1. 检查 vnode 是否标记为 SLOTS_CHILDREN
  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const slots = (instance.slots = createInternalObject())
    
    // 2. 【核心】规范化对象插槽
    normalizeObjectSlots(children as RawSlots, slots, instance)
    
  } else if (children) {
    // 3. (降级处理) 如果 children 不是对象(如 VNode 数组),
    //    则将其规范化为“默认插槽”
    normalizeVNodeSlots(instance, children)
  }
}

// 规范化,本质是遍历 + 包装
const normalizeObjectSlots = (
  rawSlots: RawSlots, // { header: fn, ... }
  slots: InternalSlots, // instance.slots
  instance: ComponentInternalInstance,
) => {
  const ctx = rawSlots._ctx // 上下文
  for (const key in rawSlots) {
    if (isInternalKey(key)) continue // 跳过 _ctx, $stable 等
    
    const value = rawSlots[key]
    if (isFunction(value)) {
      // 【核心】使用 normalizeSlot 包装
      slots[key] = normalizeSlot(key, value, ctx)
    } else {
      // (降级处理) 如果提供了非函数内容(静态内容)
      // 也将其包装成一个函数
      const normalized = normalizeSlotValue(value)
      slots[key] = () => normalized
    }
  }
}

normalizeSlot 的主要工作是使用 withCtx 包装原始的插槽函数,以确保它在执行时能绑定到正确的渲染上下文ctx),从而确保依赖追踪等功能正常工作。


“运行时 (子)”:renderSlot 执行插槽函数

插槽已经被“注册”到了 instance.slots 中。现在,子组件 MyComponent 开始 render

子组件的模板:

vue
<div class="container">
  <header>
    <slot name="header" :user="currentUser" />
  </header>
  <main>
    <slot />
  </main>
</div>

编译时<slot> 标签会被 transformSlotOutlet 插件转换renderSlot 函数调用。

子组件的 render 函数 (概念):

javascript
function render() {
  return _createElementVNode("div", { class: "container" }, [
    _createElementVNode("header", null, [
      // 1. 转换 <slot name="header" ... />
      _renderSlot(
        _ctx.$slots, // instance.slots
        "header",    // name
        { user: _ctx.currentUser } // props
      ) 
    ]),
    _createElementVNode("main", null, [
      // 2. 转换 <slot />
      _renderSlot(
        _ctx.$slots, 
        "default"
      ) 
    ])
  ])
}

renderSlot 的实现:

renderSlot 是插槽机制的执行步骤。它负责查找执行“插槽函数”。

typescript
// core/packages/runtime-core/src/helpers/renderSlot.ts
export function renderSlot(
  slots: Slots,        // instance.slots
  name: string,        // 'header'
  props: Data = {},    // { user: currentUser }
  fallback?: () => VNodeArrayChildren, // (插槽的默认内容)
): VNode {
  // 1. 查找:从 slots 对象中找到 'header' 对应的函数
  let slot = slots[name]

  // ... (省略 DEV 警告和自定义元素处理) ...

  // 2. 执行:
  //    如果找到了插槽函数,就执行它,
  //    并将 props ({ user: ... }) 作为参数传递进去。
  const validSlotContent = slot && ensureValidVNode(slot(props))

  // 3. 创建 Fragment:
  //    将插槽函数返回的 VNode 数组,
  //    或 fallback (后备内容) 返回的 VNode 数组,
  //    包装在一个 Fragment (片段) VNode 中并返回。
  return createBlock(
    Fragment,
    { key: props.key || `_${name}` },
    validSlotContent || (fallback ? fallback() : []),
    // ... (处理 PatchFlags) ...
  )
}

总结

Vue 3 的插槽系统是一个编译时运行时协同的机制:

  1. 编译时 (父)buildSlots<template v-slot> 转换成一个“插槽函数对象”({ header: (props) => [...] })。使用函数是实现懒执行作用域传参的关键。
  2. 运行时 (父):父组件 render,将这个“插槽函数对象”作为 children 传递给子组件。
  3. 运行时 (子 initSlots):子组件在初始化时,接收这个对象,通过 normalizeObjectSlots 将其包装并存入 instance.slots
  4. 运行时 (子 renderSlot):子组件模板中的 <slot> 标签被编译成 renderSlot(...) 调用。
  5. 执行renderSlotinstance.slots查找对应的“插槽函数”,执行它(同时传递 props),并最终渲染函数返回的 VNode 数组。

微信公众号二维码

Last updated: