实现mini-vue -- runtime-core模块(七)实现组件默认插槽

985 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

本篇文章会带你实现组件的slots插槽功能,包括默认插槽、具名插槽和作用域插槽

为了防止篇幅过长,我会将组件插槽的实现分成三篇文章去讲解,本篇是第一篇,带你实现组件的默认插槽

主要包括两个功能:

  1. 在组件的render函数中通过this.$slots获取到父组件传入的默认插槽内容
  2. 能够支持插槽传入数组

1. 案例环境

本节会实现一下组件的slots功能,首先来看一下案例场景

// App.js
import { h } from '../../lib/plasticine-mini-vue.esm.js';
import { Foo } from './Foo.js';

export const App = {
  setup() {
    return {};
  },
  render() {
    const foo = h(Foo, {}, h('p', {}, 'default slot'));
    return h('div', {}, [foo]);
  },
};
// Foo.js
import { h } from '../../lib/plasticine-mini-vue.esm.js';

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
    return h('div', {}, [foo]);
  },
};

我们希望实现默认插槽的功能,也就是在渲染子组件时,能够将父组件传入的children在子组件中的默认插槽中渲染出来 以模板的方式写出来就像下面这样:

<div id="app">
  <Foo>
    <template>
      <p>default slot</p>
    </template>
  </Foo>
</div>

目前尚未实现默认插槽的功能,因此渲染出的结果是下面这样的 image.png


2. this.$slots

2.1 添加组件公共实例属性$slots

一个基本的思路就是,在子组件Foo中能够通过this.$slots获取到父组件传入的slots插槽内容,然后放到h函数中进行渲染

// Foo.js
import { h } from '../../lib/plasticine-mini-vue.esm.js';

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
    return h('div', {}, [foo, this.$slots]);
  },
};

$slots也就是Foovnodechildren属性中的内容,要想通过this访问到,需要将它作为组件实例的属性,然后能够在组件公共实例中通过$slots获取到

// src/runtime-core/component.ts
export function createComponentInstance(vnode) {
  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
+   slots: {}
  };

  component.emit = emit as any;

  return component;
}
// src/runtime-core/componentPublicInstance.ts
const publicPropertiesMap = {
  $el: (i) => i.vnode.el,
+ $slots: (i) => i.slots,
};

2.2 initSlots

然后去实现initSlots,对于组件而言,slots就是它的vnode.children,因此最基本的我们需要给initSlots传入instance组件实例,以及vnode.children,然后将vnode.children挂载到instance.slots

// src/runtime-core/componentSlots.ts
export function initSlots(instance, children) {
  instance.slots = children;
}
+ import { initSlots } from './componentSlots';
export function setupComponent(instance) {
-	// TODO
  initProps(instance, instance.vnode.props);
-	initSlots();
+ initSlots(instance, instance.vnode.children);

  setupStatefulComponent(instance);
}

现在我们再到Foo.js中打印一下this.$slots看看是否有将父组件传递的children传过来

export const Foo = {
  setup() {
    return {};
  },
  render() {
+   console.log(this.$slots);
    const foo = h('p', {}, 'foo');
    return h('div', {}, [foo]);
  },
};

image.png 可以看到已经能够接收到了,那么接下来要做的就是将this.$slotsFoo.js中渲染出来

import { h } from '../../lib/plasticine-mini-vue.esm.js';

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
-   return h('div', {}, [foo]);
+   return h('div', {}, [foo, this.$slots]);
  },
};

image.png 现在就成功渲染出来了!


3. 插槽传入数组

3.1 看看模板方式的数组插槽长啥样

目前的案例中父组件给子组件传入的插槽的内容是一个Element类型的vnode,但是实际上应当支持多个vnode的渲染,也就是:

import { h } from '../../lib/plasticine-mini-vue.esm.js';
import { Foo } from './Foo.js';

export const App = {
  setup() {
    return {};
  },
  render() {
    const foo = h(Foo, {}, [
      h('p', {}, 'default slot'),
      h('p', {}, 'plasticine'),
    ]);
    return h('div', {}, [foo]);
  },
};

写成模板的形式也就是

<div id="app">
  <Foo>
    <template>
      <p>default slot</p>
      <p>plasticine</p>
    </template>
  </Foo>
</div>

3.2 解决嵌套数组问题

此时的this.$slots是一个数组类型,里面的元素全是vnode,而Foo组件内部调用h函数生成vnode的时候,如果直接将this.$slots传入一个数组中,就变成了嵌套数组

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
    // children -- [foo, [h('p', {}, 'default slot'), h('p', {}, 'plasticine')]]
    return h('div', {}, [foo, this.$slots]);
  },
};

由于h函数并不支持嵌套数组的处理,因此自然是无法渲染出结果的,那么我们可以想到一个简单的办法:

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
    return h('div', {}, [foo, h('div', {}, this.$slots)]);
  },
};

就是将原来的children数组直接传给h函数,处理后得到的vnode再作为真正的children传给Foo组件,这样一来就可以渲染成功了 image.png


3.3 封装提高可读性

但是这样的代码可读性太差了,我们可以考虑将代码封装一下,用一个renderSlots函数去封装,创建src/runtime-core/helpers/renderSlots.ts

import { createVNode } from '../vnode';

export function renderSlots(slots) {
  return createVNode('div', {}, slots);
}

为了让它被rollup打包到构建结果中,还需要将其在runtime-core模块中导出

export { createApp } from './createApp';
export { h } from './h';
+ export { renderSlots } from './helpers/renderSlots';

然后现在在Foo组件中就可以用它替代h去将$slots创建成vnode

export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h('p', {}, 'foo');
-   return h('div', {}, [foo, this.$slots]);
+   return h('div', {}, [foo, renderSlots(this.$slots)]);
  },
};

3.4 解决插槽传入单个结点失效的问题

但是现在有一个问题,对于之前的插槽中传入单个结点的情况,居然不支持了!

import { h } from '../../lib/plasticine-mini-vue.esm.js';
import { Foo } from './Foo.js';

export const App = {
  setup() {
    return {};
  },
  render() {
    // const foo = h(Foo, {}, [
    //   h('p', {}, 'default slot'),
    //   h('p', {}, 'plasticine'),
    // ]);

    const foo = h(Foo, {}, h('p', {}, 'default slot'));

    return h('div', {}, [foo]);
  },
};

image.png 这是为什么呢?这是因为我们的mountElement只支持TEXT_CHILDREN或者ARRAY_CHILDREN

image.png

然而现在renderSlots函数接收到的slots参数却是一个vnode,对于普通的vnodemountElement是无法处理的,所以我们可以将这个单独的vnode放到数组当中,变成一个ARRAY_CHILDREN类型的children,这样不就解决了吗!

export function initSlots(instance, children) {
- instance.slots = children;
+ // 保证 $slots 一定是存放 vnode 的数组
+ instance.slots = Array.isArray(children) ? children : [children];
}

现在我们的默认插槽就实现完啦!