vant-weapp 中的步进器

500 阅读5分钟

前言

vant地址

vant 不仅有 小程序版本,也有 vue2vue3 这次我是用的使用 vue3

用法

看一个组件库,要先看他的用法 只保留了个人认为常用的 属性

Props

AttributeDescriptionTypeDefault
v-modelCurrent valuenumber | string-
minMin valuenumber | string1
maxMax valuenumber | string-
stepValue change stepnumber | string1
decimal-lengthDecimal lengthnumber | string-
themeTheme, can be set to roundstring-
integerWhether to allow only integersbooleanfalse
before-changeCallback function before changing, return false to prevent change, support return Promise(value: number | string) => boolean | Promise<boolean>false

Events

EventDescriptionArguments
changeEmitted when value changedvalue: string, detail: { name: string }
blurEmitted when the input is blurredevent: Event

功能

  1. 步长设置
  2. 设置输入范围
  3. 限制输入整数
  4. 禁用两边按钮点击
  5. 禁用输入框输入
  6. 固定小数位数
  7. 异步变更
    image.png

样式

  1. 自定义大小
  2. 圆角大小 image.png

源码解读

源码位置处于 packages/vant/src/stepper
使用了 tsx来构建这个组件,也是考虑到 tsxtemplate的这种方式更有利于 ts,灵活度也更高,能够提供使用者更好的使用体验


以下函数只提供核心内容

核心

 <button
          v-show={props.showMinus}
          type="button"
         {[HAPTICS_FEEDBACK]: !minusDisabled.value},
          ]}
          
          {...createListeners('minus')}
        />
        <input
          v-show={props.showInput}
          ref={inputRef}
          type={props.integer ? 'tel' : 'text'}
          value={current.value}
          style={inputStyle.value}
          disabled={props.disabled}
          readonly={props.disableInput}
          // set keyboard in modern browsers
          inputmode={props.integer ? 'numeric' : 'decimal'}
          placeholder={props.placeholder}
          
          onBlur={onBlur}
          onInput={onInput}
          onFocus={onFocus}
          onMousedown={onMousedown}
        />
        <button
          v-show={props.showPlus}
          type="button"
          style={buttonStyle.value}
          class={[
            bem('plus', { disabled: plusDisabled.value }),
            { [HAPTICS_FEEDBACK]: !plusDisabled.value },
          ]}
          {...createListeners('plus')}
        />   

如果经常看源码的同学,可以基本上可以通过变量名把功能猜的差不多了,只有这个 createListeners 有点疑问


createListeners

其实很简单,只是返回了一个对象,里面包含一些处理函数,应该是为了以后方便扩展,也让这个组件看起来更干净些

 const createListeners = (type: typeof actionType) => ({
      onClick: (event: MouseEvent) => {
        // disable double tap scrolling on mobile safari
        preventDefault(event);
        actionType = type;
        onChange();
      },
      
      onTouchstartPassive: () => {
        actionType = type;
        onTouchStart();
      },
      
      onTouchend: onTouchEnd,
      
      onTouchcancel: onTouchEnd,
    });

这个createListeners 的主要函数就是 onClick onClick的主要功能就是把 actionType 设置为 plus或者 minus 和 触发 onChange方法 继续追究 onChange 方法做了什么


onChange

const onChange = () => {
      const diff = actionType === 'minus' ? -props.step : +props.step;

      const value = format(addNumber(+current.value, diff));

      setValue(value);
      emit(actionType);
    };

判断了 全局变量 actionType 的类型,props.step 就是步长,用户传入,默认为 1,把 步长 与 当前值 做一个转换,把 转换结果 作为 setValue 的实参。

formataddNumber都是 工具函数,后面会讲
先看 setValue 这个函数


setValue

const setValue = (value: Numeric) => {
      if (props.beforeChange) {
        callInterceptor(props.beforeChange, {
          args: [value],
          done() {
            current.value = value;
          },
        });
      } else {
        current.value = value;
      }
    };

props.beforeChange 是用户传进来在改变数值之前的拦截器,它被 callInterceptor包裹, 我们来看一下 callInterceptor


callInterceptor

接收 2个参数

  1. 一个函数

  2. 一个对象

    • args:[value]
    • done 对象函数
function callInterceptor(
  interceptor: Interceptor | undefined,{
    args = [],
    done,
    canceled,
  }: {
    args?: unknown[];
    done: () => void;
    canceled?: () => void;
  }
) {
  if (interceptor) {
   
    const returnVal = interceptor.apply(null, args);

    if (isPromise(returnVal)) {
      returnVal
        .then((value) => {
          if (value) {
            done();
          } else if (canceled) {
            canceled();
          }
        })
        .catch(noop);
    } else if (returnVal) {
      done();
    } else if (canceled) {
      canceled();
    }
  } else {
    done();
  }
}

首先判断是否传入有 interceptor拦截器

graph TD
是否有拦截器 --> 有
是否有拦截器 --> 没有
有-->是Promise
是Promise-->执行then方法
执行then方法-->执行done或者canceled方法
没有-->有返回值执行done

其中isPromise函数是自己封装,见名知意,判断是否是Promise

isPromise
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function';

export const isObject = (val: unknown): val is Record<any, any> =>
  val !== null && typeof val === 'object';

export const isPromise = <T = any>(val: unknown): val is Promise<T> =>
  isObject(val) && isFunction(val.then) && isFunction(val.catch);

