Vue3.0的插槽是如何实现的?

610 阅读5分钟
Vue 3.0 系列文章

Vue 3.0组件的渲染流程

Vue 3.0组件的更新流程和diff算法详解

揭开Vue3.0 setup函数的神秘面纱

Vue 3.0 Props的初始化和更新流程的细节分析

Vue3.0 响应式实现原理分析

Vue 3.0 计算属性的实现原理分析

Vue3.0 常用响应式API的使用和原理分析(一)

Vue3.0 常用响应式API的使用和原理分析(二)

Vue 3.0 Provide和Inject实现共享数据

Vue 3.0 Teleport的使用和原理分析

Vue3侦听器和异步任务调度, 其中有个神秘角色

Vue3.0 指令

Vue3.0 内置指令的底层细节分析

Vue3.0 的事件绑定的实现逻辑是什么

Vue3.0 的双向绑定是如何实现的

Vue3.0的插槽是如何实现的?

探究Vue3.0的keep-alive和动态组件的实现逻辑

Vuex 4.x

Vue Router 4 的使用,一篇文章给你讲透彻

Vue提供了pro可以进行参数的传递,但是有时需要给子组件的模板进行定制化,此时传递参数有时候就不太方便了。 Vue借鉴了Web Components实现了插槽slot

插槽slot通过在父组件中编写DOM,在子组件渲染的时候将这些DOM放置在对应的位置,从而实现内容的分发。

使用方法介绍

基本使用
<Son>
  <p>父组件传入的内容</p>
</Son>

我们想将一些内容渲染在Son子组件中,我们在组件中间写了一些内容,例如<p>父组件传入的内容</p>,但是最终这些内容会被Vue抛弃,是不会被渲染出来的。

如果我们想将<p>父组件传入的内容</p>这部分内容在子组件中渲染,则需要使用slot了。

<!-- Son.vue -->
<div class="card">
  <slot></slot>
</div>

我们只需要在Son组件模板中加入<slot></slot>标签,则<p>父组件传入的内容</p>将替换<slot></slot>渲染

渲染的结果:

<div class="card">
  <p>父组件传入的内容</p>
</div>
默认内容

有些情况下,如果父组件不传入内容,插槽需要显示默认的内容。这时候只需要在<slot></slot>中放置默认的内容就行:

<!-- Son.vue -->
<div class="card">
  <slot>子组件的默认内容</slot>
</div>
  • 如果父组件不传入插槽内容,则渲染为:
<div class="card">
  子组件的默认内容
</div>
  • 如果父组件传入插槽内容<p>父组件传入的内容</p>,则渲染为:
<div class="card">
  <p>父组件传入的内容</p>
</div>
具名插槽

在有些情况下可能需要多个插槽进行内容的放置, 这时候就需要给插槽一个名字:

<!-- Son.vue -->
<div class="card">
  <slot name="header"></slot> 
  <slot>子组件的默认内容</slot>
  <slot name="footer"></slot>
</div>

我们的例子中有三个插槽,其中headerfooter,还有一个没有给名字,其实它也是有名字的,不写名字它的名字就是default, 等同于<slot name="default">子组件的默认内容</slot>

这时候可以根据名称对每个插槽放置不同的内容:

<Son>
  <p>父组件的内容1</p>
  <p>父组件的内容2</p>
  <template v-slot:header> 外部传入的header </template>
  <template v-slot:footer> 外部传入的footer </template>
</Son>

渲染内容如下:

<div class="card">
  外部传入的header
  <p>父组件的内容1</p>
  <p>父组件的内容2</p>
  外部传入的footer
</div>

v-slot:header包含的内容替换<slot name="header"></slot>; v-slot:footer包含的内容替换<slot name="footer"></slot>; 其他所有内容都被当成v-slot:default替换<slot></slot>;

插槽作用域

插槽的内容使用到数据,那这个数据来自于于父组件,而不是子组件:

  • 父组件
<!-- parent.vue -->
<Son>
  <p>插槽的name {{ name }}</p>
</Son>

setup() {
  return {
    name: ref("parent"),
  }
},
  • 子组件
<!-- son.vue -->
<div class="card">
  <slot></slot>
</div>

setup() {
  return {
    name: ref("chile"),
  }
},

渲染结果:

<div class="card">
  插槽的name: parent
</div>
作用域插槽

我们刚才提到插槽的数据的作用域是父组件,有时候插槽也需要使用来自于子组件的数据,这时候可以使用作用域插槽。

  • 将数据以pro的形式传递
<slot :pro="name"></slot>
  • 父组件接收pro
<template v-slot:default="pro">
  <p>插槽的name {{ pro.pro }}</p>
</template>

此时渲染的内容:

插槽的name: child

实现原理介绍

分析案例:

<!-- Parent.vue -->
<Son>
  <p>插槽的name {{ name }}</p>
  <template v-slot:header> <p>外部传入的header</p> </template>
  <template v-slot:footer> <p>外部传入的footer</p> </template>
</Son>

<!-- Son.vue -->
<div class="card">
  <slot name="header"></slot>
  <slot>子组件的默认内容</slot>
  <slot name="footer"></slot>
</div>
渲染函数分析
  • parent
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "外部传入的header", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("p", null, "外部传入的footer", -1 /* HOISTED */)

function render(_ctx, _cache) {
  with (_ctx) {
    const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, resolveComponent: _resolveComponent, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    const _component_Son = _resolveComponent("Son")

    return (_openBlock(), _createBlock(_component_Son, null, {
      header: _withCtx(() => [
        _hoisted_1
      ]),
      footer: _withCtx(() => [
        _hoisted_2
      ]),
      default: _withCtx(() => [
        _createElementVNode("p", null, "插槽的name " + _toDisplayString(name), 1 /* TEXT */)
      ]),
      _: 1 /* STABLE */
    }))
  }
}

