【若川视野 x 源码共读】第38期 | vant-vue3步进器

381 阅读2分钟

这一章讲vue3组件库vant的一个步进器stepper组件,通过它来了解组件库中组件的构成

文档和预览:vant-ui.github.io/vant/#/zh-C…

源码:github1s.com/vant-ui/van…

参考::juejin.cn/post/713601…

功能:

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

样式

两个按钮包裹一个输入框

https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/Stepper.tsx#L291-L292

    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>
    );

按钮样式

  <div class={bem([props.theme])}> 
    <button
	style={buttonStyle.value}
  class={[
       bem('minus', { disabled: minusDisabled.value }),
       { [HAPTICS_FEEDBACK]: !minusDisabled.value },
       ]}
    />
    </div>
// 看看buttonStyle 是什么
const buttonStyle = computed(() => getSizeStyle(props.buttonSize));
// props也就是我们传入参数,api有这个button-size属性	按钮大小以及输入框高度,默认单位为 px
// 突然就完成了 一个功能 自定义大小 
// 例子:https://vant-ui.github.io/vant/#/zh-CN/stepper#zi-ding-yi-da-xiao
// <van-stepper v-model="value" input-width="40px" button-size="32px" />
// 这里传入32px
// 看getSizeStyle
# utils/format.ts
export function getSizeStyle(
  originSize?: Numeric | Numeric[]
): CSSProperties | undefined {
  if (isDef(originSize)) { // 是否定义,无视
      // 数组 不是 走下面
    if (Array.isArray(originSize)) {
      return {
        width: addUnit(originSize[0]),
        height: addUnit(originSize[1]),
      };
    }
      // addUnit('32px') = '32px'
    const size = addUnit(originSize);
      // 所以style的样式就是width = height = '32px',方块按钮样子的
    return {
      width: size,
      height: size,
    };
  }
}

// 是否定义
export const isDef = <T>(val: T): val is NonNullable<T> =>
              val !== undefined && val !== null;
export function addUnit(value?: Numeric): string | undefined {
  if (isDef(value)) {
      // 如果是数字 就加 px后缀,否则原路返回
    return isNumeric(value) ? `${value}px` : String(value);
  }
  return undefined;
}
// 哈哈我也不知道是什么东西,不过按猜测应该是数字或者小数点的数字
export const isNumeric = (val: Numeric): val is string =>
  typeof val === 'number' || /^\d+(\.\d+)?$/.test(val);

——————————接下来 看classclass={[
       bem('minus', { disabled: minusDisabled.value }),
       { [HAPTICS_FEEDBACK]: !minusDisabled.value },
       ]}
看bem('minus',) 其他的不看 
//f12得出按钮 class="van-stepper__minus van-haptics-feedback"
const [name, bem] = createNamespace('stepper');
# \packages\vant\src\utils\create.ts
export function createNamespace(name: string) {
    //name:stepper
  const prefixedName = `van-${name}`;
    // van-stepper
  return [
    prefixedName,// van-stepper
    createBEM(prefixedName),
    createTranslate(prefixedName),
  ] as const;
}
具体看 https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/utils/create.ts
太复杂,太花时间,跳过,总之 应该 bem('minus') = "van-stepper__minus van-haptics-feedback"
 <div class={bem([props.theme])}>  div.class = van-stepper
样式:https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/index.less#L19-L34

 @import './var.less';     
