03月12, 2020

不均匀带宽选择

一、背景

用户在选择带宽时,超过100M的带宽,不再期望按1M的步长进行选择。换言之,Slider需要支持不均匀步长。如下图示例。

image 当带宽最大为1000M时,step在小于100时为1,100到500时为10,500到200时为100。 image 另外一种情况,步长不变,只是标注隔段显示。

二、问题分析

2.1 组件基础

首先我们看下均匀步长的Slider + 按钮是如何实现的。 一些属性:

  1. show-input会在后侧展示input框。
  2. show-tooltip在滑块滑动时展示tooltip,内容可通过format-tooltip进行更改。

2.2 组件源码分析

2.2.0 组件介绍

image image inputNumber与slider共享value和step,当slider或者InputNumber发生变化时,另外一个组件也随之变化。如果有inputNumber,则不支持range模式。

2.2.1 用例分析

首先分析一下用户可能对带宽组件做的操作,用例图如下,可看到操作主要是两类。

  • 通过直接输入或者点击加减号来改变input number的值,从而改变带宽值。
  • 拖拽滑块或者在runway上点击,改变指示按钮位置,从而改变带宽的值。

2.2.2 数据与控制流

从下图可以看出,slider组件维护value值,button维护firstValue值。当各个组件处在振荡期(拖拽过程中)或者赋值超过边界时,firstValue和value有可能不相等,最终通过事件通信两者会保持一致。

position依赖于value,当value发生变化时会重新计算,从而影响各处样式的展示。

红色箭头部分是交互的控制流流向。通过点击滑块,滑动按钮等会形成newPosition,从而影响value的值。

2.2.3 代码结构

下图展示了各组件组成关系。可以看到slider组件中维护了主要数据,包括value,stops,markList等,主要的动作是在一定场景下setValue。组件的主要作用是维护数据的一致性和准确性。

Button组件主要处理行为层,控制tooltip的展示和隐藏以及滑块的拖拽功能,主要行为是setNewPosition,在方法里获取有效的newValue,然后抛给父组件处理。

Marklist组件主要负责展示。

2.2.4 定位问题

我们考虑一下各元素如何定位,蓝色部分为bar, bar需要确定的有start和width(barSize), start为0,barSize代码返回的是一个百分比,在style上追加left的百分比即可。

 barSize() {
        return this.range
          ? `${ 100 * (this.maxValue - this.minValue) / (this.max - this.min) }%`
          : `${ 100 * (this.firstValue - this.min) / (this.max - this.min) }%`;
      },

确定完barSize后,还需了解,当滑块滑动时,或者鼠标点击时,对应的inputNumber的值如何变化。setPosition的代码如下。

setPosition(newPosition) {
    // ...
    const lengthPerStep = 100 / ((this.max - this.min) / this.step);
    const steps = Math.round(newPosition / lengthPerStep);
    let value = steps * lengthPerStep * (this.max - this.min) * 0.01 + this.min;
    value = parseFloat(value.toFixed(this.precision));
    // ...
}

总结一下,Slider解决的问题其实是value与button position之间的映射关系。

  1. 当input number发生变化时,v-model绑定的value变化,barSize和buttonStyle的left值同步更新。
  2. 当用户在runway上点击,或者滑动滑块(button的mousedown,touchend事件被触发时),计算距离左边距离,从而改变value的值。

alt

2.3 不均匀步长如何实现?

从2.2可看出,目前Slider的实现均假设step一定,我们把slider的距离和value剥离来看,则可做出几个假设。

  • Slider上的runway是一条线,线由点构成,我们假设runway上的stop(间隔点)是均匀分布的
  • 假设有数据模型[1, 2, 3, 5, 10],则这些数据的position与其index而不是value相关

alt

则问题可以简化为

  1. 当有一个value时,我们如何确定它应该定位的位置(假设position是距离最左侧的百分比)。
  2. 当用户在滑块上点击,或者拖动上面的button时,会引起position的变化,如何根据position反推出对应value的值。

根据上面的假设,如果value值和position刚好是stop,则反查即可。针对在间隔点中间的value和position处理时需要做如下考虑:

线由各个均匀分布的点构成,根据value找position时,需要

  • 先遍历所有节点,找到符合条件的两个节点a、b,且 a <= value < b
  • 根据abs(a - value)abs(b - value)确定value距离哪个节点更近,将value值转换成a或者b。如上例中的2.3 => 2, 8 -> 10。
  • 当value修改后,则问题转化成了根据list中value找position,获取其index,根据Index计算百分比即可。

根据position确定value时类似,先遍历所有节点,找到符合条件的两个节点cd,根据绝对值确定点。根据点对应的index反查value即可。