生成子组件的VNode时传了1个children对象, 这个对象有 headrfooter, default 属性,这 3个属性的值就是对应的DOM

  • son
function render(_ctx, _cache) {
  with (_ctx) {
    const { renderSlot: _renderSlot, createTextVNode: _createTextVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", _hoisted_1, [
      _renderSlot($slots, "header"),
      _renderSlot($slots, "default", {}, () => [
        _hoisted_2
      ]),
      _renderSlot($slots, "footer")
    ]))
  }
}

联系这两个渲染函数我们就可以大概有个猜测:子组件渲染的时候遇到slot这个标签,然后就找对应名字的children对应的渲染DOM的内容,进行渲染。即通过renderSlot会渲染headrfooter, default 这三个插槽的内容。

withCtx的作用
export function withCtx(
  fn: Function,
  ctx: ComponentInternalInstance | null = currentRenderingInstance,
  isNonScopedSlot?: boolean // __COMPAT__ only
) {

  const renderFnWithContext: ContextualRenderFn = (...args: any[]) => {

    const prevInstance = setCurrentRenderingInstance(ctx)
    const res = fn(...args)
    setCurrentRenderingInstance(prevInstance)

    return res
  }

  return renderFnWithContext
}

withCtx的作用封装 返回的函数为传入的fn,重要的是保存当前的组件实例currentRenderingInstance,作为函数的作用域。

保存children到组件实例的slots
  • setupComponent setup组件实例的时候会调用initSlots

setup组件实例是什么作用?如果不知道可以参阅我前面的文章。不想看,可以直接理解为先准备数据的阶段,之后会进行组件渲染。

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  initSlots(instance, children)
}

  • children 保存到 instance.slots
export const initSlots = (
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren
) => {
  // we should avoid the proxy object polluting the slots of the internal instance
  instance.slots = toRaw(children as InternalSlots)
  def(instance.slots, "__vInternal", 1)
}
renderSlot渲染slot内容对应的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 {

  let slot = slots[name]

  const validSlotContent = slot && ensureValidVNode(slot(props))
  const rendered = createBlock(
    Fragment,
    { key: props.key || `_${name}` },
    validSlotContent || (fallback ? fallback() : []),
    validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
      ? PatchFlags.STABLE_FRAGMENT
      : PatchFlags.BAIL
  )
  return rendered
}

renderSlot创建的VNode是一个类型为Fragmentchildren为对应name的插槽的返回值。

结合前面的withCtx的分析,总结来就是 renderSlot创建的VNode是一个类型为Fragmentchildren为对应name的插槽的内容,但是插槽内的数据的作用域是属于父组件的。

processFragment挂载slot内容对应的DOM
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) {
    // 插入两个空文本节点   
    hostInsert(fragmentStartAnchor, container, anchor)
    hostInsert(fragmentEndAnchor, container, anchor)
    
    // 挂载数组子节点
    mountChildren(
      n2.children as VNodeArrayChildren,
      container,
      fragmentEndAnchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    // 更新数组子节点
    patchChildren(
      n1,
      n2,
      container,
      fragmentEndAnchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
}

processFragment先插入两个空文本节点作为锚点,然后挂载数组子节点。

作用域插槽和默认内容的实现逻辑
// 默认内容
const _hoisted_2 = /*#__PURE__*/_createTextVNode("子组件的默认内容")

// pro
renderSlot($slots, "default", { pro: name }, () => [
  _hoisted_2
])

子组件的数据和默认插槽内容作为renderSlot函数的第3个和第4个参数,进行插槽的内容渲染。

我们再回到 renderSlot函数

/**
 * @param slots 组件VNode的slots
 * @param name  slot的name
 * @param props slot的pro
 * @param fallback 默认的内容
 * @param noSlotted 
 * @returns 
 */
export function renderSlot(
  slots: Slots,
  name: string,
  props: Data = {},
  fallback?: () => VNodeArrayChildren,
  noSlotted?: boolean
): VNode {

  // 从 组件VNode的slots对象中找到name对应的渲染函数
  let slot = slots[name]

  // props作为参数执行渲染函数,这样渲染函数就拿到了子组件的数据
  const validSlotContent = slot && ensureValidVNode(slot(props))
  const rendered = createBlock(
    Fragment,
    { key: props.key || `_${name}` },
    validSlotContent || (fallback ? fallback() : []),
    PatchFlags.STABLE_FRAGMENT
  )
  return rendered
}

renderSlot函数接收pros的参数,将其传入slots对象中找到name对应的渲染函数,这样就能获取到子组件的数据pros了; fallback 是默认的渲染函数,如果父组件没有传递slot,就渲染默认的DOM。

总结

  1. 父组件渲染的时候生成一些withCtx包含的渲染函数,此时将父组件的实例对象持有在函数内部,,所以数据的作用域是父组件;
  2. 子组件在setupComponent先将这些withCtx包含的渲染函数存储在子组件实例对象的slots上;
  3. 子组件渲染的时候,插槽内容的渲染是先找到slots中对应的withCtx包含的渲染函数,然后传入子组件的pro和默认的渲染DOM内容,最后生成插槽渲染内容的DOM内容。

slot

一句话总结:父组件先编写DOM存在子组件实例对象上,渲染子组件的时候再渲染对应的这部分DOM内容。