• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Vant Stepper 组件源码学习

武飞扬头像
叶小污已被占用
帮助1

本文参加了由公众号@若川视野 发起的每周源码共读活动,  点击了解详情一起参与。这是源码共读的第38期,链接:  【若川视野 x 源码共读】第38期 | 经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学!

基础标签和事件

// 用到原生标签 button input button

// 对外暴露事件
emits: [
  'plus',
  'blur',
  'minus',
  'focus',
  'change',
  'overlimit',
  'update:modelValue',
];

return () => (
  <div role="group" class={bem([props.theme])}>
    <button
      v-show={props.showMinus}
      type="button"
      style={buttonStyle.value}
      class={[
        bem('minus', { disabled: minusDisabled.value }),
        { [HAPTICS_FEEDBACK]: !minusDisabled.value },
      ]}
      aria-disabled={minusDisabled.value || undefined}
      {...createListeners('minus')}
    />
    <input
      v-show={props.showInput}
      ref={inputRef}
      type={props.integer ? 'tel' : 'text'}
      role="spinbutton"
      class={bem('input')}
      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}
      aria-valuemax={props.max}
      aria-valuemin={props.min}
      aria-valuenow={current.value}
      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 },
      ]}
      aria-disabled={plusDisabled.value || undefined}
      {...createListeners('plus')}
    />
  </div>
);

原生标签 button 上的属性和事件

  • v-show showMinus showPlus
  • aria-disabled 是一种用于辅助技术的属性,它用于指示一个控件是否禁用或不可用。
  • class 和 style 属性计算出来
  • onClick
  • onTouchstartPassive
  • onTouchend
  • onTouchcancel
  • 事件通过 actionType 区分加减号复用到函数内

原生标签 input 上的属性和事件

  • v-show showInput
  • ref inputRef
  • class
  • value
  • style
  • disabled 控制整个组件不可操作
  • readonly 控制自身 input 标签只读,可以通过按钮操作数据
  • type {props.integer ? 'tel' : 'text'} 整数用 tel 类型 弹起数字键盘
  • inputmode props.integer ? 'numeric' : 'decimal' numeric:表示输入法应该弹出数字键盘,适用于需要输入数字的控件;decimal:表示输入法应该弹出带小数点的数字键盘,适用于需要输入带有小数的数字的控件;这是 HTML5 中新增属性,优先级应该比 type 高,做到渐进增强
  • placeholder
  • role "spinbutton" role 属性是一种用于改变 HTML 元素的角色或语义的属性。它可以让开发者在 HTML 元素上指定一个 ARIA 角色(Accessible Rich Internet Applications 角色),从而为屏幕阅读器等辅助技术提供更多的信息,以提高网站的可访问性。spinbutton:表示输入框是一个微调框,用户可以通过上下箭头对值进行微调;
  • aria-valuemax 属性是指定可调节属性的最大值的 ARIA 属性(Accessible Rich Internet Applications 属性)。它用于指定控件的最大值,以便屏幕阅读器等辅助技术可以读取和报告控件的值范围。
  • aria-valuemin 属性是指定可调节属性的最小值的 ARIA 属性(Accessible Rich Internet Applications 属性)。它用于指定控件的最小值,以便屏幕阅读器等辅助技术可以读取和报告控件的值范围。
  • aria-valuenow 属性是指定可调节属性的当前值的 ARIA 属性(Accessible Rich Internet Applications 属性)。它用于指定控件的当前值,以便屏幕阅读器等辅助技术可以读取和报告控件的当前值。
  • onBlur
  • onInput
  • onFocus
  • onMousedown

