Vue3.0 核心源码解读 | 插槽:如何实现内容分发? | 蛋烘糕
蛋烘糕.

不写博客的工程师不是好的搬砖工🧱

Vue3.0 核心源码解读 | 插槽:如何实现内容分发?

Cover Image for Vue3.0 核心源码解读 | 插槽:如何实现内容分发?
蛋烘糕
蛋烘糕

纸上得来终觉浅,绝知此事要躬行。

前一篇我们了解了 Props,使用它我们可以让组件支持不同的配置来实现不同的功能。

不过,有些时候我们希望子组件模板中的部分内容可以定制化,这个时候使用 Props 就显得不够灵活和易用了。因此,Vue.js 受到 Web Component 草案的启发,通过插槽的方式实现内容分发,它允许我们在父组件中编写 DOM 并在子组件渲染时把 DOM 添加到子组件的插槽中,使用起来非常方便。

在分析插槽的实现前,我们先来简单回顾一下插槽的使用方法。

1 插槽的用法

举个简单的例子,假设我们有一个 TodoButton 子组件:

<button class="todo-button">
  <slot></slot>
</button>

然后我们在父组件中可以这么使用 TodoButton 组件:

<todo-button>
  <!-- 添加一个字体图标 -->
  <i class="icon icon-plus"></i>
  Add todo
</todo-button>

其实就是在 todo-button 的标签内部去编写插槽中的 DOM 内容,最终 TodoButton 组件渲染的 HTML 是这样的:

<button class="todo-button">
  <!-- 添加一个字体图标 -->
  <i class="icon icon-plus"></i>
  Add todo
</button>

这个例子就是最简单的普通插槽的用法,有时候我们希望子组件可以有多个插槽,再举个例子,假设我们有一个布局组件 Layout,定义如下:

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

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

<template>
  <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>
</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>

这里使用 template 以及 v-slot 指令去把内部的 DOM 分发到子组件对应的插槽中,最终 Layout 组件渲染的 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>

这个例子就是命名插槽的用法,它实现了在一个组件中定义多个插槽的需求。另外我们需要注意,父组件在插槽中引入的数据,它的作用域是父组件的。

不过有些时候,我们希望父组件填充插槽内容的时候,使用子组件的一些数据,为了实现这个需求,Vue.js 提供了作用域插槽。

举个例子,我们有这样一个 TodoList 子组件:

<template>
  <ul>
    <li v-for="(item, index) in items">
      <slot :item="item"></slot>
    </li>
  </ul>
</template>
<script>
  export default {
    data() {
      return {
        items: ['Feed a cat', 'Buy milk']
      }
    }
  }
</script>

注意,这里我们给 slot 标签加上了 item 属性,目的就是传递子组件中的 item 数据,然后我们可以在父组件中这么去使用 TodoList 组件:

<todo-list>
  <template v-slot:default="slotProps">
    <i class="icon icon-check"></i>
    <span class="green">{{ slotProps.item }}<span>
  </template>
</todo-list>

注意,这里的 v-slot 指令的值为 slotProps,它是一个对象,它的值包含了子组件往 slot 标签中添加的 props,在我们这个例子中,v-slot 就包含了 item 属性,然后我们就可以在内部使用这个 slotProps.item 了,最终 TodoList 子组件渲染的 HTML 如下:

<ul>
  <li v-for="(item, index) in items">
    <i class="icon icon-check"></i>
    <span class="green">{{ item }}<span>
  </li>
</ul>

上述例子就是作用域插槽的用法,它实现了在父组件填写子组件插槽内容的时候,可以使用子组件传递数据的需求。

这些就是插槽的一些常见使用方式,那么接下来,我们就来探究一下插槽背后的实现原理吧!

2 插槽的实现

在分析具体的代码前,我们先来想一下插槽的特点,其实就是在父组件中去编写子组件插槽部分的模板,然后在子组件渲染的时候,把这部分模板内容填充到子组件的插槽中。

所以在父组件渲染阶段,子组件插槽部分的 DOM 是不能渲染的,需要通过某种方式保留下来,等到子组件渲染的时候再渲染。顺着这个思路,我们来分析具体实现的代码。