三、实现方案

  1. 主线,根据position换value, 根据value求position

    getValuePosition (value) {
     if (value === 0) { // 0 特殊处理
       return 0
     }
     let closeRightIndex = this.list.findIndex(item => item >= value)
     const len = this.list.length
     const rightValue = this.list[closeRightIndex]
     const leftValue = this.list[closeRightIndex - 1] || 0
     const rightPos = (closeRightIndex + 1) / len * 100
     const leftPos = closeRightIndex / len * 100
     if (this.step === 'step') {
       return Math.abs(leftValue - value) < Math.abs(rightValue - value) ? leftPos : rightPos
     } else {
       return (value - this.min) / (this.max - this.min) * 100 // FIXME to be valid
     }
    },
    
    getValue (position) {
         // ...
         // 根据position计算数值, 从list里面取
         lengthPerStep = 1 / this.list.length * 100
         steps = Math.round(position / lengthPerStep)
         return this.list[steps - 1] || this.min
     }
    
  2. prop重新定义
    • 新增list变量,用于存储实际各value值。其index作为position映射依据。
    • step定义修改,当其值为"step"值,说明该场景为不均为步长,作为组件内部修改判断的依据。
  3. 展示相关,样式修改
    • prop
    • barSize
    • buttonStyle -> position
    • stops
    • markList

四、总结

本篇主要介绍了如何实现不均匀带宽选择的需求。带宽选择器由input-number与el-slider构成,el-slider组件需要改造。在研究了slider组件源码后,可分析出要解决的问题主要是value与position之间的映射和同步问题。

通过新增list变量,其index作为position索引的思路来解决该问题,针对各场景下value与position的映射关系,二两改变时分别计算对应的position与value值即可。

最后,由于涉及到的行为交互较多,还需要检查各个场景下的变化是否符合预期。

五、QA

  1. range类型的Slider如何处理position?
    • range类型会有两个button,分别对应的值是firstValue和secondValue,但不一定和minValue与maxValue刚好对应,需要处理button1滑到button2右边的情况。赋值逻辑代码如下。
      const val = this.value;
      if (this.range && Array.isArray(val)) {
        if (val[1] < this.min) { // range右侧值 < 最小值,偏移矫正
          this.$emit('input', [this.min, this.min]);
        } else if (val[0] > this.max) { // range 左侧值 > 最大值, 偏移矫正
          this.$emit('input', [this.max, this.max]);
        } else if (val[0] < this.min) {
          this.$emit('input', [this.min, val[1]]);
        } else if (val[1] > this.max) {
          this.$emit('input', [val[0], this.max]);
        } else { // 四种情况处理,确保v1, v2均在min, max范围内, (v1 || v2) < range[0], (v1 || v2) > range[1]
          this.firstValue = val[0]; // 存v1
          this.secondValue = val[1]; // v2
          if (this.valueChanged()) { // 判断value === oldValue or range === oldRange
            this.dispatch('ElFormItem', 'el.form.change', [ // 只有oldValue与当前value不同时,再出发form.change事件
              this.minValue,
              this.maxValue
            ]);
            this.oldValue = val.slice(); // 记录旧值
          }
        }
      
  2. this.value, this.firstValue, this.oldValue作用是什么,是否可以省略部分变量?
    • A: this.value是slider与外部通信的变量,firstValue是和button绑定的变量,button的input事件改变的是firstValue变量
  3. emit('input'), 与dispatch('ElFormItem", "el.form.change")区别与调用场景? *
  4. 窗口大小变化时 slider宽度咋变的?
    • 在mounted钩子函数里监听了resize事件
      window.addEventListener('resize', this.resetSize)
      
  5. dragging 怎么来的。影响 watch value, sliderClick, 提前return了。
    • dragging状态是在button组件拖拽中间过程中出现的,说明组件现在处于不稳定的状态,所以在需要改变value的地方都需要考虑是否在dragging中,如果在则无需响应value的变化
  6. 获取元素的宽度,元素外边界position
    • clientWidth, x.getBoundRect() // {left: xx, right: xx, top: xx, bottom:xx}
  7. emitChange为什么在nextTick调用?
  8. 赋值 this.firstValue = Math.min(this.max, Math.max(this.min, this.value));
    • 可以学习,保证firstValue的值在不出界的情况下等于this.value
  9. stop和mark区别
    • 间隔点,标记位。前者与更关心position信息,与step相关。后者更关系某些value值的显示。绿化带与指示牌的区别。
  10. 按钮滑动时,触发的事件依次是什么?
    • mousedown -> mousemove -> mouseup
    • touchstart -> touchmove -> touchend
  11. focus, blur, mouseenter, mouseleave, mousedown, mouseup,touchstart, touchend, mousemove, touchmove, contextmenu 各表示什么事件,与drag与tooltip状态的关系?
    • tooltip
      • focus, blur
      • mouseenter, mouseleave
    • drag
      • mousedown -> mousemove -> mouseup
      • touchstart -> touchmove -> touchend
  12. form item 被绑定的keydown事件什么时机会被触发?
    • 元素什么时机被激活?
  13. 在button组件里何时会调用this.$parent.emitChange(), 为什么不通过setPosition间接触发change事件?
    • slider调用change时机
      • onSliderClick
      • buttonLeft
      • buttonRight
      • DragEnd

本文链接:http://fengbaiyang.cn/post/bandwidth-selector.html

-- EOF --

Comments

暂不支持评论,如有问题,请发邮件至baiyang.feng@outlook.com。 望不吝赐教~