源码阅读-vant2.x【stepper步进器】的亿点点细节

240 阅读5分钟

前言

Suggestion.gif

stepper步进器

首先来看看步进器的样子吧,它可以左右调整输入框中的数值,递增 or 递减,也可以输入值,官网还有其他功能的说明

步进器png.png

克隆项目

刚开始调试项目的遇到了小问题,启动一直报错,后来看到根目录.github/CONTRIBUTING.md 的最后有启动项目的提示,才能顺利进行下去,调试起来就方便多了

git clone https://github.com/youzan/vant.git
cd vant // 切换分支到vant 2.x版本
yarn 
yarn dev
// open http://localhost:8080

关于【pnpm】

在开始阅读源码前,想聊聊pnpm,去看了官方的描述,阅读3分钟的英文文档,意思大概是在性能上npmyarninstallupdate等方面更优秀,并配上了对比图,不容反驳哈哈哈,可以查看这里 pnpm最大的不同,是它生成的node_modules结构与众不同,不是相较于npm等包管理器,他是非扁平结构的。

pnpm有统一的存储地址,且通过链接方式进行文件共享。可以阅读这篇文章扁平的node_modules不是唯一的方法,里面有author对pnpm独特的依赖包的结构的讲解。

image.png

这里大致了解一下他的作用、和优势就可以啦

组件template

render()函数提供了组件渲染模板,里面结构清晰,createListeners(type)根据传入的type构造事件监听器,分别监听clicktouchstarttouchendtouchcancel几个事件,即在pc与移动端的事件类型。type分别为minus(减)、plus(加),左右两个按钮共享这一组事件。

中间部分是input控件,表单属性type根据integer控制在移动端展示是否只输入数值,分别为teltext,其他例如禁用、只读、inputStyle等都与暴露出来属性有关,input上也有一组事件,后面会接着分析

computed: {
    inputStyle() {
      const style = {};

      if (this.inputWidth) { // 输入框宽度
        style.width = addUnit(this.inputWidth);
      }

      if (this.buttonSize) { // 按钮大小,控制输入框高度
        style.height = addUnit(this.buttonSize);
      }

      return style;
    },
},
render() {
    const createListeners = (type) => ({
      on: {
        click: (e) => {
          // disable double tap scrolling on mobile safari
          e.preventDefault();
          this.type = type;
          this.onChange();
        },
        touchstart: () => {
          this.type = type;
          this.onTouchStart();
        },
        touchend: this.onTouchEnd,
        touchcancel: this.onTouchEnd,
      },
    });

    return (
      <div class={bem([this.theme])}>
        <button
          vShow={this.showMinus}
          type="button"
          style={this.buttonStyle}
          class={bem('minus', { disabled: this.minusDisabled })}
          {...createListeners('minus')}
        />
        <input
          vShow={this.showInput}
          ref="input"
          type={this.integer ? 'tel' : 'text'}
          role="spinbutton"
          class={bem('input')}
          value={this.currentValue}
          style={this.inputStyle}
          disabled={this.disabled}
          readonly={this.disableInput}
          // set keyboard in modern browsers
          inputmode={this.integer ? 'numeric' : 'decimal'}
          placeholder={this.placeholder}
          aria-valuemax={this.max}
          aria-valuemin={this.min}
          aria-valuenow={this.currentValue}
          onInput={this.onInput}
          onFocus={this.onFocus}
          onBlur={this.onBlur}
          onMousedown={this.onMousedown}
        />
        <button
          vShow={this.showPlus}
          type="button"
          style={this.buttonStyle}
          class={bem('plus', { disabled: this.plusDisabled })}
          {...createListeners('plus')}
        />
      </div>
    );
  }

addUnit()

有没有注意到addUnit方法,大家写控件,很多时候会有类似inputWidth这样的样式属性,这种属性类型,会有numberstring。自己之前写的时候,没有想到可以将其拆分为公共的方法,可以借鉴一下

