当我们对组件二次封装时我们在封装什么

24,615 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情 >>

前言

经常的,我们在日常工作中,会使用第三方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" 传入内部组件。它是一个对象,里面包含了作用在这个组件上的所有事件监听器,相当于子组件继承了父组件的事件。

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

效果:

input-number.png

可以看到,我们传入初始值 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>

效果:

el-input.png

可以看到,我们使用 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>

效果图:

el-input-slots.png

可以看到,插槽使用也是没有问题的

作用域插槽

如果需要封装组件使用了作用域插槽,我们可以通过以下方式实现

<template #[slotName]="slotProps" v-for="(slot, slotName) in $slots" >
    <slot :name="slotName" v-bind="slotProps"/>
</template>

具体使用示例可以看之前这篇文章里讲到的 vue 项目开发,我遇到了这些问题

小结

  • 使用 $attrs 继承父组件的属性
  • 使用 $listeners 继承父组件的事件
  • 二次封装时插槽的传递

二次封装尽量遵循的应该是在原组件基础的扩展,不管是为了针对业务还是为了方便使用,而不是为组件重新制定一套新的用法,毕竟封装的本质是为了提高使用体验,而不是增加更多不必要的心智负担。

往期回顾

2022年了,我才开始学 typescript ,晚吗?(7.5k字总结)
vue 项目开发,我遇到了这些问题
关于首屏优化,我做了哪些