如何在Vue2/3中正确透传插槽,提升组件编写效率?

8,546 阅读3分钟

在vue的组件化开发过程中,透传几乎必不可少,在创建高级组件时非常有用。而透传类型可以分为三类:属性透传、事件透传、以及插槽透传。他们分别对应了$attrs$listeners$slots/$scopedSlots

属性和事件的透传想必大家非常熟悉,我们常用v-bind="$attrs"v-on="$listeners"来透传属性和事件,详见官方文档「vm.$attrs」与「vm.$listeners」的用法说明。但说到插槽透传,除了手写对应插槽名称,其实还可以有更优雅的处理方式。

本文在讲解过程中主要使用jsx编写组件,所以开始之前请务必了解渲染函数的数据对象结构,部分场景也会给出模板写法实例。至于vue3部分的插槽透传,可以参考$scopedSlots的用法。

场景还原

首先有一个基于内置input组件开发的BaseInput组件,它实现了基本的v-model绑定,并且具有两个插槽,分别是prefixsuffix

const BaseInput = {
  name: 'BaseInput',
  props: ['value'],
  render() {
    return (
      <div class="base-input">
        <span class="prefix">{this.$scopedSlots.prefix?.()}</span>
        <input value={this.value} onInput={e => this.$emit('input', e.target.value)} />
        <span class="suffix">{this.$scopedSlots.suffix?.()}</span>
      </div>
    );
  },
};

然后基于BaseInput组件开发了一个CustomInput组件。我们为BaseInput组件定制了样式,并且想在使用CustomInput组件时,手动传入BaseInput组件所需的prefixsuffix插槽(即插槽透传)。想实现这样的需求,通常我们会在CustomInput中这样写:

const CustomInput =  {
  name: 'CustomInput',
  render() {
    return (
      <BaseInput
        class="custom-input"
        {...{
          attrs: this.$attrs,
          on: this.$listeners,
        }} 
      >
        <template slot="prefix">
          {this.$scopedSlots.prefix?.()}
        </template>
        <template slot="suffix">
          {this.$scopedSlots.suffix?.()}
        </template>
      </BaseInput>
    );
  },
};

模板写法等价为

<template>
  <BaseInput 
    class="custom-input"
    v-bind="$attrs" 
    v-on="$listeners"
  >
    <slot name="prefix" slot="prefix">
    <slot name="suffix" slot="suffix">
  </BaseInput>
</template>

这样虽然可以实现需求,但是一旦BaseInput组件的插槽数量增加,我们就不得不在CustomInput中再穷举一遍,很明显,这对于CustomInput组件的维护来说并不友好,$attrs$listeners同理。我们只是在BaseInput组件基础上定制了一点小功能,除此之外只是想把CustomInput组件当做BaseInput来用的。

那么有没有什么办法可以像透传属性和事件一样轻松来透传插槽呢?这样一来,BaseInput增加API时CustomInput就可以自动继承,无需修改了。

$slots和$scopedSlots的区别

上文中在使用jsx编写插槽代码时统一采用了$scopedSlotsAPI而非$slots,这其实是有原因的。且看官方文档中关于$scopedSlots API的描述。

2.6版本之后,所有的 $slots 现在都会作为函数暴露在 $scopedSlots 中。如果你在使用渲染函数,不论当前插槽是否带有作用域,我们都推荐始终通过 $scopedSlots 访问它们。这不仅仅使得在未来添加作用域变得简单,也可以让你最终轻松迁移到所有插槽都是函数的 Vue 3。

具体的暴露方式可参见下方源码部分

...
...
// expose normal slots on scopedSlots
for (const key in normalSlots) {
  if (!(key in res)) {
    res[key] = proxyNormalSlot(normalSlots, key)
  }
}
...
...
function proxyNormalSlot(slots, key) {
  return () => slots[key]
}

两个重点。第一,这使得我们为插槽添加作用域变的简单

<template>
  <BaseInput v-bind="$attrs" v-on="$listeners">
    <template #prefix>
      <span>不需要作用域时插槽时可以这么写</span>
    </template>
    <template #prefix="{ value }">
      <span>需要作用域时插槽时也可快速增加,例如这里的value {{ value }}</span>
    </template>
  </CustomInput>
</template>

加之所有的 $slots 都会作为函数暴露在 $scopedSlots 中,我们最初编写插槽时可以直接使用$scopedSlots并传入参数,是否使用全凭使用者决定,极具灵活性。

第二,面向未来编程,便于迁移至vue3版本。在Vue3版本中,所有的插槽均作为函数暴露在$slots上,如果我们现在开始使用$scopedSlots,将来如果需要迁移时插槽部分只需要进行简单的全局替换即可,非常方便省事,没有副作用。

