在很多业务场景中,产品要求我们实现一个交互:用户输入数字时,将数字按千分位分隔。一般我们会采用,
作为分隔符,例如 100,000,000
,但是react或者说web没有原生的方法实现这个效果,因此,我们必须自己实现这一需求。
我接到需求之后,去市面上找了一圈儿,以antd的rc-input-number为例,核心代码相对比较少一些,功能覆盖了我的需求,但是很可惜,在某些处理细节上没有覆盖全(例如在逗号后面删除)。而外网搜索得到的一个被多次提及的组件input-number-format,则功能太过复杂,代码量巨大,超出了我的承受能力。我最终决定自己实现一个组件,目标是以最少的代码量,直接了当的(不考虑其他场景下的通用性)实现需求。
接下来,我就简单的阐述我的实现过程。
接口设计
新的组件应该与原生组件的接口风格保持一致,在无法直接满足的情况下,也应该变相支持。因此,我在设计时,只用一个input,来完成所有交互过程。
<InputNumber value={value} onChange={e => setValue(e.target.value)} placeholder="请输入数字" max={100} min={0} inputRef={el} />
完成开发之后,我发现input[type=number]支持step属性,但是如果需要支持微调功能,就必须自己再模拟两个按钮,导致复杂html结构。所以,最终我只能放弃支持step属性。inputRef则是代替原来的ref属性,因为react会默认把ref绑定到组件上,所以只能找替代方案。还有一个点,由于格式化数据输入框是使用input[type=text]模拟的,因此,在手机上,无法直接唤起数字键盘,也是一大损失。
数字的格式化
用户在输入的过程中,需要实时的将数字按千分位分隔符进行格式化。如果你用过antd的组件,它会要求你传入一个formatter和parser来决定怎么做格式化,以及接收值之前怎么处理。我为了减少代码,就写死用,
进行分隔。效果如下:
你可以看到,在第一次输入时,它会自动格式化。同时支持删除时格式化。
为了在展示时是格式化的,而读取时是正常数字,必须做一个类型转化过程。展示时,先格式化;读取时,是反转为数字的值,通过onChange抛出。
function num2str(num) { // 完成数字到字符串格式化 } function str2num(str) { // 将格式化后的字符串转化为数字 } function InputNumber(props) { const { value, onChange } = props const handleChange = (e) => { const num = str2num(e.target.value) onChange(num) } const str = num2str(value) return <input value={str} onChange={handleChange} /> }
但这样会有一个问题,就是我们的onChange属性,只能直接接收数字,而无法接受e。于是我做了一个特殊处理,通过Proxy代理e,让外部在读取e.target.value时,读到的是反转为数字的值。
const responseChange = (e, num) => { const event = new Proxy(e, { get(_, key) { const value = _[key] if (key === 'target') { return new Proxy(value, { get(_, key) { const value = _[key] if (key === 'value') { return num } else { return typeof value === 'function' ? value.bind(_) : value } }, }) } else { return typeof value === 'function' ? value.bind(_) : value } }, }) onChange(event) }
然后再替换handleChange为:
const handleChange = (e) => { const num = str2num(e.target.value) responseChange(e, num) }
这样就解决了外部读取e.target.value时,读取真正需要的数据的问题。
非受控
上面InputNumber的实现,是完全受控组件的写法。但是,默认情况下,我们也应该支持非受控组件(即不传onChange来更新value)。
<InputNumber defaultValue={30} min={0} max={100} />
这种情况下,我们也要支持格式化展示输入数字。那通过上面的实现就不可以了。此时,必须借助一个内部状态来完成。为了同时兼容受控与非受控,代码结构要做很大的变化。
function InputNumber(props) { const { defaultValue, value, onChange } = props const isControlled = 'value' in props const [text, setText] = useState(() => !isControlled ? num2str(defaultValue) : '') useEffect(() => { if (isControlled) { const next = num2str(value) setText(next) } }, [value]) const handleChange = (e) { ... if (onChange) { responseChange(e, num) } if (!isControlled) { setText(e.target.value) } } return <input value={text} onChange={handleChange} /> }
在用户输入的时候,即使是非受控状态,也可以通过text这个状态值来实时的改变内容。这样,就算非受控情况下,也能做到格式化数字。
光标位置
当用户在数字串中间进行输入时,如果不做处理,react受控组件会直接将光标放在末尾,这是我们不希望看到的。我们希望在编辑过程中,光标位置能准确放在我们需要输入的地方。
看上去很简单,实际上却很复杂,首先,我们需要获取input DOM元素,对DOM进行光标操作。光标位置不是永远只前进一格,当格式化过程中多出一个,
时,光标前进两格,当删除过程中少了一个,
时,光标倒退两格。而由于react的垃圾处理,我们必须自己计算修改后光标的位置(即使不存在多出或少掉一个,
的情况下,光标的位置都需要我们自己计算,因为react总会让受控组件的光标放到末尾)。
const { selectionStart: cursor } = e.target const resetCurr = () => { // 输入英文字符等,react会主动把光标移动到最后,因此,这里要做一个主动回调 requestAnimationFrame(() => { el.current.setSelectionRange(cursor - 1, cursor - 1) // -1 是因为我们不需要这个被输入的英文 }) }
由于react在处理受控组件时,是异步处理(react的scheduler),所以,我在这里使用了requestAnimationFrame来延后设定光标位置(但是,这是不保险的,我们不能绝对保证react内部的处理在一帧内完成)。这样,当react把光标移动到末尾之后,我又把光标移动了回来。
基于同样的原理,我们需要计算每次值被修改前和修改后的一些信息,从而得到输入完成时,光标应该放在哪个位置。
const focused = useRef() const handleChange = (e) => { focused.current = [e.target.value, e.target.selectionStart] }
// 定位光标位置 useLayoutEffect(() => { if (!focused.current) { return } const [prevText, cursor] = focused.current const prevCommaCount = prevText.split(',').length - 1 const currentCommaCount = text.split(',').length - 1 const offset = currentCommaCount - prevCommaCount const curr = cursor + offset const pos = curr < 0 ? 0 : curr el.current.setSelectionRange(pos, pos) }, [text])
第二段代码就考虑到了当改变值后,是否会多出或少掉一个,
。基于useEffect的特性,我可以很好的处理这个逻辑,useEffect具有watch的效果,这一点确实让hooks让人着迷。
总之,经过一些记录、监听、比较,我可以准确的定位光标了。但是,由于我使用了ref,那么,我的这个组件,就无法实现跨端(RN)使用了。这也是一个不可避免的问题,不过暂且不考虑这么深远吧。
小数点
小数点看上去简单的不行,实际上是一个巨大的难点。由于当我们输入小数点时,实际的表达值,是相同的,这会使得我们经过格式化后,丢掉小数点。例如 1,234.
和 1,234
的实际值都是 1234
,这就会导致我们的格式化函数num2str格式化之后只能得到 1,234
而无法得到 1,234.
这就是一件极其麻烦的事。这该怎么办呢?
这时,需要写一段特殊逻辑来覆盖这种情况。由于我们实际渲染的结果,是状态值text,那么,即使在受控组件情况下,我们也可以不通过修改value而直接更新界面。所以,当我们发现末尾是小数点的时候,直接不要调onChange就好了,直接setText来修改界面。
if (text === next + '.') { // 删除了一个小数点 setText(next) } else if (next[next.length - 1] === '.' && cursor === next.length) { // 在末尾处理时,处理后末尾为小数点 setText(next) }
也就意味着,实际上,即使是受控组件,InputNumber组件也有自己的内部临时态,而这个临时态外部无法直接读取(可通过inputRef读取)。这就是设计巧妙的地方,我们一般认为一个输入框受控时,value是什么,内部展示的才是什么,而万万没想到,某些情况下,内部展示的可以不一样,但是对应相同的一个value。
而且,我对小数点后面的值进行了处理。一般来说,我们在整数部分千分位分隔符是从个位到十位方向,而小数部分则相反,从0.1往0.01方向分隔。输入小数点本身,和格式化逻辑有冲突,因此,必须做特殊处理。还有一个点,即用户可能在一串已经有小数点的数字中间,再次输入小数点,这种情况下,我的处理方式是,把小数点迁移到用户当前输入的位置,这种设计我认为是用户体验友好的。最后,当用户在输入框中,只输入了一个.
之后,他实际上将获得 0.
而非 .
本身,因此也做了替换(包含用户输入 -.
)。
0 & 00
当输入的数字实际代表的值为0时,或者当输入的小数末尾为0时,都和格式化逻辑有冲突。格式化过程中,会将小数末尾的00去掉,这就导致我们无法输入.01小数。同时,由于-0实际为0,所以导致我们无法输入-0.1这样的值。如何解决这类问题呢?答案还是:特殊处理。
另外,当用户输入的时候,数字开头是00时,也应该根据整数还是小数进行清除。
精度
JS的数精度有限,无论是整数还是小数,都有17位限制,这导致我们无法输入一个比17个位置更多的数(变成e数)。因此,我支持precise属性来让开发者决定,当前这个输入框是否可以用字符串表达数字。一旦开启字符串表达数字之后,外部接收到的就会是字符串,而非真实的数字。
结语
看上去要实现一个数字输入框非常简单,实际上却有很多坑点等着我们去踩。很多细节都是在不断的尝试和发现中补全,很难在一开始就全部想到。我已经将代码在github开源,你可以通过这里阅读源码。如果你发现还有什么细节没有被考虑在内,可以及时与我反馈。
2021-04-04 7385 React
antd 3.x的版本倒是还支持在逗号后面删除的.
各种问题,比如在逗号后面删除,只是删除了一个逗号,小数点后面还可以打小数点等等