Steps 步骤条组件封装的思考

7,059 阅读6分钟

这是我参与新手入门的第1篇文章

一、前言

一千个人眼里有一千个哈姆雷特!

组件的封装有很多种方式、适合自己、适合团队的才是最佳实践。

前段时间接了一个需求有一个显示步骤条的界面。我心想这不简单Vant UI不就有吗(///▽///)、心想做完就可以划水了嘻嘻。但是一看Vant Steps、能做的属性太少了、臣妾做不到呀。没办法只能自己开发一个、说干就干 😊 我最喜欢工作了!我最喜欢工作了!!我最喜欢工作了!!!

二、目标

image-20210630203830464

我们的目标是:

  • 具有单一性,复用性、不耦合任何业务功能的基础组件。
  • 具有通用性、可个性化配置的能力。
  • 节点能灵活变动、icon可配置、方向可配置等功能...
  • 明确我们的目标后、后面就是围绕我们的目标开始功能设计了。

三、功能规划定义:prop、slot、event

万丈高楼平地起、再复杂的组件都是由这三部分构成的

通过prop、slot、event,我们可以快速的理解一个组件的所有功能

开发组件前一定要设计好这个三个部分,这样无论是使用者还是别人阅读您的组件,都可以通过这个三个部分进行深入快速的深入了解。

一个组件的开发步骤无非就是三步:

  • 组件抽象(通用、灵活、高内聚、低耦合)
    • 组件定义的越是抽象,其功能越是灵活、扩展性越强。但是同时也带来“难以理解”的弊端。
    • 建议使用“直观优先”原则,逻辑直观为优先的原则,
  • 界面实现 (html、css、JavaScript的实现)
  • 建立关联 (组件通信、组件功能)

1. 我对组件抽象的思考

1. 组件拆分的维度:属性与方法

  • 例:人:
    • 属性:身高、体重、年龄
    • 方法:人:方法:吃饭、睡觉、写bug
  • 将变与不变抽离、将动与静抽离,确保变化的部分灵活、不变的部分稳定
  • 当我把"变化"的抽离了,就为后续的拓展提供了无限的可能性(为改需求提供可能👀)

组件开发原则

  • 单一功能原则
    • 将不同的职责分离,可以做到每个职责都能被灵活地复用
    • 一个组件只专注做一件事,且把这件事做好。

2. 步骤条组件 props

  • 步骤条组件的抽象
    • 父组件:
      • 属性:当前选中的节点、容器方向、容器背景颜色
    • 子组件:
      • 属性:当前步骤状态,激活、未激活、完成状态图标/颜色、标题
      • 方法:组件的点击事件
      • 扩展:图标插槽、标题插槽

我要父容器的能力:

  • 能控制进度条步骤、控制步骤条的方向(ps:垂直方向的还没写嘻嘻嘻)

我要子组件的能力:

  • 可配置的标题、颜色、图标、自身可控制状态

props设计原则:

  • 组件的属性应该有默认值,巧妙的使用默认值可以让我们少传递很多参数(懒狗福音)。

  • 组件的属性尽量避免使用对象等复杂的数据结构,简单的属性值容易理解和维护

  • 组件要足够健壮,考虑边界异常情况,要做好属性的类型验证,不可缺省(永远不要相信用户的输入)

ps:设计props的时候我一直没有想好到底是在父容器做icon、color的配置,还是在子容器做icon、color的配置。后来我转念一想:您🧑🏻‍🎤给衣服给你女盆友的爸爸👨,叫你女朋友的爸爸帮你女盆友换衣服👗,合适吗?👀 妈妈都说了:自己的事情自己做~

文件结构

image.png

steps 父容器 props

props: {
    active: { // 当前激活的步骤
        type: Number,
        default: 0
    },
    direction: { // 容器方向
        type: String,
        validator(value) { // 巧用 validator 属性,可以验证参数的输入是否合法。
            return ['horizontal', 'vertical'].find(e => e === value)
        },
        default: 'horizontal'
    },
    background: { // 容器背景颜色
        type: String,
        default: '#ffffff'
    }
}

stpe 子容器 props

props: {
    title: { // 标题
        type: String,
        default: ''
    },
    status: { // 当前步骤状态、不设置则用steps来设置
        type: String,
        default: ''
    },
    inactiveColor: { // 未激活状态颜色
        type: String,
        default: '#999999'
    },
    activeColor: { // 激活状态颜色
        type: String,
        default: '#333333'
    },
    finishColor: { // 已完成状态颜色
        type: String,
        default: '#999999'
    },
    inactiveIcon: { // 未激活状态图标
        type: String,
        default: require('./icons/inactive.png')
    },
    activeIcon: { // 激活状态图标
        type: String,
        default: require('./icons/active.png')
    },
    finishIcon: { // 已完成状态图标
        type: String,
        default: require('./icons/finish.png')
    }
}

3. 步骤条组件 slot

插槽这里设计比较简单、ps:没啥好写

steps 父容器 slot默认插槽简简单单~嘻嘻

<template>
  <div>
    <slot></slot>
  </div>
</template>

stpe 子容器 slot

<template>
    <div>
        <slot name="icon"><slot>
        <slot name="title"></slot>
    </div>
</template>

ps: 如何获取组件插槽的数量?

this.$slots 用来访问被插槽的内容。default数组包含所有默认插槽。具名插槽会以对象形式呈现。

console.log(this.$slots)
/**
* default: Array(4)
* 0: VNode {tag: "vue-component-209-custom-step", data: {…}, children: undefined, text: undefined, elm: div.custom-step__center.active, …}
* 1: VNode {tag: "vue-component-209-custom-step", data: {…}, children: undefined, text: undefined, elm: div.custom-step__center.inactive, …}
* 2: VNode {tag: "vue-component-209-custom-step", data: {…}, children: undefined, text: undefined, elm: div.custom-step__center.inactive, …}
* 3: VNode {tag: "vue-component-209-custom-step", data: {…}, children: undefined, text: undefined, elm: div.custom-step__center.inactive, …}
* length: 4
*/

4. 步骤条组件 provide 和 inject

组件之间通信的方式有很多种~ 但是或多或少都有一定的缺陷

组件之间通信的方式:

  • props/$emit
  • $ref/ $parent/ $children
  • $on/ $emit
  • vuex
  • $attrs/ $listeners
  • provide/inject
  • boradcast/dispatch
  • observable 由于篇幅原因就不一一介绍了~~

这次采用了 provide/ inject,$parent 来做为组件之间的通信。

为什么?为了装逼

使用考虑:

  1. 步骤条组件所有的子组件都需要访问他的数据,无论是用 prop、parent都太麻烦了、如果我想扩展到更深处的嵌套组件呢?所以我们请出 provide 和 inject

这样做的好处是:

  • 组件之间的接口始定义明确的、嵌套组件组件不用担心依赖组件的变化、因为无论嵌套多少的层级都以访问到
  • 通过provide/inject可以轻松实现跨级访问祖先组件的数据而不需要依赖vuex等库

但是请记住没有“完美”的组件通信方式、provide也是有坏处的:

  1. 当嵌套层次变得复杂的时候、不同层级注册的provide将会让数据变得复杂且难以追踪(你甚至不知道数据到底TM的从哪里来的!!!)
  2. property 是非响应式的、但是如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
  3. 广泛的注入props、但是不是每一个组件都是必须的。

Provide 选项允许我们指定我们想要提供给后代组件的数据/方法

// steps 父容器
provide() {
  return {
    direction: this.direction,
    active: this.active
  }
}

然后在任何子组件里,我们都可以使用 inject 选项来接收数据/方法

// step 子容器
inject: ['direction', 'active']


inject: {
    foo: { default: 'foo' } // 可设置默认值
}

👉 provide与inject的更多知识

5. provide 和 inject 有趣的点

1. 使用 this.$watch 来解决; provide不能动态更新

监听$parent是有风险的:因为如果你的组件层级发生变化的时候、$parent 将不是你想要的!!!嘻嘻 不推荐使用、本文使用只是我想使用,推荐在联系紧密的组件中使用

this.$watch('$parent.active', val => this.updateStatus(val),{immediate: true})

2.传入了一个可监听的对象 来解决; provide不能动态更新

  • 如 一个对象、或者返回一个对象的函数
export default {
    data() {
        return {
            level: {name: "初始化"},
            color: 'red',
        }
    },

    provide() {
        return {
            color: () => { // 这个方法需要在子组件调用
                return this.color
            },
            colorThat: this, // 传递this下去给各位宝贝点~
            level: this.level 
        }
    }
}

6.具体功能的实现细节

UI界面的实现原理:

steps组件原理:其实很简单四个组件,居中,连接线定位到left定位到中间。最后一个线隐藏就这么简单! image.png

怎么监听父组件的 active变化?

mounted() {
    // 获取当前组件的 index
    // 每一个组件都有自己的唯一的 uid 可以做为组件判断值
    this.index = this.$parent.$children.findIndex(e => e._uid === this._uid)
    // 动态监听 父组件的active变化 
    // ps:可以用我上面“5. provide 和 inject 有趣的点”改造
    this.$watch('$parent.active', val => this.updateStatus(val), {immediate: true})
},
 
// 这个用来改变stpes组件的状态
updateStatus(parentActive) {
    // 如果传入当前步骤状态则用当前状态
    if (this.status) {
        this.currentStatus = this.status
        return
    } 
    // 如果当前组件index 与 父组件的active值相等 则是处于 激活状态
    // 如果当前组件index 小于 父组件的active值 则是已完成状态
    // 就如果都不是、默认未完成状态
    if (this.index === parentActive) {
        this.currentStatus = 'active'
    } else if (this.index < parentActive) {
        this.currentStatus = 'finish'
    } else {
        this.currentStatus = 'inactive'
    } 
},

stpes是怎么变化状态的

// 可能有小可爱会疑惑🤔、 this.inactiveColor这些是从哪里来的呢
// 其实就是在上面定义的props中啦、巧用props默认值可以让我们更加优雅
computed: {
    currentIcon() {
        const statusMap = {
            'inactive': {
                color: this.inactiveColor,
                icon: this.inactiveIcon
            },
            'active': {
                color: this.activeColor,
                icon: this.activeIcon
            },
            'finish': {
                color: this.finishColor,
                icon: this.finishIcon
            },
            'default': {
                color: this.inactiveColor,
                icon: this.inactiveIcon
            }
        }
        return statusMap[this.currentStatus] || statusMap.default
    }
}
<!-- 图标 -->
<div :class="'custom-step__head__'+direction">
    <img :src="currentIcon.icon" />
    <!-- 线 -->
    <div :class="custom-step__line"></div>
</div>
<!-- 图标标题 -->
    <div 
        :class="['custom-step__title']" 
        :style="{'color': currentIcon.color}"
    >
        {{ title }}
  </div>

六、结语

我们满足不了所有人、也无法开发一个“完美”的组件, 不要过度的设计!!! 但是也不能没有设计。开发之初事事都想做到完美、做到一步到位。后来我才明白我永远都赶不上变化。

看到相同的逻辑功能拒绝CV!!!而是行动起来把它抽象成为可复用的业务组件,如果这份优化放在你面前你没有拒绝、此时你的组件抽象能力又提升多少呢?你的代码可维护性又提高多少呢?

鸣谢: 谢谢优秀的掘友8号的凌晨4点的支持与鼓舞

七、源码

steps 组件

<template>
  <div 
    :class="'custom-steps--'+direction" 
    :style="{'background-color': background}"
  >
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    active: { // 当前激活的步骤
      type: Number,
      default: 0
    },
    direction: { // 容器方向
      type: String,
      validator(value) {
        return ['horizontal', 'vertical'].find(e => e === value)
      },
      default: 'horizontal'
    },
    background: { // 容器背景颜色
      type: String,
      default: '#ffffff'
    }
  },

  provide() {
    return {
      direction: this.direction,
      active: this.active
    }
  }
};
</script>

