如何在Vue2/3中正确透传插槽,提升组件编写效率?
在vue的组件化开发过程中,透传几乎必不可少,在创建高级组件时非常有用。而透传类型可以分为三类:属性透传、事件透传、以及插槽透传。他们分别对应了$attrs
、$listeners
、$slots/$scopedSlots
。
属性和事件的透传想必大家非常熟悉,我们常用v-bind="$attrs"
和v-on="$listeners"
来透传属性和事件,详见官方文档「vm.$attrs」与「vm.$listeners」的用法说明。但说到插槽透传,除了手写对应插槽名称,其实还可以有更优雅的处理方式。
本文在讲解过程中主要使用jsx编写组件,所以开始之前请务必了解渲染函数的数据对象结构,部分场景也会给出模板写法实例。至于vue3部分的插槽透传,可以参考$scopedSlots
的用法。
场景还原
首先有一个基于内置input
组件开发的BaseInput
组件,它实现了基本的v-model绑定,并且具有两个插槽,分别是prefix
与suffix
。
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组件所需的prefix
和suffix
插槽(即插槽透传)。想实现这样的需求,通常我们会在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编写插槽代码时统一采用了$scopedSlots
API而非$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时统一使用了$scopedSlots
API,那么我们封装二级组件(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.$slots
或ctx.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
,具体写法参见上文。
最后
合理利用透传可以大幅提升高级组件开发效率,同时也能降低组件的维护成本,用更少的代码却能实现更多的事情,并且还易于维护,何乐而不为。
转载自:https://juejin.cn/post/7094858996103774245