Vant-weapp Stepper 学习

113 阅读4分钟

前言

Stepper组件了解

步进器由增加按钮、减少按钮和输入框组成,用于在一定范围内输入、调整数字
Stepper组件官方文档:vant.pro/vant-weapp/…
组件UI截图:
image.png

组件结构

index.wxml

minus按钮 + input输入框 + plus按钮页面UI代码

<wxs src="../wxs/utils.wxs" module="utils" />
<wxs src="./index.wxs" module="computed" />

<view class="{{ utils.bem('stepper', [theme]) }} custom-class">
  <view
    wx:if="{{ showMinus }}"
    data-type="minus"
    style="{{ computed.buttonStyle({ buttonSize }) }}"
    class="minus-class {{ utils.bem('stepper__minus', { disabled: disabled || disableMinus || currentValue <= min }) }}"
    hover-class="van-stepper__minus--hover"
    hover-stay-time="70"
    bind:tap="onTap"
    bind:touchstart="onTouchStart"
    bind:touchend="onTouchEnd"
  >
    <slot name="minus" />
  </view>
  <input
    type="{{ integer ? 'number' : 'digit' }}"
    class="input-class {{ utils.bem('stepper__input', { disabled: disabled || disableInput }) }}"
    style="{{ computed.inputStyle({ buttonSize, inputWidth }) }}"
    value="{{ currentValue }}"
    focus="{{ focus }}"
    disabled="{{ disabled || disableInput }}"
    always-embed="{{ alwaysEmbed }}"
    bindinput="onInput"
    bind:focus="onFocus"
    bind:blur="onBlur"
  />
  <view
    wx:if="{{ showPlus }}"
    data-type="plus"
    style="{{ computed.buttonStyle({ buttonSize }) }}"
    class="plus-class {{ utils.bem('stepper__plus', { disabled: disabled || disablePlus || currentValue >= max }) }}"
    hover-class="van-stepper__plus--hover"
    hover-stay-time="70"
    bind:tap="onTap"
    bind:touchstart="onTouchStart"
    bind:touchend="onTouchEnd"
  >
    <slot name="plus" />
  </view>
</view>

index.wxss

组件样式

index.json

标识组件

{
  "component": true
}

index.ts

组件的属性设置
组件的功能逻辑实现

import { VantComponent } from '../common/component';
import { isDef } from '../common/validator';

VantComponent({
  ...
  },
  data: {
    currentValue: '',
  },

  watch: {
    ...
  },

  created() {
    ...
  },

  methods: {
    ...
  },
});

源码详解

1. 组件初始化

  • VantComponent函数
    将vantOptions数据整合成微信小程序Component所需要的传参数据,然后调用微信小程序Component类来创建组件
function VantComponent<
  Data extends WechatMiniprogram.Component.DataOption,
  Props extends WechatMiniprogram.Component.PropertyOption,
  Methods extends WechatMiniprogram.Component.MethodOption
>(vantOptions: VantComponentOptions<Data, Props, Methods>): void {
  const options: WechatMiniprogram.Component.Options<Data, Props, Methods> = {};
  
  // 整合vantOptions里面的数据到options中
  mapKeys(vantOptions, options, {
    data: 'data',
    props: 'properties',
    watch: 'observers',
    mixins: 'behaviors',
    methods: 'methods',
    beforeCreate: 'created',
    created: 'attached',
    mounted: 'ready',
    destroyed: 'detached',
    classes: 'externalClasses',
  });

  // add default externalClasses
  options.externalClasses = options.externalClasses || [];
  options.externalClasses.push('custom-class');

  // add default behaviors
  options.behaviors = options.behaviors || [];
  options.behaviors.push(basic);

  // add relations
  const { relation } = vantOptions;
  if (relation) {
    options.relations = relation.relations;
    options.behaviors.push(relation.mixin);
  }

  // map field to form-field behavior
  if (vantOptions.field) {
    options.behaviors.push('wx://form-field');
  }

  // add default options
  options.options = {
    multipleSlots: true,
    addGlobalClass: true,
  };

  Component(options);
}

export { VantComponent };

  • mapKeys函数
    将source对象的属性值赋值给target对象,实现对象的浅拷贝
