Vue3 源码解读-Slot 插槽实现原理

999 阅读6分钟

vuejs3.png

💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4)

欢迎关注公众号:《前端 Talkking》

1、插槽使用

在 Vuejs 中,如何实现子组件接收父组件的模版内容然后渲染呢?答案是我们可以使用插槽 slot

举例来说,这里有一个 <FancyButton> 组件,可以像这样使用:

<FancyButton>
  Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> 的模板是这样的:

<button class="fancy-btn">
  <slot></slot> <!-- 插槽出口 -->
</button>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

Slot-01-2023-12-19-11:31:28.png

最终渲染出的 DOM 是这样:

<button class="fancy-btn">Click me!</button>

如果我们希望子组件有多个插槽,则可以使用具名插槽。举一个例子:

<div class="layout">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

我们在 BaseLayout 组件中定义了多个插槽,并且其中两个插槽标签还添加了 name 属性(没有设置 name 属性则默认 name 是 default),然后我们在父组件中可以这么使用 BaseLayout 组件:

<template>
  <div class="container">
    <template v-slot:header>
      <h1>{{ header }}</h1>
    </template>
    <template v-slot:default>
      <p>{{ main }}</p>
    </template>
    <template v-slot:footer>
      <p>{{ footer }}</p>
    </template>
  </layout>
</template>
<script>
  export default {
    data (){
      return {
        header: 'Here might be a page title',
        main: 'A paragraph for the main content.',
        footer: 'Here\'s some contact info'
      }
    }
  }
</script>

Slot-02-2023-12-19-11:41:55.png

最终 BaseLayout 组件渲染的 HTML 如下:

<div class="layout">
  <header>
    <h1>Here might be a page title</h1>
  </header>
  <main>
    <p>A paragraph for the main content.</p>
  </main>
  <footer>
    <p>Here's some contact info</p>
  </footer>
</div>

同时,Vuejs 还提供的作用域插槽的用法,具体用法可以参见:作用域插槽

以上就是插槽的常见使用方法,接下来,我们一起来看看插槽背后实现的原理吧。

2、插槽源码实现

通过以上两个示例,我们得知插槽其实就是在父组件中去编写子组件插槽部分的模版,然后在子组件渲染的时候,将这部分模版内容填充到子组件的插槽中。

因此,在父组件渲染阶段,子组件插槽部分的 DOM 是不能渲染的,需要将这部分模版内容保存下来,等到子组件渲染的时候再渲染。顺着这个思路,我们来看源码具体实现。

2.1 父组件渲染流程实现

我们有父组件:

<layout>
  <template v-slot:header>
    <h1>{{ header }}</h1>
  </template>
  <template v-slot:default>
    <p>{{ main }}</p>
  </template>
  <template v-slot:footer>
    <p>{{ footer }}</p>
  </template>
</layout>

借助Vue SFC Playground,我们可以看它编译后的代码:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_layout = _resolveComponent("layout")

  return (_openBlock(), _createBlock(_component_layout, null, {
    header: _withCtx(() => [
      _createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
    ]),
    default: _withCtx(() => [
      _createElementVNode("p", null, _toDisplayString($data.main), 1 /* TEXT */)
    ]),
    footer: _withCtx(() => [
      _createElementVNode("p", null, _toDisplayString($data.footer), 1 /* TEXT */)
    ]),
    _: 1 /* STABLE */
  }))
}

查看编译后的代码,我们发现 createBlock第三个参数是一个对象,它表示创建的 vnode子节点。createBlock内部调用了 _createVNode函数创建 vnode节点。此时传入的 children就是一个对象,needFullChildrenNormalization为 true。

_createVNode 源码实现


// packages/runtime-core/src/vnode.ts
function _createVNode(
  // 编译后的.vue文件形成的对象
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  // 给组件传递的props
  props: (Data & VNodeProps) | null = null,
  // 子组件
  children: unknown = null,
  // patch的类型
  patchFlag: number = 0,
  // 动态的props
  dynamicProps: string[] | null = null,
  // 是否是block节点
  isBlockNode = false
): VNode {
  // 省略部分代码

  // 调用更基层的方法处理
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    // needFullChildrenNormalization是true,还会执行normlizeChildren去标准化子节点
    true
  )
}

