上节回顾
上篇文章手动封装多项选择器组件(一),大概讲述了整体设计思路,以及MultiPicker
的设计编码,同时还引出了vue2.6
的一些新特性。
这次再接着上篇文章的思路,来完结这个组件的设计。
先来重新贴上上篇文章关键的演示图:
组件拆分图:
组件参数设计:
参数名 | 说明 | 类型 | 具体值类型说明 |
---|---|---|---|
isShow | 显示隐藏开关 | Boolean | ------------- |
columns | 用于展示的数据 | Array | 数组里每个元素仍然是数组, 每个数组代表每一列数据 (如5列选择器就有5个数组), 每一列数组里的元素才是 真正可展示的键值对信息( Object )这一点可参考微信小程序的设计 |
keyField | 接收的columns中,用于表示id的key | String | |
labelField | 接收的columns中,用于展示的key | String | |
defaultCurrent | 外部告知每次弹出选择器时需要滚动到的数据位置 | String |
布局设计
工欲善其事,必先利其器,在开始picker-container
之前,有必要先来简单了解一下better-scroller
的滚动原理,直观用一张图来理解一下:
其中
wrapper
就是视窗能展示的内容(滑动层),而content
是实际数据需要展示的内容(显示层),当显示层的高度大于滑动层的高度时,就能滑动了。
那运用在我们这个组件当中,关键的问题是如何选取显示层的区域?看似很简单的问题,大部分能理解到的都如下图所示,但实际上真的这么设计,还会引发更多的复杂计算问题。
关键的问题还是在于选中计算和样式的过渡变化。
在滑动的过程中,我们一定能够取得的是滑动层ul
的顶部相对于显示层的位置possy
和每个li
的高度,但如何知道到底选中的是哪个呢?如果仅仅只是将possy
除以li
的高度取整,仅仅能计算的是可视区域的第一个相对于ul
的位置,并不是中间那个被选中的位置,最后结果还得加2,这其实很不靠谱,万一以后想展示7个li
的高度,或只展示3个呢?
但如果我们在布局上稍微做点调整,即可避免这个问题,例如我们将显示层的高度仅仅只是一个li
的高度,并在外面再包一层,通过padding
值撑出5个li
的高度,这样就能让用户看到可视区域有5个的假象。
这样将超出隐藏的属性
overflow
放在外层(蓝色区域),即可以保证5个可见,又可以保持中间的数据时刻被选中。其中相关的css样式代码如下:
.scroll-box {
flex: 1;
color: $darkFont;
text-align: center;
overflow: hidden;
padding: $liHeight * 2 0;
.scroll-selected { // 显示层的样式
height: $liHeight;
background-color: #fff;
.wrapper { // 滑动层样式 ul
.item { // li
min-height: $liHeight;
line-height: $liHeight;
// ...
}
}
}
}
数据流
需要哪些数据?
这个组件最核心的部分在于理清数据之间的耦合关系,或者说是依赖关系,首先思考一下,在滑动的过程中,需要如何获取到被选中的数据呢?
在滑动的过程中,我们能监听到的数据滑出去的相对位置possy
,需要通过计算,得到它滑动到了第几个,即currentIndex
,从而在初始化数据initColumn
中找到对应的数据current
,进而向外派发事件。另外,为了满足滑动过程中的样式动态变化,我们还需要有一个数组itemStyle
去记录每一个li
的样式class
。
总结一下,这个组件核心的数据包括如下:
- initColumn: 即需要渲染的数据,根据上一篇的内容结论,这个数据结构应该是
ES6
的Map
。其value
直接存储数据本身。 - possy: 即监听上下滑动过程中
y
值的变化。 - currentIndex: 即当前被选中的是第几个
li
- current : 即当前被选中的数据,通常是
Object
- itemStyle: 即记录每一个
li
的样式class
一步步推导数据流
根据上述的推导,可以绘制出在滑动过程中,这些数据依赖关系的数据流:
但除了滑动过程之外,还有可能对这些数据产生影响的事件包括初始化,和列变化的时候。
初始化,即选择器弹出时,又如何获取当前被选中的数据呢?实际上它和传入组件的两个参数
columns
和defaultCurrent
有关,而columns
需要映射成Map
结构的initColumn
,那是不是initColumn
和defaultCurrent
直接计算出最终的结果current
就行了呢?实际上,这个是错误的做法!如果你有
redux
或vuex
的设计理念就知道,数据流越简单和单向,数据才会越好维护可追踪,上图的数据流看似可行,实际上会引来很多不可维护的地方,因此,初始化的时候不应该直接计算出最终所需要的数据currents
,而需要沿着数据流的走向,从更新possy
开始,间接去更新current
,而current
的计算本身就依赖于原始数据initColumns
。紫色的箭头可以代表初始化事件,在vue
中的mounted
,青色箭头就是滑动事件产生的数据流。
这里可以先来过一下将columns
映射成Map
结构的伪代码:
this.columns.map((c, k) => {
const map = new Map();
c.forEach((v, i) => {
const value = { ...v, index: i };
map.set(v[this.keyField], value);
});
return map;
});
注意到在映射过程中,同时增加了用于记录索引的index
,这样做的目的有助于初始化时计算出初始滑动位置possy
:
// 这里的column即上面所说的Map结构
const v = this.column.get(this.defaultCurrent);
if (v) {
// 即一个li的高度 * 该数据所在的位置index
this.possy = -this.liHeight * v.index;
}
接下来还有一个很关键的操作,即列变化,即某一列的滑动结果会影响其他列的数据变化,在单个列来看,即columns
可能不定期发生数据的改变,而这样的改变,又会有两种不同的情况:如果变化后的长度小于当前被选中的索引值currentIndex
,那么可以通过再次触发滑动事件重新渲染refresh
。当然如果变化后的长度仍然大于currentIndex
,那么此时possy
不需要变化,此时影响current
的因素就只有initColumns
,也就是上图中棕色的箭头。
据此,我们可以设计出计算current
大概算法:
const entry = this.column.entries();
// 目的在于判断变化后的长度是否仍然大于currentIndex
const reallyIndex = Math.min(this.column.size - 1, this.currentIndex);
for (let n = 0; n < reallyIndex; n++) {
entry.next();
}
const e = entry.next().value;
是data还是computed?
相信使用vue
的开发者都纠结过这个问题,虽然说放在computed
里的数据放在data
一样可以解决,但是这样会使得逻辑非常复杂,随之代码也就非常臃肿。数据流的设计正好帮我们解决这个问题,找出数据之间的耦合,抽取中间变量,降低数据间的耦合度,能有效将数据尽量在computed
中完成计算。
而computed
不能做的,就是接收事件参数,即当派发的事件有参数过来,并在本组件中需要对其响应式处理时,就只能放在data
中完成了。(还有需要双向绑定的情况)
因此中绿色的数据possy
放在data
中,浅蓝色的数据均可以放在computed
中。
开始开发吧
PickerContainer
首先是pickerContainer
组件,它起到一个承上启下的作用,上承MultiPicker
组件传来的参数,下发到pickerCloumn
,唯一要做的就是将外围传入的columns
映射成initColumn
即可。
<template>
<div class="scroll-container">
<div class="container-top">
<i class="iconfont withdraw" @click.stop="withdraw"></i>
</div>
<div class="scroll-box" v-for="(col, coli) in initColumn" :key="coli">
<picker-column
:scroller-ref="coli"
:column="col"
:key-field="keyField"
:label-field="labelField"
:defaultCurrent="defaultCurrent[coli]"
/>
</div>
</div>
</template>
props: {
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: () => [],
},
},
data() {
return {
currents: [],
};
},
computed: {
initColumn() {
return this.columns.map((c, k) => {
const map = new Map();
c.forEach((v, i) => {
const value = { ...v, index: i };
map.set(v[this.keyField], value);
});
return map;
});
},
},
Scroller
Scroller组件其实只是vue
化的better-scroll
,就是将一个通用插件做一层vue
的抽象封装,其实现原理也是非常简单的:
通过插槽让滑动层自定义,通过样式参数wrapperClass
让显示层样式自定义:
<template>
<div :class="wrapperClass" :ref="scrollRef">
<slot />
</div>
</template>
import BScroll from '@better-scroll/core';
export default {
name: 'scroller',
props: {
startY: Number, // 起始位置
scrollRef: [String, Number], // Scroller的唯一标识
wrapperClass: String, // 显示层的样式
refresh: Boolean, // 是否需要刷新
},
data() {
return {
bs: null,
};
},
updated() {
if (this.refresh) {
this.bs.refresh();
this.$emit('update:refresh', false);
}
},
mounted() {
this.$nextTick(() => {
const el = this.$refs[this.scrollRef];
this.bs = new BScroll(el, {
scrollY: true,
startY: this.startY,
click: true,
probeType: 3,
bindToWrapper: true,
});
this.bs.on('scrollEnd', pos => this.onScrollEnd(pos));
this.bs.on('scroll', pos => this.onScroll(pos));
});
},
methods: {
onScrollEnd(pos) {
this.$emit('scroll-end', [this.bs, pos, this.scrollRef]);
},
onScroll(pos) {
if (!this.refresh) {
this.$emit('scroll', [this.bs, pos, this.scrollRef]);
}
}
},
};
pickerColumn
最后是最核心的pickerColumn
了,根据设计的数据流以及Scroller
的设计,我们需要在此真正计算出当前被选中的数据current
并派发事件给外部,这里要分为两个事件流,一个是真正派发给外部提供列变化的columnChange
,而另一个仅仅只是组件内部通讯使用的监听current
变化的事件currentChange
。
<template>
<!-- scroll-selected 样式详见布局设计(显示层) -->
<scroller
:scroll-ref="scrollerRef"
:startY="possy"
:refresh.sync="refresh"
wrapper-class="scroll-selected"
@scroll-end="onScrollEnd"
@scroll="onScroll"
>
<ul class="wrapper" ref="wrapper">
<li
v-for="(item, key) in column"
:class="['item', itemStyle ? itemStyle[item[1].index] : '']"
:key="key"
>
{{item[1][labelField]}}
</li>
</ul>
</scroller>
</template>
export default {
name: 'PickerColumn',
components: {
Scroller,
},
props: {
scrollerRef: {
type: [String, Number],
required: true,
},
column: {
type: Map,
required: true,
},
defaultCurrent: {
type: [String, Number],
required: true,
},
keyField: {
type: String,
required: true,
},
labelField: {
type: String,
required: true,
},
},
data() {
return {
refresh: false, // 性能优化需要添加的控制变量
shouldChangeCurrent: true, // 性能优化需要添加的控制变量
liHeight: 0,
possy: 0, // defaultCurrent -> possy -> currentIndex -> currents
};
},
mounted() {
this.initLiHeight();
this.initPossy();
},
methods: {
/**
* 计算初始Li的高度
*/
initLiHeight() {
if (this.$refs.wrapper) {
const ele = this.$refs.wrapper;
const height = ele.clientHeight;
this.liHeight = Math.ceil(height / ele.childElementCount);
}
},
/**
* 计算初始位置
*/
initPossy() {
const v = this.column.get(this.defaultCurrent);
if (v) {
this.possy = -this.liHeight * v.index;
}
},
/**
* 矫正滑动后的位置
*/
getScrollFinalTop(scrollTop, liHeight) {
if (Math.abs(scrollTop % liHeight) > (liHeight / 2)) {
return Math.floor(scrollTop / liHeight) * liHeight;
}
return Math.ceil(scrollTop / liHeight) * liHeight;
},
/**
* 响应滑动结束后事件
*/
onScrollEnd([bs, pos, index]) {
this.shouldChangeCurrent = true;
const finalY = this.getScrollFinalTop(pos.y, this.liHeight);
bs.scrollTo(pos.x, finalY, 0, undefined, undefined, true);
this.possy = Math.min(Math.max(pos.y, -this.liHeight * (this.column.size - 1)), 0);
this.$emit('column-change', this.currents, index);
},
/**
* 响应滑动中事件
*/
onScroll([bs, pos, index]) {
this.shouldChangeCurrent = false;
this.possy = Math.min(Math.max(pos.y, -this.liHeight * (this.column.size - 1)), 0);
},
},
computed: {
currentIndex() {
return Math.abs(Math.round(this.possy / this.liHeight));
},
currents() {
const entry = this.column.entries();
const reallyIndex = Math.min(this.column.size - 1, this.currentIndex);
for (let n = 0; n < reallyIndex; n++) {
entry.next();
}
const e = entry.next().value;
return e[1];
},
itemStyle() {
const length = this.column.size;
const arr = ArrayUtils.fill('dispear', length);
const c = this.currentIndex;
ArrayUtils.set(arr, c, 'selected'); // ArrayUtils 作用只是防止定长数组越界
ArrayUtils.set(arr, c + 1, 'near');
ArrayUtils.set(arr, c - 1, 'near');
ArrayUtils.set(arr, c + 2, 'further');
ArrayUtils.set(arr, c - 2, 'further');
return arr;
},
},
// 性能优化需要,还需要添加两个监听器以及控制变量
watch: {
currents(val) {
if (this.shouldChangeCurrent) {
this.$emit('current-change', this.currents, this.scrollerRef);
}
},
column(val, old) {
if (val.size <= this.currentIndex) {
this.refresh = true;
}
},
},
注意到pickerColumn
中派发的事件参数仅仅只是当前列被选中的数据,因此在pickerContainer
中要把这些数据拼合起来,返回到外围中去:
<picker-column
:scroller-ref="coli"
:column="col"
:key-field="keyField"
:label-field="labelField"
:defaultCurrent="defaultCurrent[coli]"
@column-change="columnChange"
@current-change="currentChange"
/>
methods: {
columnChange(current, index) {
this.$set(this.currents, index, current);
this.$emit('column-change', this.currents, index);
},
currentChange(current, index) {
this.$set(this.currents, index, current);
this.$emit('current-change', this.currents);
},
/**
* 响应关闭后事件
*/
withdraw() {
this.$emit('withdraw', this.currents);
},
},
完结
到这里为止,整个组件的设计和实现就算完结了,这一次主要还讲了巧妙的布局设计思路以及理清数据之间的耦合关系,能有效减少组件复杂度,提高组件可扩展性和可维护性。
这两篇文章着重还是在设计思路上,代码的实现仅仅只是个参考,有好的设计就能够产出好的实现,但好的设计本身也不是一蹴而就的,它需要实践的迭代才能推导出来,而在互联网飞速发展的今天,每一次好的设计的总结,都能有效减少这样的迭代次数。这就是我写本篇文章的目的。