用React hooks实现一个inputNumber组件

664 阅读3分钟

前言

前一阵子某个业务上需要用到inputNumber组件,而我们团队才刚成立没多久,许多组件都要自己从头写,就想着自己写一个得了,但没想到实现过程中遇到这么多坑,所以在此分享自己的代码,抱砖引玉一下。

需求

  • 在输入时删除全部内容

    在输入框可删除全部内容,删除全部内容后,输入提交,元素不变化,回到上一值

  • 非法字符的定义

    输入数值超出设置的范围或者输入非数字数值,为非法字符

    输入不合规或输入非法字符(&或者?这种,不属于数字的符号),元素不变化,回到上一值

  • 输入小数点

    小数点保留两位,采取四舍五入

  • 提交的方式

    回车 or 失焦

  • 按「上、下」按键可快速提交变化,配合shift按钮则一次+-10

需要注意的点

从头到尾只维护一个value?

最开始开发的时候,我想当然的就把组件设计成了直接受控的组件,即外部传入onChange和value,遵循单向数据流的方式,任何变动都会直接onChange,从头到尾只有一个value。但在测试的时候,发现这样做直接导致提交输入非法字符后回滚的情况失效,因为如果从头到尾只控制一个value,就没法记录上一次正确的提交了。为此我设计成了,组件内部维护一个变量叫innerVal,这个变量是我们每次onChange都会影响的值,不管你是乱输一通还是全部删光,innerVal都与你输入保持一致,也可以理解为,innerVal是用户会看到的自己实时输入的值。

回滚/回到上一值的原理

既然我们要维护两个value,其中一个(innerVal)是反映每次输入的变化,那另一个(外部传入的value)必然是反映提交的变化。我们要确保的是,外部传入的value始终处在一个合法的状态,如果innerVal处于非法的状态并且尝试提交,那value必须作为backup顶上。所以每次尝试提交的时候,我们都要校验innerVal是否合法,如果合法则调用外部传入的onChange,并将innerVal作为参数传入。

提交的时机

  • initial
  • onPressEnter
  • onBlur
  • onStep

越界时的处理

如果设置了min或者max,那min和max也需要放入非法的判断情况中,当innerVal越界的时候,自动回滚为上次提交的内容。

代码

utils

export const parseFloatPrecision = (num: number, precision = 12) => {
  return +parseFloat(num.toPrecision(precision));
};

组件

import React, { ChangeEvent, useCallback, useState, useEffect, memo } from 'react';

import { parseFloatPrecision } from '@/utils';

import './index.module.less';

export interface IInputNumber {
  defaultValue?: number;
  value?: number | string;
  min?: number;
  max?: number;
  precision?: number;
  disabled?: boolean;
  controls?: boolean;
  style?: React.CSSProperties;
  addonAfter?: React.ReactNode;
  onChange?: (value: string | number | null) => void;
  onPressEnter?: (value: number | string) => void;
  onStep?: (value: number) => void;
  onBlur?: (value: number | string) => void;
}