<style lang="scss" scoped>
.custom-steps--horizontal {
  display: flex;
}

.custom-steps--vertical {
  display: flex;
  flex-direction: column;
}
</style>

steps组件

<template>
  <div 
    :class="['custom-step__center', currentStatus]"
    @click="stepClick" 
  >
    <!-- 图标 -->
    <div :class="'custom-step__head__'+direction">
      <slot name="icon">
        <img 
          :src="currentIcon.icon" 
          :class="['custom-status__icon', {'wait-icon': currentStatus === 'inactive'} ]"
        />
        <!-- 线 -->
        <div :class="['custom-step__line', {'active_horizontal': currentStatus === 'finish'}, direction]"></div>
      <slot>
    </div>
    <!-- 图标标题 -->
    <slot name="title">
      <div 
        :class="['custom-step__title']" 
        :style="{'--active-color': currentIcon.color}"
      >
        {{ title }}
      </div>
    </slot>
  </div>
</template>

<script>
export default {
  props: {
    title: { // 标题
      type: String,
      default: ''
    },
    status: { // 当前步骤状态、不设置则用steps来设置
      type: String,
      default: ''
    },
    inactiveColor: { // 未激活状态颜色
      type: String,
      default: '#999999'
    },
    activeColor: { // 激活状态颜色
      type: String,
      default: '#333333'
    },
    finishColor: { // 已完成状态颜色
      type: String,
      default: '#999999'
    },
    inactiveIcon: { // 未激活状态图标
      type: String,
      default: require('./icons/inactive.png')
    },
    activeIcon: { // 激活状态图标
      type: String,
      default: require('./icons/active.png')
    },
    finishIcon: { // 已完成状态图标
      type: String,
      default: require('./icons/finish.png')
    }
  },

  inject: ['direction', 'active'],
  
  data() {
    return {
      index: -1,
      currentStatus: ''
    }
  },

  computed: {
    currentIcon() {
      const statusMap = {
        'inactive': {
          color: this.inactiveColor,
          icon: this.inactiveIcon
        },
        'active': {
          color: this.activeColor,
          icon: this.activeIcon
        },
        'finish': {
          color: this.finishColor,
          icon: this.finishIcon
        },
        'default': {
          color: this.inactiveColor,
          icon: this.inactiveIcon
        }
      }
      return statusMap[this.currentStatus] || statusMap.default
    }
  },

  mounted() {
// parent 谨慎使用、不推荐
    this.index = this.$parent.$children.findIndex(e => e._uid === this._uid)
    this.$watch('$parent.active', val => this.updateStatus(val), {immediate: true})
  },

  methods: {
    updateStatus(parentActive) {
      // 如果传入当前步骤状态则用当前状态
      if (this.status) {
        this.currentStatus = this.status
        return
      } 
      // 如果当前组件index 与 父组件的active值相等 则是处于 激活状态
      // 如果当前组件index 小于 父组件的active值 则是已完成状态
      // 就如果都不是、默认未完成状态
      if (this.index === parentActive) {
        this.currentStatus = 'active'
      } else if (this.index < parentActive) {
        this.currentStatus = 'finish'
      } else {
        this.currentStatus = 'inactive'
      } 
    },

    // 点击step事件
    stepClick() {
      this.$parent.$emit('click-step', this.index)
    },
  }
};

</script>
<style lang="scss" scoped>
.custom-step__title {
  font-size: 14px;
  color: #909399;
}

.custom-step__center {
  flex-basis: 50%;
}

.custom-step__center .custom-step__line {
  position: absolute;
  right: -50%;
  left: 50%;
  height: 1px;
  z-index: 0;
  &.horizontal {
    border-top: 2px solid #CAD6EE;
  }
}

.active_horizontal {
  border-top: 2px solid #3D7BED !important;
}

.custom-step__center:last-of-type .custom-step__line {
  display: none;
}

.custom-step__center .custom-step__title {
  text-align: center;
  margin-top: 8px;
  font-weight: 500;
}

.custom-step__center .custom-step__head__horizontal {
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
}

// 动态标题
.inactive, .active, .finish {
  & .custom-step__title {
    color: var(--active-color);
  }
}

.custom-status__icon {
  width: 16px;
  height: 16px;
  z-index: 1;
}

.wait-icon {
  padding: 4px;
  box-sizing: border-box;
  z-index: 1;
}
</style>