我们还是通过例子的方式来分析插槽实现的整个流程,首先来看父组件模板:

<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>

这里你可以借助模板编译工具看一下它编译后的 render 函数:

import {
  toDisplayString as _toDisplayString,
  createVNode as _createVNode,
  resolveComponent as _resolveComponent,
  withCtx as _withCtx,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from "vue";
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_layout = _resolveComponent("layout");
  return (
    _openBlock(),
    _createBlock(_component_layout, null, {
      header: _withCtx(() => [
        _createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */),
      ]),
      default: _withCtx(() => [
        _createVNode("p", null, _toDisplayString(_ctx.main), 1 /* TEXT */),
      ]),
      footer: _withCtx(() => [
        _createVNode("p", null, _toDisplayString(_ctx.footer), 1 /* TEXT */),
      ]),
      _: 1,
    })
  );
}

前面我们学习过 createBlock,它的内部通过执行 createVNode 创建了 vnode,注意 createBlock 函数的第三个参数,它表示创建的 vnode 子节点,在我们这个例子中,它是一个对象。

通常,我们创建 vnode 传入的子节点是一个数组,那么对于对象类型的子节点,它内部做了哪些处理呢?我们来回顾一下 createVNode 的实现:

function createVNode(type, props = null, children = null) {
  if (props) {
    // 处理 props 相关逻辑,标准化 class 和 style
  }
  // 对 vnode 类型信息编码

  // 创建 vnode 对象
  const vnode = {
    type,
    props,
    // 其他一些属性
  };
  // 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
  normalizeChildren(vnode, children);
  return vnode;
}

其中,normalizeChildren 就是用来处理传入的参数 children,我们来看一下它的实现:

function normalizeChildren(vnode, children) {
  let type = 0;
  const { shapeFlag } = vnode;
  if (children == null) {
    children = null;
  } else if (isArray(children)) {
    type = 16; /* ARRAY_CHILDREN */
  } else if (typeof children === "object") {
    // 标准化 slot 子节点
    if (
      (shapeFlag & 1 /* ELEMENT */ || shapeFlag & 64) /* TELEPORT */ &&
      children.default
    ) {
      // 处理 Teleport 的情况
      normalizeChildren(vnode, children.default());
      return;
    } else {
      // 确定 vnode 子节点类型为 slot 子节点
      type = 32; /* SLOTS_CHILDREN */
      const slotFlag = children._;
      if (!slotFlag && !(InternalObjectKey in children)) {
        children._ctx = currentRenderingInstance;
      } else if (slotFlag === 3 /* FORWARDED */ && currentRenderingInstance) {
        // 处理类型为 FORWARDED 的情况
        if (
          currentRenderingInstance.vnode.patchFlag & 1024 /* DYNAMIC_SLOTS */
        ) {
          children._ = 2; /* DYNAMIC */
          vnode.patchFlag |= 1024; /* DYNAMIC_SLOTS */
        } else {
          children._ = 1; /* STABLE */
        }
      }
    }
  } else if (isFunction(children)) {
    children = { default: children, _ctx: currentRenderingInstance };
    type = 32; /* SLOTS_CHILDREN */
  } else {
    children = String(children);
    if (shapeFlag & 64 /* TELEPORT */) {
      type = 16; /* ARRAY_CHILDREN */
      children = [createTextVNode(children)];
    } else {
      type = 8; /* TEXT_CHILDREN */
    }
  }
  vnode.children = children;
  vnode.shapeFlag |= type;
}

normalizeChildren 函数主要的作用就是标准化 children 以及获取 vnode 的节点类型 shapeFlag。

这里,我们重点关注插槽相关的逻辑。经过处理,vnode.children 仍然是传入的对象数据,而 vnode.shapeFlag 会与 slot 子节点类型 SLOTS_CHILDREN 进行或运算,由于 vnode 本身的 shapFlag 是 STATEFUL_COMPONENT,所以运算后的 shapeFlag 是 SLOTS_CHILDREN | STATEFUL_COMPONENT。

