vueVue

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

2022-01-29  本文已影响0人  chonglingliu

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>
<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>
作用域插槽

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

<slot :pro="name"></slot>
<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>
渲染函数分析
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

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

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

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

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内容。

上一篇 下一篇

猜你喜欢

热点阅读