// packages/runtime-core/src/vnode.ts
function createBaseVNode(
  // 创建的虚拟节点的类型
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag = 0,
  dynamicProps: string[] | null = null,
  shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
  isBlockNode = false,
  needFullChildrenNormalization = false
) {
  const vnode = {
    // 这是一个vnode
    __v_isVNode: true,
    // 不进行响应式处理
    __v_skip: true,
    // .vue文件编译后的对象
    type,
    // 组件收到的props
    props,
    // 组件key
    key: props && normalizeKey(props),
    // 收集到的ref
    ref: props && normalizeRef(props),
    // 当前作用域ID
    scopeId: currentScopeId,
    // 插槽ID
    slotScopeIds: null,
    // 子节点
    children,
    // 组件实例
    component: null,
    suspense: null,
    ssContent: null,
    ssFallback: null,
    dirs: null,
    transition: null,
    el: null,
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    // 当前虚拟节点的类型
    shapeFlag,
    // patch类型
    patchFlag,
    // 动态props
    dynamicProps,
    dynamicChildren: null,
    appContext: null,
    ctx: currentRenderingInstance
  } as VNode
  // 是否需要对children进行标准化
  if (needFullChildrenNormalization) {
    normalizeChildren(vnode, children)
    // 处理SUSPENSE逻辑
    // normalize suspense children
    if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
      ;(type as typeof SuspenseImpl).normalize(vnode)
    }
  } else if (children) {
    // compiled element vnode - if children is passed, only possible types are
    // string or Array.
    // 设置shapeFlags
    vnode.shapeFlag |= isString(children)
      ? ShapeFlags.TEXT_CHILDREN
      : ShapeFlags.ARRAY_CHILDREN
  }

  // 省略部分代码

  return vnode
}

在 createBaseVNode 内部中判断了 needFullChildrenNormalization为 true,此时会执行 normalizeChildren函数去标准化 children 子节点,继续深入 normalizeChildren函数看一下它的实现:

normalizeChildren 源码实现

export function normalizeChildren(vnode: VNode, children: unknown) {
  let type = 0
  const { shapeFlag } = vnode
  if (children == null) {
    children = null
  } else if (isArray(children)) {
    type = ShapeFlags.ARRAY_CHILDREN
  } else if (typeof children === 'object') {
    if (shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.TELEPORT)) {
      // Normalize slot to plain children for plain element and Teleport
      const slot = (children as any).default
      if (slot) {
        // _c marker is added by withCtx() indicating this is a compiled slot
        slot._c && (slot._d = false)
        normalizeChildren(vnode, slot())
        slot._c && (slot._d = true)
      }
      return
    } else {
      type = ShapeFlags.SLOTS_CHILDREN
      const slotFlag = (children as RawSlots)._
      if (!slotFlag && !(InternalObjectKey in children!)) {
        // if slots are not normalized, attach context instance
        // (compiled / normalized slots already have context)
        ;(children as RawSlots)._ctx = currentRenderingInstance
      } else if (slotFlag === SlotFlags.FORWARDED && currentRenderingInstance) {
        // a child component receives forwarded slots from the parent.
        // its slot type is determined by its parent's slot type.
        if (
          (currentRenderingInstance.slots as RawSlots)._ === SlotFlags.STABLE
        ) {
          ;(children as RawSlots)._ = SlotFlags.STABLE
        } else {
          ;(children as RawSlots)._ = SlotFlags.DYNAMIC
          vnode.patchFlag |= PatchFlags.DYNAMIC_SLOTS
        }
      }
    }
  } else if (isFunction(children)) {
    children = { default: children, _ctx: currentRenderingInstance }
    type = ShapeFlags.SLOTS_CHILDREN
  } else {
    children = String(children)
    // force teleport children to array so it can be moved around
    if (shapeFlag & ShapeFlags.TELEPORT) {
      type = ShapeFlags.ARRAY_CHILDREN
      children = [createTextVNode(children as string)]
    } else {
      type = ShapeFlags.TEXT_CHILDREN
    }
  }
  vnode.children = children as VNodeNormalizedChildren
  vnode.shapeFlag |= type
}

