# 前言
经常的,我们在日常工作中,会使用第三方 UI 组件库,比如:element-ui、vant-ui、iview、ant-design 等等。不管是为了业务考虑还是单纯的为了提高效率,我们会把一些经常用到的组件抽离、封装成公共组件,这样方便我们在不同的地方使用这个组件,减少重复代码的编写。
我们把对于第三方组件库的封装称为组件的二次封装,那么这带来有个思考,当我们在二次封装时,我们在封装什么?
# 二次封装时,我们需要遵循什么?
在 vue 组件封装时,我们需要注意的主要是三部分:prop、event、slot。
- prop:表示组件接收的参数,最好用对象的写法,这样可以针对每个属性设置类型、默认值或自定义校验属性的值,此外还可以通过 type、validator 等方式对输入进行验证;
- event:子组件向父组件传递消息的重要途径;
- slot:以给组件动态插入一些内容或组件,是实现高阶组件的重要途径;当需要多个插槽时,可以使用具名 slot。
# 你必须要知道的 $attrs
和 $listeners
我们多级组件嵌套需要传递数据时,通常使用的方法是通过 vuex。如果仅仅是传递数据,而不做中间处理,使用 vuex 处理,这就有点大材小用了。所以就有了 $attrs / $listeners
,通常配合 inheritAttrs 一起使用。
感觉还是挺晦涩难懂的,简单的说就是 inheritAttrs:true
继承除 props 之外的所有属性; inheritAttrs:false
只继承 class 属性。
$attrs
: 包含了父作用域中不被认为 (且不预期为) props 的特性绑定 (class 和 style 除外),并且可以通过v-bind="$attrs"
传入内部组件。当一个组件没有声明任何 props 时,它包含所有父作用域的绑定 (class 和 style 除外)。$listeners
: 包含了父作用域中的 (不含 .native 修饰符) v-on 事件监听器。它可以通过v-on="$listeners"
传入内部组件。它是一个对象,里面包含了作用在这个组件上的所有事件监听器,相当于子组件继承了父组件的事件。
attrs 和 attrs 和 listeners 在做组件二次封装时非常有用。
# 如何使用 $attrs
和 $listeners
上面说了那么多,我们来看一个例子:
在使用 el-input-number 时,当我们给他赋默认值 null
或者空字符串 ""
时,会显示 0 ,而这在我们一些业务场景里并不是很友好,并且值是居中显示的,那么现在我们想要做的改造是: 值居左显示,没有默认值显示0的问题,且默认不展示控制按钮
- 控制按钮默认不显示:controls 设置成 false
- 居左显示:通过样式控制
- 默认值显示 0 的问题:通过 computed 计算不为 number 类型时,赋值为 undefined 解决
v-model 是一个语法糖,可以拆解为 props: value 和 events: input。就是说组件只要提供一个名为 value 的 prop,以及名为 input 的自定义事件
下面开始我们对 el-input-number 的封装:
<template> | |
<el-input-number class="cz-input-number" style="width: 100%" :controls='controls' :value='num' @input="$emit('input',$event)"></el-input-number> | |
</template> | |
<script> | |
export default { | |
props: { | |
name: 'CzInputNumber', | |
value: [String, Number], | |
controls: { | |
type: Boolean, | |
default: false | |
} | |
}, | |
computed: { | |
num() { | |
return typeof this.value === 'number' ? this.value : undefined | |
} | |
} | |
} | |
</script> | |
<style lang="scss" scoped> | |
.cz-input-number { | |
::v-deep .el-input__inner { | |
text-align: left; | |
} | |
} | |
</style> |
上面
@input="emit(′input′,**emit**(′input′,event)"
的参数$event
其实就是我们在输入框输入的值,这是因为 el-input-number 内部的 input 元素触发的是 "input" 自定义事件,而非原生 input 事件;此时已经是把值传递出来了,也就是event.target.value
,所有在自定义组件的回调函数中可以直接接收这个 value 值。有兴趣的小伙伴可以去 element 对应的源码里看一下。(准确的来说是 el-input 的源码,因为 el-input-number 内部也是基于 el-input 的二次封装)
经过对 el-input-number 二次封装,我们的需求其实已经基本实现了,但是我们希望其用法保持和 el-input-number 组件相似,这样即使其他人在使用我们封装的组件时,也能参照 element 对应的文档正常将其使用起来。
二次封装尽量遵循的应该是原有基础的扩展,不管是为了针对业务还是为了方便使用,而不是为组件重新制定一套新的用法,毕竟封装的本质是为了提高使用体验,而不是增加更多不必要的心智负担。
这里我们就要用到上面介绍的 $attrs
和 $listeners
了
- $attrs “继承 “ el-input-number 原有组件所有的 v-bind 属性
- $listeners “继承” el-input-number 原有组件所有 v-on 的事件
我们为组件添加 v-bind="$attrs"
和 v-on="$listeners"
:
<template> | |
<el-input-number class="cz-input-number" style="width: 100%" v-bind="$attrs" v-on="$listeners" :controls='controls' :value='num' @input="$emit('input',$event)"></el-input-number> | |
</template> | |
<script> | |
export default { | |
name: 'CzInputNumber', | |
props: { | |
value: [String, Number], | |
controls: { | |
type: Boolean, | |
default: false | |
} | |
}, | |
computed: { | |
num() { | |
return typeof this.value === 'number' ? this.value : undefined | |
} | |
} | |
} | |
</script> | |
<style lang="scss" scoped> | |
.cz-input-number { | |
::v-deep .el-input__inner { | |
text-align: left; | |
} | |
} | |
</style> |
为了方便使用,对于经常使用的组件,我已经把他封装为了全局组件,所以直接通过 name 使用
<template> | |
<cz-input-number placeholder='请输入数量' @change="change" v-model="num"></cz-input-number> | |
</template> | |
<script> | |
export default { | |
data() { | |
return { | |
num: null | |
} | |
}, | |
methods: { | |
change(val) { | |
console.log(val, typeof val) | |
} | |
} | |
} | |
</script> |
效果:
可以看到,我们传入初始值 null 时已经不会有默认显示 0 的情况了,没有在 props 里定义的 placeholder 通过 $attrs 的透传也生效了,再试验一下,change 事件也可以正常触发。ok 优雅,我们的对 el-input-number 的二次封装优雅完成。
一个思考
既然我们知道 v-model 是 v-bind 以及 v-on 配合使用的语法糖。那是不是我们也可以利用 $attrs
和 $listeners
替我们实现 v-model 呢,答案当然是可以的
如果上面我们封装的 cz-number-input 不需要对初始值做处理,那么完全可以去掉 props 中 value 的定义及 @input="emit (′input′,emit (′input′,event)" 的事件。
# 穿透一层组件实现 v-model
再次举个简单的例子,我们希望 el-input 默认可清空,即 clearable 默认为 ture,我们基于其做二次封装如下:
<template> | |
<el-input v-bind='$attrs' v-on="$listeners" :clearable="clearable"> | |
</el-input> | |
</template> | |
<script> | |
export default { | |
name:'CzInput', | |
props:{ | |
clearable:{type:Boolean,default:true} | |
} | |
} | |
</script> |
使用:
<template> | |
<CzInput placeholder="请输入内容" v-model="value"></CzInput> | |
</template> | |
<script> | |
export default { | |
data() { | |
return { | |
value: '默认可清空' | |
} | |
} | |
} | |
</script> |
效果:
可以看到,我们使用 CzInput 时,v-model 是完全没问题的,这里我们父组件是 CzInput , 穿透 el-input 这一层,到达孙子原生 input 实现了 v-model, 并不需要重新定义 value 属性及 input 事件。
原因:父组件更改了数据,会因为
$atrrs
传递到后代组件。而后代组件 emit 一个 input 事件,会因为 $listeners 冒到父组件处。又因为父组件的 v-model 而自动把新数据赋值到父组件变量上,因此实现了所谓的 "双向绑定"。
# slot 插槽
上面我们对 el-input 进行了简单的二次封装,所封装组件已经继承了 el-input 的所有属性及事件,但是 el-input 为了更方便用户自定义还提供了一系列插槽,所以我们的封装也应该继承这些插槽。
# 普通插槽
<!-- 在组件中创建新的对应名称的插槽 --> | |
<template #slotName> | |
<!-- 在插槽内部使用对应名称的插槽 --> | |
<slot name="slotName" /> | |
</template> |
slotName 为我们的插槽名称,默认插槽时名称为 default 或可不写
# 动态插槽
如果需要传递的 slot 不固定或者较多,我们可以通过动态插槽名称透传
<template #[slotName] v-for="(slot, slotName) in $slots" > | |
<slot :name="slotName" /> | |
</template> |
这里我们把前面封装的 CzInput 加上插槽
<template> | |
<el-input v-bind='$attrs' v-on="$listeners" :clearable="clearable"> | |
<template #[slotName] v-for="(slot, slotName) in $slots" > | |
<slot :name="slotName" /> | |
</template> | |
</el-input> | |
</template> | |
<script> | |
export default { | |
name:'CzInput', | |
props:{ | |
clearable:{type:Boolean,default:true} | |
} | |
} | |
</script> |
我们使用一下,为组件加一个后置的按钮:
<template> | |
<CzInput placeholder="请输入内容" v-model="value"> | |
<el-button slot="append" icon="el-icon-search"></el-button> | |
</CzInput> | |
</template> | |
<script> | |
export default { | |
data() { | |
return { | |
value: '默认可清空' | |
} | |
} | |
} | |
</script> |
效果图:
可以看到,插槽使用也是没有问题的
# 作用域插槽
如果需要封装组件使用了作用域插槽,我们可以通过以下方式实现
<template #[slotName]="slotProps" v-for="(slot, slotName) in $slots" > | |
<slot :name="slotName" v-bind="slotProps"/> | |
</template> |
# 小结
- 使用 $attrs 继承父组件的属性
- 使用 $listeners 继承父组件的事件
- 二次封装时插槽的传递
二次封装尽量遵循的应该是在原组件基础的扩展,不管是为了针对业务还是为了方便使用,而不是为组件重新制定一套新的用法,毕竟封装的本质是为了提高使用体验,而不是增加更多不必要的心智负担。