确定了 shapeFlag,会影响后续的 patch 过程,我们知道在 patch 中会根据 vnode 的 type 和 shapeFlag 来决定后续的执行逻辑,我们来回顾一下它的实现:

const patch = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  optimized = false
) => {
  // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1);
    unmount(n1, parentComponent, parentSuspense, true);
    n1 = null;
  }
  const { type, shapeFlag } = n2;
  switch (type) {
    case Text:
      // 处理文本节点
      break;
    case Comment:
      // 处理注释节点
      break;
    case Static:
      // 处理静态节点
      break;
    case Fragment:
      // 处理 Fragment 元素
      break;
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
      } else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        );
      } else if (shapeFlag & 64 /* TELEPORT */) {
        // 处理 TELEPORT
      } else if (shapeFlag & 128 /* SUSPENSE */) {
        // 处理 SUSPENSE
      }
  }
};

这里由于 type 是组件对象,shapeFlag 满足shapeFlag&6的情况,所以会走到 processComponent 的逻辑,递归去渲染子组件。

至此,带有子节点插槽的组件与普通的组件渲染并无区别,还是通过递归的方式去渲染子组件。

渲染子组件又会执行组件的渲染逻辑了,这个流程我们在前面的章节已经分析过,其中有一个 setupComponent 的流程,我们来回顾一下它的实现:

function setupComponent(instance, isSSR = false) {
  const { props, children, shapeFlag } = instance.vnode;
  // 判断是否是一个有状态的组件
  const isStateful = shapeFlag & 4;
  // 初始化 props
  initProps(instance, props, isStateful, isSSR);
  // 初始化插槽
  initSlots(instance, children);
  // 设置有状态的组件实例
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined;
  return setupResult;
}

注意,这里的 instance.vnode 就是组件 vnode,我们可以从中拿到子组件的实例、props 和 children 等数据。setupComponent 执行过程中会通过 initSlots 函数去初始化插槽,并传入 instance 和 children,我们来看一下它的实现:

const initSlots = (instance, children) => {
  if (instance.vnode.shapeFlag & 32 /* SLOTS_CHILDREN */) {
    const type = children._;
    if (type) {
      instance.slots = children;
      def(children, "_", type);
    } else {
      normalizeObjectSlots(children, (instance.slots = {}));
    }
  } else {
    instance.slots = {};
    if (children) {
      normalizeVNodeSlots(instance, children);
    }
  }
  def(instance.slots, InternalObjectKey, 1);
};

initSlots 的实现逻辑很简单,这里的 children 就是前面传入的插槽对象数据,然后我们把它保留到 instance.slots 对象中,后续我们就可以从 instance.slots 拿到插槽的数据了。

到这里,我们在子组件的初始化过程中就拿到由父组件传入的插槽数据了,那么接下来,我们就来分析子组件是如何把这些插槽数据渲染到页面上的吧。

我们先来看子组件的模板:

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

这里你可以借助模板编译工具看一下它编译后的 render 函数:

import {
  renderSlot as _renderSlot,
  createVNode as _createVNode,
  openBlock as _openBlock,
  createBlock as _createBlock,
} from "vue";
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createBlock("div", { class: "layout" }, [
      _createVNode("header", null, [_renderSlot(_ctx.$slots, "header")]),
      _createVNode("main", null, [_renderSlot(_ctx.$slots, "default")]),
      _createVNode("footer", null, [_renderSlot(_ctx.$slots, "footer")]),
    ])
  );
}

通过编译后的代码我们可以看出,子组件的插槽部分的 DOM 主要通过 renderSlot 方法渲染生成的,我们来看它的实现:

function renderSlot(slots, name, props = {}, fallback) {
  let slot = slots[name];
  return (
    openBlock(),
    createBlock(
      Fragment,
      { key: props.key },
      slot ? slot(props) : fallback ? fallback() : [],
      slots._ === 1 /* STABLE */ ? 64 /* STABLE_FRAGMENT */ : -2 /* BAIL */
    )
  );
}

renderSlot 函数的第一个参数 slots 就是 instance.slots,我们在子组件初始化的时候已经获得了这个 slots 对象,第二个参数是 name。