分析以上代码,normalizeChildren函数的主要作用就是标准化 children以及 vnode的节点类型 shapeFlag

这里,我们重点关注以下两行代码的值:

 vnode.children = children as VNodeNormalizedChildren
 vnode.shapeFlag |= type

此时,childrenobject类型,经过处理,vnode.children是插槽对象,而 vnode.shapeFlag会与 slot 子节点类型 SLOTS_CHILDREN进行或运算,由于 vnode 本身的 shapFlagSTATEFUL_COMPONENT,所以运算后的 shapeFlagSLOTS_CHILDREN | STATEFUL_COMPONENT(运算后的值为 6)。

确定了 shapeFlag后会影响 patch流程,我们来看看 patch函数的实现:

const patch: PatchFn = (
    // 旧vnode
    n1,
    // 新vnode
    n2,
    // 挂载的容器
    container,
    // 挂载参考的锚点
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
	// 省略部分代码
    const { type, ref, shapeFlag } = n2
    // 根据不同的节点类型进行不同的处理规则
    switch (type) {
      case Text:
        // 处理文本节点
        break
      case Comment:
     	// 处理注释节点
        break
      case Static:
        // 处理静态节点
        break
      case Fragment:
        // 处理Fragment节点
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
         // 处理普通DOM元素
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 处理普通DOM元素
          processComponent(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
       		// 处理TELEPORT
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        	// 处理SUSPENSE
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }

    // set ref
    // 设置ref引用
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

在上面分析得知,shapeFlag的值是6,因此会走到 processComponent逻辑,递归渲染子组件。

到目前为止,带有子节点插槽的组件渲染与普通组件的渲染并没有任何区别,还是通过递归的方式去进行渲染的,组件中插槽对象则保留在组件的 vnodechildren属性中。

2.2 子组件渲染流程实现

渲染子组件会调用 processComponent函数,调用流程如下:processComponent->mountComponent->setupComponent->initSlots。我们沿着这条主线继续分析子组件的渲染流程。

setupComponent函数中,调用了 initSlots函数来初始化插槽,并传入 instance 和 children。

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  isInSSRComponentSetup = isSSR

  const { props, children } = instance.vnode
  const isStateful = isStatefulComponent(instance)
  // 初始化props
  initProps(instance, props, isStateful, isSSR)
  // 初始化插槽
  initSlots(instance, children)
  // 设置有状态的组件实例(通常,我们写的组件就是一个有状态的组件,所谓有状态,就是组件会在渲染过程中把一些状态挂载到组件实例对应的属性上)
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined
  isInSSRComponentSetup = false
  return setupResult
}

我们来看看 initSlots函数的实现:

export const initSlots = (
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren
) => {
  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const type = (children as RawSlots)._
    if (type) {
      instance.slots = toRaw(children as InternalSlots)
      def(children as InternalSlots, '_', type)
    } else {
      normalizeObjectSlots(
        children as RawSlots,
        (instance.slots = {}),
        instance
      )
    }
  } else {
    instance.slots = {}
    if (children) {
      normalizeVNodeSlots(instance, children)
    }
  }
  def(instance.slots, InternalObjectKey, 1)
}

initSlots其实就是将插槽对象保留到了 instance.slots对象中,这样后面的程序就可以从 instance.slots拿到插槽对象了。接下来,我们来看子组件是如何将这些插槽数据渲染到页面上。

子组件模版如下:

<div class="layout">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

借助Vue SFC Playground,我们可以看它编译后的代码:

import { renderSlot as _renderSlot, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "layout" }
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _createElementVNode("header", null, [
      _renderSlot(_ctx.$slots, "header")
    ]),
    _createElementVNode("main", null, [
      _renderSlot(_ctx.$slots, "default")
    ]),
    _createElementVNode("footer", null, [
      _renderSlot(_ctx.$slots, "footer")
    ])
  ]))
}