function mapKeys(
  source: Record<string, any>,
  target: Record<string, any>,
  map: Record<string, any>
) {
  Object.keys(map).forEach((key) => {
    if (source[key]) {
      target[map[key]] = source[key];
    }
  });
}
  • 组件初始化
    • 在 Vant Weapp 的小程序组件中,props 和 data 中的属性都可以通过 this.data 统一访问
    • Created钩子函数将外部传递的value赋值给当前组件属性currentValue
    • observeValue函数在watch中使用,观察外部传入组件的value属性变化,并将变化值赋值给currentValue
    • filter函数是过滤value中不是数字、点和小横线的字符
    • format函数将value控制在min值至max值范围内,如果存在小数位限制,还要格式化成小数位的字符串
// 导入VantComponent函数创建组件
import { VantComponent } from '../common/component';
import { isDef } from '../common/validator';

const LONG_PRESS_START_TIME = 600;
const LONG_PRESS_INTERVAL = 200;

// add num and avoid float number
function add(num1: number, num2: number) {
  const cardinal = 10 ** 10;
  return Math.round((num1 + num2) * cardinal) / cardinal;
}

function equal(value1: number | string, value2: number | string) {
  return String(value1) === String(value2);
}

VantComponent({
  field: true,
  // 内置样式
  classes: ['input-class', 'plus-class', 'minus-class'],
  // 外部传参属性
  props: {
    value: { // 输入框value值
      type: null,
    },
    integer: { // 是否为整数
      type: Boolean,
      observer: 'check',
    },
    disabled: Boolean, // 是否禁用
    inputWidth: String, // input宽度
    buttonSize: String, // 按钮尺寸
    asyncChange: Boolean,  // 如果需要异步地修改输入框的值,可以设置`async-change`属性,并在`change`事件中手动修改`value`
    disableInput: Boolean, // 禁用输入框
    decimalLength: {  // 小数点位数长度
      type: Number,
      value: null as unknown as number,
      observer: 'check',
    },
    min: { // 最小值
      type: null,
      value: 1,
      observer: 'check',
    },
    max: { // 最大值
      type: null,
      value: Number.MAX_SAFE_INTEGER,
      observer: 'check',
    },
    step: { // 步长
      type: null,
      value: 1,
    },
    showPlus: { // 显示加号按钮
      type: Boolean,
      value: true,
    },
    showMinus: { // 显示减号按钮
      type: Boolean,
      value: true,
    },
    disablePlus: Boolean, // 禁用加号按钮
    disableMinus: Boolean, // 禁用减号按钮
    longPress: { // 是否长按
      type: Boolean,
      value: true,
    },
    theme: String,
    alwaysEmbed: Boolean,
  },

  data: {
    currentValue: '',
  },
  watch: {
    // 观察value属性的变化
    value() {
      this.observeValue();
    },
  },

  created() {
    // 将外部传入的value赋值给currentValue
    this.setData({
      currentValue: this.format(this.data.value).newValue,
    });
  },

  methods: {
    observeValue() {
      const { value } = this.data;
      this.setData({ currentValue: this.format(value).newValue });
    },
    filter(value: string): string {
      // 替换除了数字、点和小横线的其他字符为空
      value = String(value).replace(/[^0-9.-]/g, '');
      // 如果integer属性为true,则取整数部分
      if (this.data.integer && value.indexOf('.') !== -1) {
        value = value.split('.')[0];
      }

      return value;
    },
    format(value: string) {
      // 过滤非法字符串
      const safeValue = this.filter(value);

      // 获取value在min-max的取值,超过max或min则取值max或min
      const rangeValue = Math.max(
        Math.min(this.data.max, +safeValue),
        this.data.min
      );

      // 格式化小数点位数
      const newValue = isDef(this.data.decimalLength)
        ? rangeValue.toFixed(this.data.decimalLength)
        : String(rangeValue);

      return { value, newValue };
    },
  },
});

2. 点击减号按钮

  • wxml
  <view
    wx:if="{{ showMinus }}"
    data-type="minus"
    style="{{ computed.buttonStyle({ buttonSize }) }}"
    class="minus-class {{ utils.bem('stepper__minus', { disabled: disabled || disableMinus || currentValue <= min }) }}"
    hover-class="van-stepper__minus--hover"
    hover-stay-time="70"
    bind:tap="onTap"
    bind:touchstart="onTouchStart"
    bind:touchend="onTouchEnd"
  >
    <slot name="minus" />
  </view>
  • event
    • 点击按钮触发tap事件,这里会向外部父组件广播3个事件,overlimit事件、change事件和minus事件
    • dataset能获取view上data-的所有自定义属性值