大概的两侧按钮点击事件就这么多了,剩下的是 中间的 input 的各种事件


input

   <input
    ....
    value={current.value}
    onInput={onInput}
   >
   </input>

当前的input的绑定的是 valueonInput 事件,这样便于处理 步长,小数位数 等问题

onInput 事件

对输入的值进行转化,构造成符合条件后的数字,如果是小数,要根据传入的 decimalLength进行切割, 然后赋值给 current.value

  const onInput = (event: Event) => {
      const input = event.target as HTMLInputElement;
      const { value } = input;
      // input value 是当前输入的 value 值,current.value 是绑定的值
      const { decimalLength } = props;
        
       // 转换当前输入的值  integer 整数
      let formatted = formatNumber(String(value), !props.integer);

    
      // limit max decimal length
      if (isDef(decimalLength) && formatted.includes('.')) {
        const pair = formatted.split('.');
        
        // 截取后面的小数位数,比如你输入的是 1.23456,但是23456 会被 decimalLength 截断
        formatted = `${pair[0]}.${pair[1].slice(0, +decimalLength)}`;
      }

      if (props.beforeChange) {
        input.value = String(current.value);
      } else if (!isEqual(value, formatted)) {
        input.value = formatted;
      }
      
      // prefer number type 字符串转数字
      const isNumeric = formatted === String(+formatted);
      setValue(isNumeric ? +formatted : formatted);
    };

其中有一个工具函数我觉得还是比较有意思的 formatNumber


工具函数 formatNumber
function trimExtraChar(value: string, char: string, regExp: RegExp) {
  const index = value.indexOf(char);

  if (index === -1) {
    return value;
  }

  if (char === '-' && index !== 0) {
    return value.slice(0, index);
  }

  return value.slice(0, index + 1) + value.slice(index).replace(regExp, '');
}

export function formatNumber(
  value: string,
  allowDot = true,
  allowMinus = true
) {
  if (allowDot) {
    value = trimExtraChar(value, '.', /\./g);
  } else {
    value = value.split('.')[0];
  }

  if (allowMinus) {
    value = trimExtraChar(value, '-', /-/g);
  } else {
    value = value.replace(/-/, '');
  }

  const regExp = allowDot ? /[^-0-9.]/g : /[^-0-9]/g;

  return value.replace(regExp, '');
}

trimExtraChar 是对一个字符valuechar分割成两部分,第二部分用传入的 regExp进行替换成 ''

然后判断是否需要小数点allowDot,如果需要,则需要正则 /[^-0-9.]/g非 -,数字 小数点 全部替换 成 空字符,保证全部是数字

同理,失去焦点也是差不多的逻辑,只要等输入框失去焦点,就要执行判断逻辑


onBlur

const onBlur = (event: Event) => {
      const input = event.target as HTMLInputElement;
      const value = format(input.value);
      input.value = String(value);
      current.value = value;
    };

又出现了一个 工具函数 format

format
const format = (value: Numeric) => {
      const { min, max, allowEmpty, decimalLength } = props;

      if (allowEmpty && value === '') {
        return value;
      }

      value = formatNumber(String(value), !props.integer);
      value = value === '' ? 0 : +value;
      value = Number.isNaN(value) ? +min : value;
      value = Math.max(Math.min(+max, value), +min);

      // format decimal
      if (isDef(decimalLength)) {
        value = value.toFixed(+decimalLength);
      }

      return value;
    };

formatNumber 要判断的多,因为是最终显示的结果,判断

  1. 写入的 value 是不是 空,空的话转成数字 0,不是的话 +value 转成数字
  2. 是不是一个数字类型(Number.isNaN),不过不是,value 是最小值
  3. 限制范围 Math.max(Math.min(+max, value), +min) 要把 value 限制在 minmax之间,经典 比 最大值比较,取小值,与最小值比较,取最大值
  4. 如果是小数位数限制,还要 toFixed设置长度

最后一点

当然用户使用是 v-model 双向绑定,所以需要触发父元素的事件来改变自身的值

watch(current, (value) => {
      emit('update:modelValue', value);
      emit('change', value, { name: props.name });
    });

一个特殊的ts类型 ExtractPropTypes,抽取 props 类型,形成


export const makeNumericProp = <T>(defaultVal: T) => ({
  type: numericProp,
  default: defaultVal,
});

export const truthProp = {
  type: Boolean, // 这里作为 构造函数,不能是 boolean 
  default: true as const,
};

const stepperProps = {
  min: makeNumericProp(1),
  max: makeNumericProp(Infinity),
  name: makeNumericProp(''),
  theme: String as PropType<StepperTheme>,
  integer: Boolean,

  showPlus: truthProp,
  allowEmpty: Boolean,
  modelValue: numericProp,
};
// 为了暴露给使用者
export type StepperProps = ExtractPropTypes<typeof stepperProps>;

defineComponent({
    props: stepperProps,
    setup(props,{emit}){
        ...
    }
})

暴露给使用者

type StepperProps = {
name: string | number;
min: string | number;
max: string | number;
....
}

最后

通过这个简单vue3的步进器,学到了很多,包括但不限于 vue3 中的 tsx 写法,自己平时很少用这种方式,确实还可以,更趋近于react的写法, 主要是 学会了 对 用户传入的 各种情况进行判断,不断的矫正用户输入