组件数据流实现逻辑

    // 比对值,忽略类型差别
    const isEqual = (value1?: Numeric, value2?: Numeric) =>
      String(value1) === String(value2);

    // 格式化数据格式
    const format = (value: Numeric, autoFixed = true) => {
      const { min, max, allowEmpty, decimalLength } = props;
      // 兼容空值展示
      if (allowEmpty && value === '') {
        return value;
      }

      value = formatNumber(String(value), !props.integer);
      // 兼容空值转0
      value = value === '' ? 0 :  value;
      // 转数字类型
      value = Number.isNaN(value) ?  min : value;

      // whether to format the value entered by the user
      // 自动修正初始值再范围内
      value = autoFixed ? Math.max(Math.min( max, value),  min) : value;

      // format decimal
      // 小数点精度
      if (isDef(decimalLength)) {
        value = value.toFixed( decimalLength);
      }

      return value;
    };

    // 初始值函数 主要格式化组件显示值 返回格式化后的值做响应式
    // update:modelValue 单向修改父组件数据
    // watch props.modelValue 再修改 current的值
    const getInitialValue = () => {
      const defaultValue = props.modelValue ?? props.defaultValue;
      const value = format(defaultValue);

      if (!isEqual(value, props.modelValue)) {
        emit('update:modelValue', value);
      }

      return value;
    };

    const current = ref(getInitialValue());
    watch(
      () => props.modelValue,
      (value) => {
        if (!isEqual(value, current.value)) {
          current.value = format(value!);
        }
      }
    );

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

数据处理封装

// beforeChange钩子做拦截器控制current值变化
    const setValue = (value: Numeric) => {
      if (props.beforeChange) {
        callInterceptor(props.beforeChange, {
          args: [value],
          done() {
            current.value = value;
          },
        });
      } else {
        current.value = value;
      }
    };
  // 支持同步、异步自定义拦截函数
  export function callInterceptor(
    interceptor: Interceptor | undefined,
    {
      args = [],
      done,
      canceled,
    }: {
      args?: unknown[];
      done: () => void;
      canceled?: () => void;
    }
  ) {
    if (interceptor) {
      // eslint-disable-next-line prefer-spread
      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();
    }
  }