export function addUnit(value?: string | number): string | undefined {
  if (!isDef(value)) {
    return undefined;
  }

  value = String(value);
  // 校验是number就拼接单位:px
  return isNumeric(value) ? `${value}px` : value;
}
export function isNumeric(val: string): boolean {
// 整数 or 浮点数
  return /^\d+(\.\d+)?$/.test(val);
}

onChange()

onChange() {
    const { type } = this;
     // type是minus or plus,如果按钮状态不可点击,会触发overlimit事件 并返回
      if (this[`${type}Disabled`]) {
        this.$emit('overlimit', type);
        return;
      }
        // 调整步长
      const diff = type === 'minus' ? -this.step : +this.step; 
      // addNumber 防止相加的数值出现浮点数超限问题,并格式化数值
      const value = this.format(addNumber(+this.currentValue, diff));
      // 同步currentValue
      this.emitChange(value);
      // 两侧按钮点击会触发事件
      this.$emit(type);
   },
   emitChange(value) {
   // asyncChange异步修改,不会立即修改currentValue,而是触发自定义事件,使用者可自己决定修改currentValue的时机
      if (this.asyncChange) {
        this.$emit('input', value);
        this.$emit('change', value, { name: this.name });
      } else {
      // 给currentValue重新赋值,在watch里会监听currentValue值变化,也会触发input、change事件
        this.currentValue = value;
      }
    },
   format(value) {
       // allowEmpty 允许输入框是空字符
      if (this.allowEmpty && value === '') {
        return value;
      }
      // 格式化value
      value = this.formatNumber(value);

      // format range 
      value = value === '' ? 0 : +value; // 这里还悄悄进行类型转换
      value = isNaN(value) ? this.min : value;
      value = Math.max(Math.min(this.max, value), this.min); // 修正大小值的限制

      // format decimal
      // decimalLength 保留几位小数
      if (isDef(this.decimalLength)) {
        value = value.toFixed(this.decimalLength);
      }

      return value;
    },
    // formatNumber illegal characters
    formatNumber(value) {
      return formatNumber(String(value), !this.integer);
    },
