前言
前一阵子某个业务上需要用到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;
}