:root {
  --van-stepper-input-width: @stepper-input-width;
  --van-stepper-input-height: @stepper-input-height;
。。。。
.van-stepper {
  display: inline-block;
  user-select: none;

  &__minus, // 就是这个 van-stepper__minus
  &__plus {
    position: relative;
    box-sizing: border-box;
    width: var(--van-stepper-input-height);
    height: var(--van-stepper-input-height);
    margin: 0;
    padding: 0;
    color: var(--van-stepper-button-icon-color);
    vertical-align: middle;
    background: var(--van-stepper-background-color);
    border: 0;
    
    &::before {// 横杆 一 减号
      width: 50%;
      height: 1px;
    }

    &::after { // |	加号=before+after
      width: 1px;
      height: 50%;
    }

    &::before,
    &::after { //横杆 居中 
      position: absolute;
      top: 50%;
      left: 50%;
      background-color: currentColor;
      transform: translate(-50%, -50%);
      content: '';
    }

    &--disabled {
      color: var(--van-stepper-button-disabled-icon-color);
      background-color: var(--van-stepper-button-disabled-color);
      cursor: not-allowed;
    }
  }
    // 第一个buttom不需要 | 有减号就行。
&__minus {
    &::after {
      display: none;
    }
  }
https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/var.less
@import '../style/var.less';
@stepper-background-color: var(--van-active-color);
@stepper-button-icon-color: var(--van-text-color);
@stepper-button-disabled-color: var(--van-background-color);
@stepper-button-disabled-icon-color: var(--van-gray-5);
@stepper-button-round-theme-color: var(--van-danger-color);
@stepper-input-width: 32px;
@stepper-input-height: 28px; //于是就得出了初始的宽高是28px
@stepper-input-font-size: var(--van-font-size-md);
@stepper-input-line-height: normal;
@stepper-input-text-color: var(--van-text-color);
@stepper-input-disabled-text-color: var(--van-text-color-3);
@stepper-input-disabled-background-color: var(--van-active-color);
@stepper-border-radius: var(--van-border-radius-md);

我们传button-size,他是怎么接收驼峰的

总而言之:vue3 自转的。细节看下:

看看parsetransform的结果

vue-next-template-explorer.netlify.app/

<axiao x-a='abc'> </axiao>
结果:
_createVNode(_component_axiao, { "x-a": "abc" })
就是说`transform`没转,那只能在runtime
# vue3/shared.ts
若川源码有过这篇喔
https://github1s.com/vuejs/core/blob/main/packages/shared/src/index.ts#L124
// 横杆转驼峰
const camelizeRE = /-(\w)/g
export const camelize = cacheStringFunction((str: string): string => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})

# \packages\runtime-core\src\componentProps.ts
https://github1s.com/vuejs/core/blob/main/packages/runtime-core/src/componentProps.ts#L521
for (let i = 0; i < raw.length; i++) {
	const normalizedKey = camelize(raw[i])
    。。。
我也没细看,应该是runtime模块渲染组件的时候把props的key都转成驼峰了

image-20220901143628098.png

圆角风格

vant-ui.github.io/vant/#/zh-C…

先从感兴趣和感觉应该很简单的圆角下手

theme 设置为 round 来展示圆角风格的步进器。

<van-stepper v-model="value" theme="round" button-size="22" disable-input />

那么

<div role="group" class={bem([props.theme])}> 
这里取到theme 为 round ,bem执行后得到
div.class = 'van-stepper van-stepper--round'
                         
                         
.van-stepper {
  &--round {
    .van-stepper__input {
      background-color: transparent;
    }

    .van-stepper__plus,
    .van-stepper__minus { // 圆角
      border-radius: 100%;

      &--disabled {
        opacity: 0.3;
        cursor: not-allowed;
      }
    }

初始值

default-value	初始值,当 v-model 为空时生效
const stepperProps = {
defaultValue: makeNumericProp(1),
    。。
}
<input value={current.value}
          
    const inputRef = ref<HTMLInputElement>();
// 输入框 默认值 	defaultValue:1
    const current = ref(getInitialValue());

    const getInitialValue = () => {
        // 取model的值 ,拿不到再取默认值
      const defaultValue = props.modelValue ?? props.defaultValue;
      const value = format(defaultValue);
	// 如果默认值和 model值不一样 ,触发父组件回调事件 update:modelValue,这个文档都没写呢
      if (!isEqual(value, props.modelValue)) {
        emit('update:modelValue', value);
      }
	
      return value;
    };

格式化值

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;
    };

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

步长设置

通过 step 属性设置每次点击增加或减少按钮时变化的值,默认为 1

<van-stepper v-model="value" step="2" />

step 默认是 1

const stepperProps = {
  step: makeNumericProp(1),
  
  
export default defineComponent({
  name,
  props: stepperProps,

我们来看按钮事件

   <button
          {...createListeners('minus')}
        />
// 传入 type :minus
  const createListeners = (type: typeof actionType) => ({
      // 点击事件  触发 onChange
      onClick: (event: MouseEvent) => {
        // disable double tap scrolling on mobile safari
        preventDefault(event);
        actionType = type;  //actionType =  minus
        onChange();
      },
      onTouchstartPassive: () => {
        actionType = type;
        onTouchStart();
      },
      onTouchend: onTouchEnd,
      onTouchcancel: onTouchEnd,
    });
//actionType === 'minus'
const onChange = () => {
    if (
        (actionType === 'plus' && plusDisabled.value) ||
        (actionType === 'minus' && minusDisabled.value)
    ) {
        emit('overlimit', actionType);
        return;
    }
	//diff = -2
    const diff = actionType === 'minus' ? -props.step : +props.step;
    // current -2 
    const value = format(addNumber(+current.value, diff));
	// 更新
    setValue(value);
    // 组件Events minus 点击减少按钮时触发
    emit(actionType);
};

限制输入范围

通过 minmax 属性限制输入值的范围。

<van-stepper v-model="value" min="5" max="8" />
    const format = (value: Numeric) => {
      const { min, max, allowEmpty, decimalLength } = props;
。。。
      value = Math.max(Math.min(+max, value), +min);
。。。
      return value;
    };

我们在改值时都会用 format 包裹,当超过时,值会变成 限制的最大值

限制输入整数

设置 integer 属性后,输入框将限制只能输入整数。

<van-stepper v-model="value" integer />

原理和上面类似,这边是正则替换

    const format = (value: Numeric) => {
      const { min, max, allowEmpty, decimalLength } = props;
。。。
      value = formatNumber(String(value), !props.integer);
    }
    
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, '');
}
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, '');
}

禁用状态

通过设置 disabled 属性来禁用步进器,禁用状态下无法点击按钮或修改输入框。

<van-stepper v-model="value" disabled />
 const minusDisabled = computed(
     // props.disabled 传入 禁用参数,或者单独禁用减号,或者 当前数小于等于 限制最小值了
      () => props.disabled || props.disableMinus || current.value <= +props.min
    );

	<button
          class={[
            bem('minus', { disabled: minusDisabled.value }),
          ]}

样式多出 disabled
    &--disabled {
      color: var(--van-stepper-button-disabled-icon-color);
      background-color: var(--van-stepper-button-disabled-color);
      cursor: not-allowed;
    }

禁用输入框

通过设置 disable-input 属性来禁用输入框,此时按钮仍然可以点击。

<van-stepper v-model="value" disable-input />
        <input  readonly={props.disableInput}

用的是原生自带的 readonly
下面是组件样式
  &__input {
   &:disabled {
      color: var(--van-stepper-input-disabled-text-color);
      background-color: var(--van-stepper-input-disabled-background-color);
      // fix disabled color in iOS
      -webkit-text-fill-color: var(--van-stepper-input-disabled-text-color);
      opacity: 1;
    }

    &:read-only {
      cursor: default;
    }

固定小数位数

通过设置 decimal-length 属性可以保留固定的小数位数。

<van-stepper v-model="value" step="0.2" :decimal-length="1" />
 const format = (value: Numeric) => {
 // format decimal
      if (isDef(decimalLength)) {
        value = value.toFixed(+decimalLength);
      }

异步变更

通过 before-change 属性可以在输入值变化前进行校验和拦截。

<van-stepper v-model="value" :before-change="beforeChange" />
import { ref } from 'vue';
import { Toast } from 'vant';

export default {
  setup() {
    const value = ref(1);

    const beforeChange = (value) => {
      Toast.loading({ forbidClick: true });

      return new Promise((resolve) => {
        setTimeout(() => {
          Toast.clear();
          // 在 resolve 函数中返回 true 或 false
          resolve(true);
        }, 500);
      });
    };

    return {
      value,
      beforeChange,
    };
  },
};
// 更新值的调用函数,判断beforeChange是否存在,若存在,执行完后再更新值
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;	// 回调,更新值	current.value = value;
    canceled?: () => void; 	
  }
) {
  if (interceptor) {
    // eslint-disable-next-line prefer-spread
    const returnVal = interceptor.apply(null, args);
	// 如果是promise,执行完后执行 回调done
    if (isPromise(returnVal)) {
      returnVal
        .then((value) => {
          if (value) {
            done();
              // 没返回值,会去执行canceled
          } else if (canceled) {
            canceled();
          }
        })
        .catch(noop);
    } else if (returnVal) {
      done();
    } else if (canceled) {
      canceled();
    }
  } else {
    done();
  }
}

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

输入框失焦事件

blur输入框失焦时触发
       <input	onBlur={onBlur}
   
   const onBlur = (event: Event) => {
      const input = event.target as HTMLInputElement;
      const value = format(input.value);
      input.value = String(value);
      current.value = value;
       // 把输入框里的值格式化 和 更新
      nextTick(() => {
        emit('blur', event); // 这里触发,而且还是异步的
        resetScroll();
      });
    };

这里我好奇为啥不用 setValue去更新值,不用执行 before-change吗?

然后我去看了vue2版本的源码 github.com/vant-ui/van…

还是也是没有,我又自己建了个沙盒,里面确实改数据有触发beforeChange,所以我知道是我迷糊了,不是一定要输入框失去焦点才改值的。改值的操作在onInput里

codesandbox.io/s/vant-fork…

onInput

    const onInput = (event: Event) => {
      const input = event.target as HTMLInputElement;
      const { value } = input;
      const { decimalLength } = props;
      // 校验是否要整数,是的话格式化成整数
      let formatted = formatNumber(String(value), !props.integer);
      // 限定长度,和toFixed类似,这里为啥不跟format一样我也不懂   value = value.toFixed(+decimalLength);
      // 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;
      }
      // 赋值,setValue在这里,执行beforeChange
      // prefer number type
      const isNumeric = formatted === String(+formatted);
      setValue(isNumeric ? +formatted : formatted);
    };

总结

样式 传入的参数会作为 类或者style 替换里面的样式 ,和组件内容同一个文件夹,模块化

组件事件 通过子组件emit回调触发

有些变量用computed,为了能响应式的更新。

步进器由一个div包裹,其中三个儿子,div+input+div

设置一个响应式变量作为input的值,减号和加号会修改它的值,更新时加限制小数位、上下限等,格式化。