onTap(event) {
    // 获取data-type属性minus
    const { type } = event.currentTarget.dataset;
    // 将minus赋值到this.type
    this.type = type;
    // 触发change
    this.onChange();
}
  onChange() {
        // type为minus或者plus
        const { type } = this;
        // 如果判断是disable状态,则向外部广播一个overlimit事件
        if (this.isDisabled(type)) {
            this.$emit('overlimit', type);
            return;
        }
        // 加减步长值
        const diff = type === 'minus' ? -this.data.step : +this.data.step;
        // 获取加减步长之后新的value
        const value = this.format(String(add(+this.data.currentValue, diff)));
        this.emitChange(value);
        // 广播minus事件
        this.$emit(type);
    }
    isDisabled(type) {
        const { disabled, disablePlus, disableMinus, currentValue, max, min } = this.data;
        if (type === 'plus') {
            // 存在disable属性或者value超出max边界
            return disabled || disablePlus || +currentValue >= +max;
        }
        // 存在disable属性或者value超出min边界
        return disabled || disableMinus || +currentValue <= +min;
    },
    emitChange(data) {
            const { value, newValue } = data;
            if (!this.data.asyncChange) {
                // fix when input 11. parsed to 11, unable to enter decimal
                this.setData({ currentValue: +value === +newValue ? value : newValue });
            }
            // 广播change事件
            this.$emit('change', +newValue);
        },

3. 长按减号按钮

触发onTouchStart和onTouchEnd事件

 onTouchStart(event) {
     // 如果设置longPress为false, return
    if (!this.data.longPress) {
        return;
    }
    // 清除已经存在的定时器
    clearTimeout(this.longPressTimer);
    const { type } = event.currentTarget.dataset;
    this.type = type;
    this.isLongPress = false;
    this.longPressTimer = setTimeout(() => {
        this.isLongPress = true;
        // 600ms后触发onChange事件
        this.onChange();
        this.longPressStep();
    }, LONG_PRESS_START_TIME);
},
longPressStep() {
    // longPressStep自己调用自己实现定时增加或者减少步长
    this.longPressTimer = setTimeout(() => {
        this.onChange();
        this.longPressStep();
    }, LONG_PRESS_INTERVAL);
},
onTouchEnd() {
    if (!this.data.longPress) {
        return;
    }
    // 清楚定时器
    clearTimeout(this.longPressTimer);
},

4. 点击加号按钮和长按加号按钮

除了配置属性命名不一致外,功能和减号按钮都一致

 <view
    wx:if="{{ showPlus }}"
    data-type="plus"
    style="{{ computed.buttonStyle({ buttonSize }) }}"
    class="plus-class {{ utils.bem('stepper__plus', { disabled: disabled || disablePlus || currentValue >= max }) }}"
    hover-class="van-stepper__plus--hover"
    hover-stay-time="70"
    bind:tap="onTap"
    bind:touchstart="onTouchStart"
    bind:touchend="onTouchEnd"
  >
    <slot name="plus" />
  </view>

5. 输入框事件

  <input
    type="{{ integer ? 'number' : 'digit' }}"
    class="input-class {{ utils.bem('stepper__input', { disabled: disabled || disableInput }) }}"
    style="{{ computed.inputStyle({ buttonSize, inputWidth }) }}"
    value="{{ currentValue }}"
    focus="{{ focus }}"
    disabled="{{ disabled || disableInput }}"
    always-embed="{{ alwaysEmbed }}"
    bindinput="onInput"
    bind:focus="onFocus"
    bind:blur="onBlur"
  />
  • onInput 获取wx提供的event对象的value, 向外部广播change事件
    onInput(event) {
            const { value = '' } = event.detail || {};
            // allow input to be empty
            if (value === '') {
                return;
            }
            const formatted = this.format(value);
            this.emitChange(formatted);
        },
  • onFocus 获取event对象的detail数据,广播focus事件到外层组件
  onFocus(event) {
            this.$emit('focus', event.detail);
        }
  • onBlur 格式化event.detail.value数据然后广播chang事件和blur事件到外层组件
   onBlur(event) {
            const data = this.format(event.detail.value);
            this.setData({ currentValue: data.newValue });
            this.emitChange(data);
            this.$emit('blur', Object.assign(Object.assign({}, event.detail), { value: +data.newValue }));
        }

总结

从组件的wxml、css和JS了解一个stepp组件的实现逻辑,组件功能相对简单。
坚持阅读源码,如有问题,欢迎指正