// 通过actionType判断是加还是减,然后调用setValue修改值
// 触发plus或minus事件或超出限制触发overlimit事件
    const onChange = () => {
      if (
        (actionType === 'plus' && plusDisabled.value) ||
        (actionType === 'minus' && minusDisabled.value)
      ) {
        emit('overlimit', actionType);
        return;
      }

      const diff = actionType === 'minus' ? -props.step :  props.step;
      const value = format(addNumber( current.value, diff));

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

    // 手动变更数据
    // 设置 integer 属性后,输入框将限制只能输入整数。
    // decimal-length	固定显示的小数位数
    const onInput = (event: Event) => {
      const input = event.target as HTMLInputElement;
      const { value } = input;
      const { decimalLength } = props;

      let formatted = formatNumber(String(value), !props.integer);

      // limit max decimal length
      if (isDef(decimalLength) && formatted.includes('.')) {
        const pair = formatted.split('.');
        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);
    };

touch 函数 支持长按触发效果

不熟识移动端 clicktouch 的事件逻辑,单从 createListeners 函数看会以为触发多次,以下:

在移动设备上,用户与屏幕的交互主要包括触摸和点击两种方式。当用户触摸或点击屏幕时,浏览器会触发一系列事件,包括 touchstart、touchmove、touchend、touchcancel、click 等事件。这些事件的触发顺序和触发逻辑如下:
  • touchstart:当用户触摸屏幕时触发,表示一个或多个手指已经放在了屏幕上。在此事件中,可以获取触摸点的坐标、时间戳等信息。

  • touchmove:当用户在屏幕上滑动手指时触发,表示触摸点的位置发生了变化。在此事件中,可以获取滑动过程中触摸点的坐标、时间戳等信息。

  • touchend:当用户从屏幕上抬起手指时触发,表示触摸事件结束。在此事件中,可以获取触摸结束时的坐标、时间戳等信息。

  • touchcancel:当触摸事件被中断时触发,例如系统中断了触摸事件、页面被隐藏等。

  • click:当用户在屏幕上点击元素时触发,表示用户已经完成了一个简单的点击操作。在此事件中,可以获取鼠标点击的位置、时间戳等信息。

  • 在移动设备上,触摸事件和点击事件是互斥的,即当触摸事件被触发时,浏览器不会立即触发点击事件,而是等待一定时间,如果用户没有继续滑动手指,则会触发点击事件。这个时间间隔通常为 300 毫秒,称为“点击延迟”(click delay)。

const createListeners = (type: typeof actionType) => ({
  onClick: (event: MouseEvent) => {
    // disable double tap scrolling on mobile safari
    // 在移动设备上,当用户双击屏幕时,浏览器会默认缩放页面。
    // 例如在 Stepper 组件中,缩放页面可能会导致一些问题,例如界面变形或者用户误操作等。
    preventDefault(event);
    actionType = type;
    onChange();
  },
  onTouchstartPassive: () => {
    actionType = type;
    onTouchStart();
  },
  onTouchend: onTouchEnd,
  onTouchcancel: onTouchEnd,
});
//onTouchstartPassive onTouchStart 是 JavaScript 中的事件处理程序,当用户触摸设备屏幕时触发该事件。默认情况下,当 onTouchStart 事件被触发时,浏览器会等待事件处理程序完成后再滚动页面。这可能会导致用户界面响应时间延迟,特别是在移动设备上更为明显。
const onTouchStart = () => {
  if (props.longPress) {
    isLongPress = false;
    clearTimeout(longPressTimer);
    longPressTimer = setTimeout(() => {
      isLongPress = true;
      onChange();
      longPressStep();
    }, LONG_PRESS_START_TIME);
  }
};

const onTouchEnd = (event: TouchEvent) => {
  if (props.longPress) {
    clearTimeout(longPressTimer);
    if (isLongPress) {
      preventDefault(event);
      // 由于在移动设备上,长按按钮会触发默认的长按菜单(类似于右键菜单),
      // 从而干扰了 Stepper 组件的长按功能。
      // 为了避免这种情况发生,需要在 touchstart 事件中调用 event.preventDefault() 方法来阻止默认的长按菜单的弹出。
    }
  }
};

useCustomFieldValue hook 实现跨组件间数据传输,实现表单校验、重置等效果

vue2 的时候多是是通过原型链往上级 parent 找最近的 form 以及 formitem 节点再触发组件内事件来传递事件。反之,上级要递归子节点找到后代目标节点传递事件

  1. stepper 组件通过 useCustomFieldValue inject 注入 Field 组件的一些事件以及值,然后 watch modelValue 变化触发 Field 组件事件以及更新数值
  2. Field 组件把内部事件暴露到 Field 组件 instance 上,通过 ref 可以获取到 Field 实例并调用实例方法
  3. Form 组件把事件暴露到 instance 上, 然后我们就可以直接调用,例如调用 validate 事件做表单校验,validate 函数会通过 useChildren 寻找所有 Field 组件,调用其身上对应暴露的 validate 事件校验 stepper 组件的值是否满足校验规则要求
// stepper组件
useCustomFieldValue(() => props.modelValue);
// useCustomFieldValue函数实现
export function useCustomFieldValue(customValue: () => unknown) {
  const CUSTOM_FIELD_INJECTION_KEY: InjectionKey<CustomFieldInjectionValue> =
    Symbol('van-field');
  const field = inject(CUSTOM_FIELD_INJECTION_KEY, null);
  if (field && !field.customValue.value) {
    field.customValue.value = customValue;
    // 监听值变化触发field上面的事件
    watch(customValue, () => {
      field.resetValidation();
      field.validateWithTrigger('onChange');
    });
  }
}

// Field组件
provide(CUSTOM_FIELD_INJECTION_KEY, {
  customValue, // 当前值
  resetValidation, // 重置校验
  validateWithTrigger, // 校验当前值
});
// 把事件暴露到Field组件instance上,通过 ref 可以获取到 Field 实例并调用实例方法
useExpose <
  FieldExpose >
  {
    blur,
    focus,
    validate,
    formValue,
    resetValidation,
    getValidationStatus,
  };

// Form组件
useExpose({
  submit,
  validate,
  getValues,
  scrollToField,
  resetValidation,
  getValidationStatus,
});
// useExpose 函数实现
// 把事件暴露到instance上,就可以直接调用组件内部的方法
export function useExpose<T = Record<string, any>>(apis: T) {
  const instance = getCurrentInstance();
  if (instance) {
    extend(instance.proxy as object, apis);
  }
}

学习 Vant 总结

  • bem 的封装 classname
  • 标签上大量辅助技术的属性
  • preventDefault 阻止原生事件
  • 结合useCustomFieldValue useChildren 等 hook 实现跨组件数据以及事件传输
  • 最近用到了 Vant,看着简单,写篇文章好难

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgaigai
系列文章
更多 icon
同类精品
更多 icon
继续加载