Vue 组件化实战之——手动封装多项选择器组件(二)

上节回顾

上篇文章手动封装多项选择器组件(一),大概讲述了整体设计思路,以及MultiPicker的设计编码,同时还引出了vue2.6的一些新特性。
这次再接着上篇文章的思路,来完结这个组件的设计。
先来重新贴上上篇文章关键的演示图:

vue-frame2.gif

组件拆分图:
image

组件参数设计:

参数名 说明 类型 具体值类型说明
isShow 显示隐藏开关 Boolean -------------
columns 用于展示的数据 Array 数组里每个元素仍然是数组,
每个数组代表每一列数据
(如5列选择器就有5个数组),
每一列数组里的元素才是
真正可展示的键值对信息(Object
这一点可参考微信小程序的设计
keyField 接收的columns中,用于表示id的key String
labelField 接收的columns中,用于展示的key String
defaultCurrent 外部告知每次弹出选择器时需要滚动到的数据位置 String

布局设计

工欲善其事,必先利其器,在开始picker-container之前,有必要先来简单了解一下better-scroller的滚动原理,直观用一张图来理解一下:

image

其中wrapper就是视窗能展示的内容(滑动层),而content是实际数据需要展示的内容(显示层),当显示层的高度大于滑动层的高度时,就能滑动了。

那运用在我们这个组件当中,关键的问题是如何选取显示层的区域?看似很简单的问题,大部分能理解到的都如下图所示,但实际上真的这么设计,还会引发更多的复杂计算问题。


content.png

关键的问题还是在于选中计算和样式的过渡变化。
在滑动的过程中,我们一定能够取得的是滑动层ul的顶部相对于显示层的位置possy和每个li的高度,但如何知道到底选中的是哪个呢?如果仅仅只是将possy除以li的高度取整,仅仅能计算的是可视区域的第一个相对于ul的位置,并不是中间那个被选中的位置,最后结果还得加2,这其实很不靠谱,万一以后想展示7个li的高度,或只展示3个呢?
但如果我们在布局上稍微做点调整,即可避免这个问题,例如我们将显示层的高度仅仅只是一个li的高度,并在外面再包一层,通过padding值撑出5个li的高度,这样就能让用户看到可视区域有5个的假象。

content2.png

这样将超出隐藏的属性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: 即需要渲染的数据,根据上一篇的内容结论,这个数据结构应该是ES6Map。其value直接存储数据本身。
  • possy: 即监听上下滑动过程中y值的变化。
  • currentIndex: 即当前被选中的是第几个li
  • current : 即当前被选中的数据,通常是Object
  • itemStyle: 即记录每一个li的样式class

一步步推导数据流

根据上述的推导,可以绘制出在滑动过程中,这些数据依赖关系的数据流:

滑动过程中的数据流

但除了滑动过程之外,还有可能对这些数据产生影响的事件包括初始化,和列变化的时候。
初始化,即选择器弹出时,又如何获取当前被选中的数据呢?实际上它和传入组件的两个参数columnsdefaultCurrent有关,而columns需要映射成Map结构的initColumn,那是不是initColumndefaultCurrent直接计算出最终的结果current就行了呢?实际上,这个是错误的做法!
错误的初始化数据流关系.png

如果你有reduxvuex的设计理念就知道,数据流越简单和单向,数据才会越好维护可追踪,上图的数据流看似可行,实际上会引来很多不可维护的地方,因此,初始化的时候不应该直接计算出最终所需要的数据currents,而需要沿着数据流的走向,从更新possy开始,间接去更新current,而current的计算本身就依赖于原始数据initColumns
比较好的数据流2.png

紫色的箭头可以代表初始化事件,在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">&#xe60a;</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);
    },
  },

完结

到这里为止,整个组件的设计和实现就算完结了,这一次主要还讲了巧妙的布局设计思路以及理清数据之间的耦合关系,能有效减少组件复杂度,提高组件可扩展性和可维护性。
这两篇文章着重还是在设计思路上,代码的实现仅仅只是个参考,有好的设计就能够产出好的实现,但好的设计本身也不是一蹴而就的,它需要实践的迭代才能推导出来,而在互联网飞速发展的今天,每一次好的设计的总结,都能有效减少这样的迭代次数。这就是我写本篇文章的目的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,552评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,666评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,519评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,180评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,205评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,344评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,781评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,449评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,635评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,467评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,515评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,217评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,775评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,851评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,084评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,637评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,204评论 2 341

推荐阅读更多精彩内容