组件效果演示
多项选择器是移动端常见的通用组件,比如多级分类、多级菜单的展示都离不开它,它还可以进一步扩展为时间选择器、地址选择器等组件。其基本的效果演示如下图:
(图片质量不清晰,并不是样式问题哦)
需求分析
简单对这种效果做一个需求分析吧。
表面需求
先从直观的表面来看这个需求
- 多列展示
可以展示多个层级的列 - 支持列变化
例如在第一列数据滑动选定后,会影响其他列的数据变化。 - 组件不重叠
即同个页面中显示的多个组件数据隔离。 - 支持回显
即选中的内容可以实时显示在输入框中,并且是可以自定义的。
重新打开选择器后,能够默认滑动到上一次选中的位置。
细化需求
- 组件本身是一个受控的输入框,允许自定义显示内容,点击输入框才会弹出多列选择器,同时产生一个遮罩层。
- 多项选择器每列显示5个内容,其中中间的内容作为选中状态列,这5个内容还应带有样式渐变的效果。
- 每一列均可上下滑动,但不能滑出边界(滑出边界会有回弹效果),同时滑动结束后还应该做滑动校正(后面具体说明)。
- 滑动过程中和滑动结束后需要触发事件回调,由外部响应并重新刷新其他列的数据变化。
- 数据变化时应保持其原有的相对位置不变,但当当前位置超出变化后的长度边界值时,应自动调整回弹到第一个或最后一个选中。(例如上图中,先选中12月31日,滑动回11月时,没有31日,自动回弹到30日)
- 关闭选择器后,触发事件回调,能够让外部收集到该表单的数据信息。重新打开选择器后,能够默认滑动到上一次选中的位置。
组件参数
设计组件好比设计一个函数,组件参数其实就是外部应该传入什么数据提供组件渲染,在vue
中就对应于props
属性。
对于这种组件,往往数据来源都是后端返回,我们希望设计得更加友好调用,即后端返回的数据直接可以作为参数传入组件,而无需另做转换。
虽然后端返回的数据格式千变万化,但对于组件内部的渲染来说,其实只需关注这个数据的key
值和用于显示的label
值,因此还需两个参数确定即可。
同时为了支持回显操作,需要由外部告知每次弹出选择器时需要滚动到的位置,这个参数也为了方便外部使用,传递的应该是数据的key
值,而不是需要外部再次转换后的index
最后还需要一个开关控制组件的显示和隐藏,虽然组件内部本身也有做控制,但仍然提供一个接口给外部响应式开关。(也就是说需要双向绑定该变量)
综上,组件的参数大体包含这么几个:
- isShow: 显示隐藏开关
Boolean
- columns: 接收数据
Array
数组里每个元素仍然是数组,每个数组代表每一列数据(如5列选择器就有5个数组),每一列数组里的元素才是真正可展示的键值对信息(Object
)。
这一点可参考微信小程序的设计 - keyField: 接收的columns中,用于表示id的key
String
- labelField: 接收的columns中,用于展示的key
String
- defaultCurrent: 外部告知每次弹出选择器时需要滚动到的数据位置。
String
组件拆分
虽然对外显示的是一个组件,但组件的内部实现往往需要细化出多个组件,组件拆分本身就是个具有实践性的哲学问题,不同的学者都有不同的见解。但在这里我想从另一个维度做探讨,即利用组件的生命周期来拆分。
在探讨这个维度之前,我想先介绍vue
中一个比较常见的API,即this.$nextTick
,相信大家并不陌生,因为在vue中,数据的变化并不会立即触发视图的渲染,但很多时候我们仍需要获取数据变化后与视图相关的数据,比如在这个组件中,每一列的高度、相对顶部的位置,这些数据都必须保证在视图渲染完成后才能正确获取的,因此视图元素信息的获取通常会放在this.$nextTick
回调里面,这样就能确保回调能在视图渲染完成后执行。
因此,在你的组件逻辑中如果有大量使用this.$nextTick
的情况,那么这个组件就一定有再拆分的可能。
为什么这么说?其实也很好理解,父组件的数据变化同样会带给子组件的视图刷新,父组件的视图更新完成子组件必定也更新完成,因此在原有的this.$nextTick
回调逻辑必然可以放在子组件的带过去式的生命周期的钩子函数里边,例如mounted
,updated
等。大量的this.$nextTick
在一个组件上逻辑肯定是模糊不好维护的,拆分出一个子组件会使逻辑更加的清晰。
试想一下,如果把所有的逻辑都放在一个组件实现,当点击输入框弹出选择器的时候,会有一个变量控制它从隐藏到显示,有经验的开发者都知道,在这个过程中父组件的数据肯定还没到,这样的显示势必会带来一次空数据渲染,而此时想希望在mounted
钩子里直接获取每一列的高度、位置等信息,肯定是错的,即使在this.$nextTick
下也未见得可行。因此如果能再细化出一个子组件,就多了一个(甚至是多个)mounted
,利用这个mounted
,我们就可以获取渲染后的视图信息。
依据这个思路,我们将这个组件拆分如下:
- MutiPicker: 看似是一个大容器,其实它本质不过是一个受控的输入框
- PickerContainer :即演示图里所看到的白色部分,也是整个组件最核心的部分
- PickerColumn:就是每一列的内容了。
在MultiPicker和PickerContainer之间还插了一层Drawer抽屉动画组件,仅动画展示用途。
同时为了通用性,在PickerColumn中还抽象了一层Scroller划窗组件,以便扩展更多组件的需要。
技术实现
基础依赖
- vue2.6.x : 标题已经说明了,我们要用vue的H5版实现,而之所以要2.6版本以上,因为有些新特性是在这个组件中不得不用的。
- better-scroll/core ^2.0.0-beta.2 : better-scroll是一个比较好用的滚动插件,使用它可以简化很多很底层细节的逻辑,当然由于差价本身通配性很强,我们需要把它定制化并vue化。
- node-sass ^4.9.0 : 是一个css样式的预处理器,这里不会涉及太多的css,但是有些设计思路离不开css的巧妙运用。
组件数据定义
如何将数据展示出来?数据结构的选择就很重要。试想一下,外围传入的columns好比这样:
[
[{id: 1000, name: ‘北京’, isTest: true},{id: 2000, name: ‘上海’, isTest: true},{id: 3000, name: ‘广州’, isTest: true}],
[{id: 3001, name: ‘天河区’, isTest: true}, {id: 3002, name: ‘海珠区’, isTest: true}, {id: 3001, name: ‘从化区’, isTest: true}]
]
这样的结构,通过keyField和LabelField,我们自然可以知道用id和name来展示,这看似没什么问题,但是问题在于,当我选择完成后,如何告知外围我选中的数据。如果还是这种结构,那么通常返回的是双index模式,这类似于微信小程序的设计,比如选择广州海珠区,告知外围的是[2,1]
。这其实很不友好,对外围来说还需要做一次转换,才能拿到想要的数据,因此是否可以改进一下,能否直接告诉外围的数据就是[{id: 3000, name: ‘广州’, isTest: true}, {id: 3002, name: ‘海珠区’, isTest: true}]
,这种带完整数据结构的呢?
另外,为了支持回显,外围需要传入defaultCurrent
给组件默认展示,而如果是双index模式,外围通常还需要做一次转换才能知道[2,1]
。为了友好组件设计,能否让外围直接传入默认需要展示的id值,如[3000, 3002]
即可呢?
若外围不想转换,必然要在组件内转换,核心在于我们并不需要数组这种index对数据的映射关系,而我们迫切需要的是id对数据的映射关系,这样就能能直接通过id取得数据,对此很容易联想到ES5
的键值对Object
,但这里更推荐使用ES6
的Map
,那么同样是存储键值对,Object
和Map
有什么区别呢?
Object 好比是HashMap,顺序添加键值对时,默认是根据键的内部哈希值排序的。
Map 好比是LinkedMap,顺序添加键值对,其顺序会和原数组的顺序一样。
但是在Vue中是否能支持Map的遍历呢?庆幸的是Vue2.6以上的版本支持v-for
对实现Symbol.iterator
的数据结构的遍历,这其中就包括了Map。即使不支持动态响应式,但对于展示渲染已经满足了。
因此最后的结论就是,我们要将外围传入的二维数组结构,转换成数组的Map,即类似于[Map, Map, Map]
,每个Map键就是keyField值,值就是labelField值。
Drawer抽屉动画组件
上面的核心部分说了那么多,是时候开始实践了,这里从最简单的组件开始,那就是动画。这个组件唯一的一个动画那就是弹出动画,而弹出动画不过是抽屉效果的一个子集。
Vue提供的动画主要有两种,简单来说就是问要CSS实现还是js实现,答案也很简单,能不用js就不用js,绝大多数特效用css已经足够。
而靠css实现的动画,vue又提供了两种模式,这两种模式的区别主要是看你对动画的理解,姑且称其中一种为点态式,习惯这种模式的需要掌握这张很熟悉的图片:
另一种方式估计也猜到了,那就是过程式,它是通过css3动画实现的,使用这种方式的,通常是把动画看成一个整体,研究的是整条动画曲线的变化,而不是一个点状态到另一个点状态。
大多数情况下,两种实现是相通的,而css3的动画实现也许适用范围更广一些,但关键如何实现,还得看个人对动画的理解,个人对比这两种方式,点态式相对比较抽象,过程式比较直观一些。
点态式的动画实现方式:
<template>
<transition name="drawer">
<slot></slot>
</transition>
</template>
.drawer-enter, .drawer-leave-to {
transform: translateY(100%)
}
.drawer-enter-active, .drawer-leave-active {
transition: transform .5s;
}
过程式的动画实现方式:
@keyframes drawer-in {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
.drawer-enter-active {
animation: drawer-in .5s;
}
.drawer-leave-active {
animation: drawer-in .5s reverse;
}
MutiPicker 中间组件
从图上画MutiPicker好像是一个大容器,实际上它只是一个小小的输入框,准确的说,是一个受控的输入框,只有当它被点击的时候,整个容器的面貌才显示得出来。
根据组件参数的设定,我们先将这个顶层组件的props
定义出来:
props: {
isShow: {
type: Boolean,
default: false,
},
columns: {
validator: arr => Array.isArray(arr) && arr.reduce((a, b, i) => a && Array.isArray(b), true),
default: () => [[]],
},
keyField: {
type: String,
required: true,
},
labelField: {
type: String,
required: true,
},
defaultCurrent: {
type: Array,
default: () => [],
},
},
组件参数的双向绑定
这里问题就来了,外围可以通过isShow参数控制picker的显示隐藏,但是输入框作为组件内部,也需要去控制isShow变量显示隐藏,那怎么让通过props
绑定来的参数实现双向绑定呢?vue2.6为此专门提供了新语法.sync
,只要外围采用这种方式绑定<multi-picker :is-show.sync="isShow" />
,组件内部派发update:isShow
事件,就可以间接"改变"props
定义的isShow
变量,实现双向绑定了。
而这种语法,和2.6之前版本的v-model
有什么区别呢?
.sync 更适用于实现自定义组件之间的双向绑定,它派发的事件具有自定义性
v-model 更适用于表单组件之间的双向绑定,它通常派发的是表单相关的事件
新版slot插槽
需求说到,这个表单组件允许自定义展示内容,那么就需要通过插槽对外提供视图自定义显示,当然除了视图之外,外围更需要的是拿到这个组件选中的数据,那么如何通过插槽的方式向外传递数据呢?vue2.6对插槽的语法进行了改造,其实主要的变化在于使用v-slot
指令代替之前放在template
标签上的scope
,也就是说,2.6版本后,独占默认插槽就可以不用template
标签了。
因此我们最初版的MutiPicker可以长这样
<div id="picker" @click.stop="onShow">
<slot :currents="currents"></slot>
<div class="mask" v-if="isShow"></div>
<div class="test-tmp" v-if="isShow">这里将会是个picker-container</div>
</div>
props: {
isShow: {
type: Boolean,
default: false,
},
// ....
}
data() {
return {
currents: [],
};
},
methods: {
onShow() {
this.$emit('update:isShow', true);
},
}
#picker {
width: 100%;
height: 100%;
}
.mask {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
background-color: rgba(55, 55, 55, .5);
}
顺便提一下,这里使用v-if
来控制遮罩层和pickerContainer的显示和隐藏,而不是v-show
,目的是为了满足多个MultiPicker同时使用时的数据隔离。
对于外围来说,可以通过这种方式做数据交互:
<multi-picker :is-show.sync="isShow" v-slot="{ currents }">
<span>{{ viewShow(currents) }}</span>
</multi-picker>
总结
本文主要提出了需求并做分析,并对整体组件做了初步的设计,包括组件参数、组件拆分、数据结构定义,其中富含不一样的设计思想。最后初步做了组件技术实现,同时还介绍一些了vue2.6新特性,包括动画设计、双向数据绑定、插槽参数等诸多内容,当然主要在思想设计方面占了比较多的篇幅。
下期还将出一篇完结这个组件的最核心部分picker-container
,敬请期待更精彩的内容吧!