通过编译后的代码我们可以看出,子组件插槽部分的DOM是通过 renderSlot函数渲染的,我们来看看 renderSlot函数的实现:

renderSlot函数源码实现

export function renderSlot(
  slots: Slots,
  name: string,
  props: Data = {},
  // this is not a user-facing function, so the fallback is always generated by
  // the compiler and guaranteed to be a function returning an array
  fallback?: () => VNodeArrayChildren,
  noSlotted?: boolean
): VNode {
  if (
    currentRenderingInstance!.isCE ||
    (currentRenderingInstance!.parent &&
      isAsyncWrapper(currentRenderingInstance!.parent) &&
      currentRenderingInstance!.parent.isCE)
  ) {
    if (name !== 'default') props.name = name
    return createVNode('slot', props, fallback && fallback())
  }

  let slot = slots[name]

  if (__DEV__ && slot && slot.length > 1) {
    warn(
      `SSR-optimized slot function detected in a non-SSR-optimized render ` +
        `function. You need to mark this component with $dynamic-slots in the ` +
        `parent template.`
    )
    slot = () => []
  }

  // a compiled slot disables block tracking by default to avoid manual
  // invocation interfering with template-based block tracking, but in
  // `renderSlot` we can be sure that it's template-based so we can force
  // enable it.
  if (slot && (slot as ContextualRenderFn)._c) {
    ;(slot as ContextualRenderFn)._d = false
  }
  openBlock()
  // 如果slot内部全部是注释节点,则不是一个合法的插槽
  const validSlotContent = slot && ensureValidVNode(slot(props))
  const rendered = createBlock(
    Fragment,
    {
      key:
        props.key ||
        // slot content array of a dynamic conditional slot may have a branch
        // key attached in the `createSlots` helper, respect that
        (validSlotContent && (validSlotContent as any).key) ||
        `_${name}`
    },
    validSlotContent || (fallback ? fallback() : []),
    validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
      ? PatchFlags.STABLE_FRAGMENT
      : PatchFlags.BAIL
  )
  if (!noSlotted && rendered.scopeId) {
    rendered.slotScopeIds = [rendered.scopeId + '-s']
  }
  if (slot && (slot as ContextualRenderFn)._c) {
    ;(slot as ContextualRenderFn)._d = true
  }
  return rendered
}

参数含义如下:

  • slots:子组件初始化时获取的插槽对象,即 instance.slots
  • name:插槽名称;
  • props:插槽数据,用于作用域插槽。

renderSlots函数的拆解过程如下:

  1. 通过 name获取对应的插槽函数;
  2. 执行 slot函数获取对应的插槽内容,这里同时会执行 ensureValidVNode校验插槽内容的合法性(全部为注释节点,则不合法);
  3. 通过 createBlock函数创建 Fragment类型的 vnode节点并返回,children 是执行 slot 插槽函数的返回值

也就是说,在子组件执行 renderSlot的时候,创建了与插槽内容对应的 vnode节点,候选在 patch的过程中就可以渲染并生成对应的DOM了。

在上文中,我们知道父组件编译后的内容为:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_layout = _resolveComponent("layout")

  return (_openBlock(), _createBlock(_component_layout, null, {
    header: _withCtx(() => [
      _createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
    ]),
    default: _withCtx(() => [
      _createElementVNode("p", null, _toDisplayString($data.main), 1 /* TEXT */)
    ]),
    footer: _withCtx(() => [
      _createElementVNode("p", null, _toDisplayString($data.footer), 1 /* TEXT */)
    ]),
    _: 1 /* STABLE */
  }))
}

如果此时 nameheader,则对应的 slot的值就是:

_withCtx(() => [
	_createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
])

它是执行 _withCtx函数返回的值,接着我们来分析下 _witchCtx的实现。

withCxt实现

