这是我参与新手入门的第1篇文章
一、前言
一千个人眼里有一千个哈姆雷特!
组件的封装有很多种方式、适合自己、适合团队的才是最佳实践。
前段时间接了一个需求有一个显示步骤条的界面。我心想这不简单Vant UI不就有吗(///▽///)、心想做完就可以划水了嘻嘻。但是一看Vant Steps、能做的属性太少了、臣妾做不到呀。没办法只能自己开发一个、说干就干 😊 我最喜欢工作了!我最喜欢工作了!!我最喜欢工作了!!!。
二、目标
我们的目标是:
- 具有单一性,复用性、不耦合任何业务功能的基础组件。
- 具有通用性、可个性化配置的能力。
- 节点能灵活变动、icon可配置、方向可配置等功能...
- 明确我们的目标后、后面就是围绕我们的目标开始功能设计了。
三、功能规划定义:prop、slot、event
万丈高楼平地起、再复杂的组件都是由这三部分构成的
通过prop、slot、event,我们可以快速的理解一个组件的所有功能
开发组件前一定要设计好这个三个部分,这样无论是使用者还是别人阅读您的组件,都可以通过这个三个部分进行深入快速的深入了解。
一个组件的开发步骤无非就是三步:
- 组件抽象(通用、灵活、高内聚、低耦合)
- 组件定义的越是抽象,其功能越是灵活、扩展性越强。但是同时也带来“难以理解”的弊端。
- 建议使用“直观优先”原则,逻辑直观为优先的原则,
- 界面实现 (html、css、JavaScript的实现)
- 建立关联 (组件通信、组件功能)
1. 我对组件抽象的思考
1. 组件拆分的维度:属性与方法
- 例:人:
- 属性:身高、体重、年龄
- 方法:人:方法:吃饭、睡觉、写bug
- 将变与不变抽离、将动与静抽离,确保变化的部分灵活、不变的部分稳定
- 当我把"变化"的抽离了,就为后续的拓展提供了无限的可能性(为改需求提供可能👀)
组件开发原则:
- 单一功能原则
- 将不同的职责分离,可以做到每个职责都能被灵活地复用
- 一个组件只专注做一件事,且把这件事做好。
2. 步骤条组件 props
- 步骤条组件的抽象
- 父组件:
- 属性:当前选中的节点、容器方向、容器背景颜色
- 子组件:
- 属性:当前步骤状态,激活、未激活、完成状态图标/颜色、标题
- 方法:组件的点击事件
- 扩展:图标插槽、标题插槽
- 父组件:
我要父容器的能力:
- 能控制进度条步骤、控制步骤条的方向(ps:垂直方向的还没写嘻嘻嘻)
我要子组件的能力:
- 可配置的标题、颜色、图标、自身可控制状态
props设计原则:
-
组件的属性应该有默认值,巧妙的使用默认值可以让我们少传递很多参数(懒狗福音)。
-
组件的属性尽量避免使用对象等复杂的数据结构,简单的属性值容易理解和维护
-
组件要足够健壮,考虑边界异常情况,要做好属性的类型验证,不可缺省(永远不要相信用户的输入)
ps:设计props的时候我一直没有想好到底是在父容器做icon、color的配置,还是在子容器做icon、color的配置。后来我转念一想:您🧑🏻🎤给衣服给你女盆友的爸爸👨,叫你女朋友的爸爸帮你女盆友换衣服👗,合适吗?👀 妈妈都说了:自己的事情自己做~
文件结构
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 来做为组件之间的通信。
为什么?为了装逼
使用考虑:
- 步骤条组件所有的子组件都需要访问他的数据,无论是用 prop、parent都太麻烦了、如果我想扩展到更深处的嵌套组件呢?所以我们请出 provide 和 inject、
这样做的好处是:
- 组件之间的接口始定义明确的、嵌套组件组件不用担心依赖组件的变化、因为无论嵌套多少的层级都以访问到
- 通过provide/inject可以轻松实现跨级访问祖先组件的数据而不需要依赖vuex等库
但是请记住没有“完美”的组件通信方式、provide也是有坏处的:
- 当嵌套层次变得复杂的时候、不同层级注册的provide将会让数据变得复杂且难以追踪(你甚至不知道数据到底TM的从哪里来的!!!)
- property 是非响应式的、但是如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
- 广泛的注入props、但是不是每一个组件都是必须的。
Provide 选项允许我们指定我们想要提供给后代组件的数据/方法
// steps 父容器
provide() {
return {
direction: this.direction,
active: this.active
}
}
然后在任何子组件里,我们都可以使用 inject
选项来接收数据/方法
// step 子容器
inject: ['direction', 'active']
inject: {
foo: { default: 'foo' } // 可设置默认值
}
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定位到中间。最后一个线隐藏就这么简单!
怎么监听父组件的 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>