renderSlot 的实现也很简单,首先根据第二个参数 name 获取对应的插槽函数 slot,接着通过 createBlock 创建了 vnode 节点,注意,它的类型是一个 Fragment,children 是执行 slot 插槽函数的返回值。

下面我们来看看 slot 函数长啥样,先看一下示例中的 instance.slots 的值:

{
header: _withCtx(() => [
_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
]),
default: _withCtx(() => [
_createVNode("p", null, _toDisplayString(_ctx.main), 1 /* TEXT */)
]),
footer: _withCtx(() => [
_createVNode("p", null, _toDisplayString(_ctx.footer), 1 /* TEXT */)
]),
_: 1
}

那么对于 name 为 header,它的值就是:

_withCtx(() => [
  _createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
])
它是执行 _withCtx 函数后的返回值,我们接着看 withCtx 函数的实现:

function withCtx(fn, ctx = currentRenderingInstance) { if (!ctx) return fn return function renderFnWithContext() { const owner = currentRenderingInstance setCurrentRenderingInstance(ctx) const res = fn.apply(null, arguments) setCurrentRenderingInstance(owner) return res } }



withCtx 的实现很简单,它支持传入一个函数 fn 和执行的上下文变量 ctx,它的默认值是 currentRenderingInstance,也就是执行 render 函数时的当前组件实例。


withCtx 会返回一个新的函数,这个函数执行的时候,会先保存当前渲染的组件实例 owner,然后把 ctx 设置为当前渲染的组件实例,接着执行 fn,执行完毕后,再把之前的 owner 设置为当前组件实例。


这么做就是为了保证在子组件中渲染具体插槽内容时,它的渲染组件实例是父组件实例,这样也就保证它的数据作用域也是父组件的了。


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


```javascript
[
  _createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)
]

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

createBlock(
  Fragment,
  { key: props.key },
  [_createVNode("h1", null, _toDisplayString(_ctx.header), 1 /* TEXT */)],
  64 /* STABLE_FRAGMENT */
);

我们知道,createBlock 内部是会执行 createVNode 创建 vnode,vnode 创建完后,仍然会通过 patch 把 vnode 挂载到页面上,那么对于插槽的渲染,patch 过程又有什么不同呢?

注意这里我们的 vnode 的 type 是 Fragement,所以在执行 patch 的时候,会执行 processFragment 逻辑,我们来看它的实现:

const processFragment = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(""));
  const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(""));
  let { patchFlag } = n2;
  if (patchFlag > 0) {
    optimized = true;
  }
  if (n1 == null) {
    //插入节点
    // 先在前后插入两个空文本节点
    hostInsert(fragmentStartAnchor, container, anchor);
    hostInsert(fragmentEndAnchor, container, anchor);
    // 再挂载子节点
    mountChildren(
      n2.children,
      container,
      fragmentEndAnchor,
      parentComponent,
      parentSuspense,
      isSVG,
      optimized
    );
  } else {
    // 更新节点
  }
};

我们只分析挂载子节点的过程,所以 n1 的值为 null,n2 就是我们前面创建的 vnode 节点,它的 children 是一个数组。

processFragment 函数首先通过 hostInsert 在容器的前后插入两个空文本节点,然后在以尾文本节点作为参考锚点,通过 mountChildren 把 children 挂载到 container 容器中。

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

3 总结

好的,到这里这一篇的内容就结束啦。希望你能了解插槽的实现原理,知道父组件和子组件在实现插槽 feature 的时候各自做了哪些事情。

最后,思考一个问题,作用域插槽是如何实现子组件数据传递的?

本篇的相关代码在源代码中的位置如下
packages/runtime-core/src/componentSlots.ts
packages/runtime-core/src/vnode.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/helpers/withRenderContext.ts

Vue3.0核心源码解读 | Props:Props 的初始化和更新流程是怎样的?
Vue3.0 核心源码解读 | 指令:指令完整的生命周期是怎样的?
博客日历
2022年10月
26
27
28
29
30
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
01
02
03
04
05
06
更多