const InputNumber: React.FC<IInputNumber> = memo(
  ({
    value,
    defaultValue,
    min,
    max,
    addonAfter,
    precision = 2,
    disabled = false,
    controls = false,
    style,
    onChange,
    onPressEnter,
    onBlur,
    onStep,
  }) => {
    const [innerVal, setInnerVal] = useState(value);

    /**
     * commit时机
     * 1. initial
     * 2. onPressEnter
     * 3. onBlur
     * 4. onStep
     */
    const commit = useCallback(
      (commitVal: number) => {
        setInnerVal(commitVal);
        onChange && onChange(Number(commitVal));
      },
      [onChange]
    );

    const verifyBeforeSubmit = useCallback(
      (callback) => {
        const NUM_PATTERN = /^-?\d*\.?\d+$/;

        // step 1 validation
        if (
          !innerVal ||
          String(innerVal).length === 0 ||
          !NUM_PATTERN.test(String(innerVal)) ||
          (typeof min === 'number' && Number(innerVal) < min) || // 越界
          (typeof max === 'number' && Number(innerVal) > max) // 越界
        ) {
          setInnerVal(value);
          return;
        }

        let commitVal = parseFloat(String(innerVal)).toFixed(precision);

        // step2 保留两位小数点
        if (
          typeof min === 'number' &&
          typeof max === 'number' &&
          parseFloat(String(innerVal)) > min &&
          parseFloat(String(innerVal)) < max
        ) {
          commitVal = parseFloat(String(innerVal)).toFixed(precision);
        } else {
          if (typeof min === 'number' && parseFloat(String(innerVal)) <= min) {
            commitVal = String(min);
          } else if (typeof max === 'number' && parseFloat(String(innerVal)) >= max) {
            commitVal = String(max);
          }
        }

        // step3 commit value
        commit(Number(commitVal));

        // step4 执行回调
        callback && callback(commitVal);
      },
      [innerVal, min, max, commit, value, precision]
    );

    const handleChangeValue = useCallback((e: ChangeEvent<HTMLInputElement>) => {
      const { value: v } = e.target;
      setInnerVal(v);
    }, []);

    const handlePressEnter = useCallback(() => {
      verifyBeforeSubmit(onPressEnter);
    }, [verifyBeforeSubmit, onPressEnter]);

    const handleBlur = useCallback(() => {
      onBlur && verifyBeforeSubmit(onBlur);
    }, [onBlur, verifyBeforeSubmit]);

    const handleStep = useCallback(
      (type: 'up' | 'down', stepLength: number = 1) => {
        if (!onStep) return;

        // step1 获取prevCommitVal
        const nextVal = type === 'up' ? Number(innerVal) + stepLength : Number(innerVal) - stepLength;
        let finalVal: string | number = parseFloat(String(nextVal)).toFixed(precision);

        // step2 执行回调
        if (
          typeof min === 'number' &&
          typeof max === 'number' &&
          parseFloat(String(nextVal)) > min &&
          parseFloat(String(nextVal)) < max
        ) {
          if (type === 'up') {
            finalVal = parseFloatPrecision(nextVal);
          } else {
            finalVal = parseFloatPrecision(nextVal);
          }
        } else {
          if (typeof min === 'number' && parseFloat(String(nextVal)) <= min) {
            finalVal = String(min);
          } else if (typeof max === 'number' && parseFloat(String(nextVal)) >= max) {
            finalVal = String(max);
          }
        }

        onStep(Number(finalVal));
        commit(Number(finalVal));
      },
      [onStep, innerVal, min, max, commit, precision]
    );

    const handleKeyDown = useCallback(
      (e) => {
        const { key } = e;
        switch (key) {
          case 'Enter':
            handlePressEnter && handlePressEnter();
            break;
          case 'ArrowUp':
            e.preventDefault();
            if (e.shiftKey) {
              handleStep('up', 10);
            } else {
              handleStep('up');
            }
            break;
          case 'ArrowDown':
            e.preventDefault();
            if (e.shiftKey) {
              handleStep('down', 10);
            } else {
              handleStep('down');
            }
            break;
          default:
            break;
        }
      },
      [handlePressEnter, handleStep]
    );

    // initial commit
    useEffect(() => {
      setInnerVal(value);
    }, [value]);

    return (
      <div style={style} styleName={`${disabled ? 'input-wrapper input-disabled' : 'input-wrapper'}`}>
        <div className="row row-center">
          <input
            disabled={disabled}
            type="text"
            value={innerVal}
            defaultValue={defaultValue}
            min={min}
            max={max}
            onBlur={handleBlur}
            onChange={handleChangeValue}
            onKeyDown={handleKeyDown}
          />
          {controls && (
            <div styleName="input-controls">
              <div onClick={() => handleStep('up')} />
              <div onClick={() => handleStep('down')} />
            </div>
          )}
          {addonAfter && <span styleName="input-right-icon">{addonAfter}</span>}
        </div>
      </div>
    );
  }
);

export default InputNumber;

样式

.input-number {
  border-radius: 6px;
}

.input-wrapper {
  position: relative;
  width: 90px;
  height: 36px;
  background: #fff;
  border: 1px solid #ccc;
  border-radius: 6px;

  &:hover .input-controls {
    display: block;
  }

  .input-controls {
    position: absolute;
    right: 0;
    display: none;
    width: 20px;
    height: 100%;

    color: #00000073;
    text-align: center;
    background: #fff;
    border-left: 1px solid #d9d9d9;
    border-radius: 6px;
    transition: all 0.1s linear;

    div {
      align-items: center;
      justify-content: center;
      width: 18px;
      min-width: auto;
      height: 17px;
      margin-right: 0;
      overflow: hidden;

      font-size: 7px;
      background: #fff url("@/images/icon/design/icon-down.png") center center no-repeat;
      background-size: 11px;
      border-radius: 4px;

      &:hover {
        background: #d5d5d5 url("@/images/icon/design/icon-down.png") center center no-repeat;
        background-size: 11px;
      }

      &:first-of-type {
        transform: rotate(180deg);
      }
    }
  }

  .input-right-icon {
    padding: 0 4px;
    font-size: 12px;
    font-weight: 400;
    color: #999;
    vertical-align: middle;
  }

  input {
    width: 100%;
    height: 35px;
    margin-top: -1px;
    margin-left: 8px;
    line-height: 35px;
    background-color: transparent;
  }
}

.input-disabled {
  color: #b7b7b7;
  background: #f5f5f5;
}