有了上面的基础,我们的CustomInput组件迎来升级,通过渲染函数直接传入$scopedSlots,如此一来,传递给CustomInput组件的所有属性、事件、插槽都会原样传递给BaseInput组件,CustomInput组件就好像不存在一样。

const CustomInput =  {
  name: 'CustomInput',
  render() {
    return (
      <BaseInput
        class="custom-input"
        {...{
          attrs: this.$attrs,
          on: this.$listeners,
          scopedSlots: this.$scopedSlots, // 新增
        }} 
      />
    );
  },
};

兼容性

虽然全部使用$scopedSlots的愿景很美好,但或许因为历史原因,我们使用的基础组件库中,并非所有组件统一使用$scopedSlots语法,相当一部分组件仍在使用$slots。虽然$slots中的内容均会在$scopedSlots中暴露一个函数与之对应,但反之却并没有这个联系

假设我们的BaseInput组件全部使用this.$slots[name]的方式调用插槽,而我们在CustomInput中间层组件中只传递了$scopedSlots,这种情况下,BaseInput将无法获取到$slots,原因如上。所以CustomInput中间层组件还需要将自身的$slots通过children的方式传递给BaseInput以实现透传,如下:

const CustomInput =  {
  name: 'CustomInput',
  render() {
    return (
      <BaseInput 
        class="custom-input"
        {...{
          attrs: this.$attrs,
          on: this.$listeners,
          scopedSlots: this.$scopedSlots, 
        }} 
      >
        {/* 新增 */}
        {Object.keys(this.$slots).map(name => (
          <template slot={name}>
            {this.$slots[name]}
          </template>
        ))}
      </BaseInput>
    );
  },
};

模板写法

由于template模板中只能使用children方式传递插槽,所以我们只能通过使用v-for遍历$scopedSlots对象并填充<slot/>组件以达到效果,如下:

<template>
  <BaseInput v-bind="$attrs" v-on="$listeners">
    <template v-for="(_, name) in $scopedSlots" v-slot:[name]="data">
      <slot :name="name" v-bind="data"/>
    </template>
  </BaseInput>
</template>

结论

根据上方提到的jsx和模板的对应写法,以及兼容性章节叙述,有以下结论:

如果接收方(BaseInput)内部使用模板方式编写组件,或在使用jsx时统一使用了$scopedSlotsAPI,那么我们封装二级组件(CustomInput)时使用jsx借助渲染函数的scopedSlots参数即可快速透传插槽。

如果接收方混用$slots$scopedSlots并且中间层组件使用了jsx编写,那么透传时需要额外使用children的方式传递中间层自身的$slots,以确保接收方可以正常拿到相关插槽。

当然了,无论接收方(BaseInput)组件如何编写插槽,我们都可以在中间层(CustomInput)通过模板方式一劳永逸地透传。但你说你就是想用jsx,那就需要弄清二者的区别。

补充

函数式组件(funtional)透传

函数式组件不同与普通组件,他没有实例(即this上下文)。使用函数式组件透传,我们需要用到render函数的第二个参数context

const CustomInput =  {
  name: 'CustomInput',
  functional: true,
  render(h, ctx) {
    return (
      <BaseInput class="custom-input" {...ctx.data}>
        {ctx.children}
      </BaseInput>
    );
  },
};

其中ctx.data即VNodeData,ctx.children即原始的未经任何处理的插槽内容(可以看作渲染函数的第三个参数children)。

关于slots和children的区别

正常创建一个VNode节点时,有以下表达式,其中children是vnode数组,其创建过程可递归类比。

const vnode = h(Parent, VNodeData, children)

当Parent组件创建时,内部会对children中的节点进行检查,并通过VNodeData中的slot字段进行解析,将Parent的slots从children中分离出来。

const vnode = h(Parent, VNodeData, [
  h(Child1, { slot: 'prefix' }),
  h(Child2, { slot: 'suffix' })
])

于是Parent组件就有了原始children,和经过处理后得到的slots,处理函数是resolve-slots

因此你可以选择让组件感知某个插槽机制,还是简单地通过传递 children,移交给其它组件去处理

vue3中的插槽透传

在vue3中,所有的插槽都是函数,统一暴露在$slots中,我们可以看做vue2的$scopedSlots

在jsx中的写法可以参照Vue3版本的babel-plugin-jsx,在中间层使用v-slots指定传递对象(this.$slotsctx.slots)即可。

const App = {
  setup(props, ctx) {
    const slots = {
      default: () => <div>A</div>,
      bar: () => <span>B</span>,
    };
    return () => <A v-slots={slots} />;
  },
};

模板写法则与Vue2相同,只不过v-for遍历的对象变成了$slots,具体写法参见上文。

最后

合理利用透传可以大幅提升高级组件开发效率,同时也能降低组件的维护成本,用更少的代码却能实现更多的事情,并且还易于维护,何乐而不为。