export function formatNumber(
  value: string,
  allowDot = true,
  allowMinus = true
) {
// allowDot是否允许存在小数点 
  if (allowDot) { 
  // 修正第一个小数点.的位置,及过滤掉其余位置不适当的小数点
    value = trimExtraChar(value, '.', /\./g);
  } else {
    value = value.split('.')[0];
  }
    // allowMinus 是否允许存在-
  if (allowMinus) {
  // 过滤掉位置不适当的-
    value = trimExtraChar(value, '-', /-/g);
  } else {
    value = value.replace(/-/, '');
  }
 // 去除数字中的非-、0-9、.的字符
  const regExp = allowDot ? /[^-0-9.]/g : /[^-0-9]/g;

  return value.replace(regExp, '');
}
function trimExtraChar(value: string, char: string, regExp: RegExp) {
  const index = value.indexOf(char);
  let prefix = '';
   // 没找到直接返回
  if (index === -1) {
    return value;
  }
  // -出现在中间,返回-之前的数值
  if (char === '-' && index !== 0) {
    return value.slice(0, index);
  }
  // 这里匹配.123 or -.123,prefix增加0 or -0
  if (char === '.' && value.match(/^(\.|-\.)/)) {
    prefix = index ? '-0' : '0';
  }

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

onInput()

onInput(event) {
      const { value } = event.target;
     // 同样需要格式化
      let formatted = this.formatNumber(value);

      // limit max decimal length
      // 输入的过程中限制小数位数
      if (isDef(this.decimalLength) && formatted.indexOf('.') !== -1) {
        const pair = formatted.split('.');
        formatted = `${pair[0]}.${pair[1].slice(0, this.decimalLength)}`;
      }

      if (!equal(value, formatted)) {
        event.target.value = formatted;
      }

      // prefer number type
      // 更期望是数值类型
      if (formatted === String(+formatted)) {
        formatted = +formatted;
      }
       // 触发change、input事件
      this.emitChange(formatted);
    },

onBlur()

 onBlur(event) {
       // 格式化数值
      const value = this.format(event.target.value);
      // 重新给输入框赋值
      event.target.value = value;
      // 触发change、input事件
      this.emitChange(value);
      // 触发blur事件
      this.$emit('blur', event);
        
      resetScroll();
   },

onFocus()

onFocus(event) {
      // readonly not work in legacy mobile safari
      // 为了兼容处理部分老机型不能识别readonly属性
      if (this.disableInput && this.$refs.input) {
        this.$refs.input.blur();
      } else {
      // 触发focus事件
        this.$emit('focus', event);
      }
    },

onMouseDown()

onMousedown(event) {
      // fix mobile safari page scroll down issue
      // see: https://github.com/vant-ui/vant/issues/7690
      // 上面这个issues链接 是处理的兼容逻辑 在禁用输入框的状态下 仍触发了某些滚动行为
      if (this.disableInput) {
        event.preventDefault();
      }
    },

currentValue

我们来看看currentValue,外部传入的value,会赋值给data上的currentValue变量

data() {
   // ?? 是判断左侧如果是null 或者 undefined 就会返回右侧的值
   // value没有值 就会使用defaultValue默认值
    const defaultValue = this.value ?? this.defaultValue;
    // 格式化value
    const value = this.format(defaultValue);
    // 判断前后两个值不一致 就会立即触发input事件
    if (!equal(value, this.value)) {
      this.$emit('input', value);
    }

    return {
      currentValue: value, // props上的value会赋值给内部的变量currentValue
    };
  },
  watch: {
    max: 'check', // 思考为什么要监听这四个值的变化  又要做什么处理呢?
    min: 'check',
    integer: 'check',
    decimalLength: 'check',

    value(val) {
    // 监听外部传入的value  如果两个值不一样 就会赋值给currentValue 同时格式化
      if (!equal(val, this.currentValue)) {
        this.currentValue = this.format(val);
      }
    },

    currentValue(val) { // 内部修改currentValue会触发input、change事件
      this.$emit('input', val);
      this.$emit('change', val, { name: this.name });
    },
  },
  methods: {
    check() {
    // 因为一旦min、max、decimalLength、integer的变化 都会引起currentValue的值发生变化,详细可以看format函数,例如mix、max发生变化,可能会修正range
      const val = this.format(this.currentValue);
      if (!equal(val, this.currentValue)) {
        this.currentValue = val;
      }
    },
 }

computed相关

最后再关注一下computed属性,

computed: {
// 减号禁用状态,这些属性都会有优先级,依次是整个控件禁用>只禁用减号>当前值已经是最小值
    minusDisabled() {
      return (
        this.disabled || this.disableMinus || this.currentValue <= +this.min
      );
    },
// 加号禁用状态,同上
    plusDisabled() {
      return (
        this.disabled || this.disablePlus || this.currentValue >= +this.max
      );
    },
// 输入框的style属性,最好不要直接设置dom上,就写成计算属性
    inputStyle() {
      const style = {};
 // 输入框宽度
      if (this.inputWidth) {
        style.width = addUnit(this.inputWidth);
      }
 // 按钮大小决定输入框高度
      if (this.buttonSize) {
        style.height = addUnit(this.buttonSize);
      }

      return style;
    },

    buttonStyle() {
      if (this.buttonSize) {
        const size = addUnit(this.buttonSize);
// 按钮大小:宽与高
        return {
          width: size,
          height: size,
        };
      }
    },
  },

总结

我们今天学习vant2.xstepper组件,源码300多行,但是内容很丰富。

  1. 关注新的打包工具【pnpm
  2. 从头到尾了解组件的结构、功能以及一些处理细节,个人觉得里面对于format格式的处理非常细致,还抽离成了公共函数。
  3. 在代码中也会发现开源项目中会解决一些issue问题,以及一些兼容处理,也很巧妙

在工作中如果常常封闭自己,没有接触到一些优秀源码,就会坐井观天,固步自封。 积极拥抱开源,从阅读开源代码中能够获取成长。

今天依然是希望提高写作能力与前端技术的小刘同学( ̄︶ ̄*))

下期源码继续💡