export function withCtx(
  fn: Function,
  ctx: ComponentInternalInstance | null = currentRenderingInstance,
  isNonScopedSlot?: boolean // __COMPAT__ only
) {
  if (!ctx) return fn

  // already normalized
  if ((fn as ContextualRenderFn)._n) {
    return fn
  }

  const renderFnWithContext: ContextualRenderFn = (...args: any[]) => {
    // If a user calls a compiled slot inside a template expression (#1745), it
    // can mess up block tracking, so by default we disable block tracking and
    // force bail out when invoking a compiled slot (indicated by the ._d flag).
    // This isn't necessary if rendering a compiled `<slot>`, so we flip the
    // ._d flag off when invoking the wrapped fn inside `renderSlot`.
    if (renderFnWithContext._d) {
      setBlockTracking(-1)
    }
    const prevInstance = setCurrentRenderingInstance(ctx)
    let res
    try {
      res = fn(...args)
    } finally {
      setCurrentRenderingInstance(prevInstance)
      if (renderFnWithContext._d) {
        setBlockTracking(1)
      }
    }

    if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
      devtoolsComponentUpdated(ctx)
    }

    return res
  }

  // mark normalized to avoid duplicated wrapping
  renderFnWithContext._n = true
  // mark this as compiled by default
  // this is used in vnode.ts -> normalizeChildren() to set the slot
  // rendering flag.
  renderFnWithContext._c = true
  // disable block tracking by default
  renderFnWithContext._d = true
  // compat build only flag to distinguish scoped slots from non-scoped ones
  if (__COMPAT__ && isNonScopedSlot) {
    renderFnWithContext._ns = true
  }
  return renderFnWithContext
}

withCtx的主要作用就是给执行函数 fn做一层封装,当 fn执行时当前组件实例指向上下文变量 ctx。通过 withCtx的封装,保证了子组件渲染插槽内容是,渲染组件实例仍然是父组件实例,这样也就保证了数据作用域来源于父组件。

所以对于header这个slot,他的slot函数返回值就是一个数组,如下所示:

[
  _createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
]

我们回到renderSlot函数,最终header插槽对应的vnode函数变成了如下函数:

export function renderSlot(
  slots: Slots,
  name: string,
  props: Data = {},
  // this is not a user-facing function, so the fallback is always generated by
  // the compiler and guaranteed to be a function returning an array
  fallback?: () => VNodeArrayChildren,
  noSlotted?: boolean
): VNode {
  // 省略部分代码
  const rendered = createBlock(
    Fragment,
    {
      key:
        props.key
    },
    [
      _createElementVNode("h1", null, _toDisplayString($data.header), 1 /* TEXT */)
    ],
    64 /*PatchFlags.STABLE_FRAGMENT*/
  )
  return rendered
}

createBlock内部会调用 createVnode创建 vnodevnode创建完毕后,会调用 patch函数将 vnode渲染到页面上,由于此时 vnodetypeFragement,我们来看 patch函数对插槽的渲染实现:

processElement源码实现

const processFragment = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
    const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!

    let { patchFlag, dynamicChildren, slotScopeIds: fragmentSlotScopeIds } = n2

   	// 删除部分代码

    if (n1 == null) {
      // 首次挂载时插入Fragment
      // 先在前后插入两个空文本节点
      hostInsert(fragmentStartAnchor, container, anchor)
      hostInsert(fragmentEndAnchor, container, anchor)
      // 断言片段节点的子节点为数组类型,因为片段节点只能包含数组子节点。
      // 挂载子节点,这里只能是数组的子集
      mountChildren(
        n2.children as VNodeArrayChildren,
        container,
        fragmentEndAnchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      // 更新节点
    }
  }

在插入节点流程中,首先会通过 hostInsert在容器的前后插入两个空文本节点,然后在以尾文本节点作为参考锚点,通过 mountChildrenchildren 挂载到 container 容器中。

经过以上步骤的处理,就完成了子组件插槽内容的渲染。

3、总结

插槽的实现实际上就是一种延时渲染,把父组件中编写的插槽内容保存到一个对象上,并且把具体渲染 DOM 的代码用函数的方式封装,然后在子组件渲染的时候,根据插槽名在对象中找到对应的函数,然后执行这些函数做真正的渲染。

4、参考资料

[1]vue官网

[2]vuejs设计与实现

[3]vue3源码