从一个“不能输负号”的数字输入框说起:Web Component 数字输入组件重构实录
背景
拿到需求时,因为工期还比较宽松,官网开发,我又只做其中一个组件,框架又没有定。
我决定使用原生开发,并封装为Web Component以适配任何框架(如果不能适配,说明框架有问题)。其中就有一个数字输入框带拉杆的,数字输入框和拉杆这两个东西,原生组件都有。
于是在我的要求下,ai很快给我封装了一个还可以的东西,不过后面ui又去掉拉杆了。
临近发布,要合代码了,同事才发现这个输入框有点儿问题!
![]()
起点:一段“差不多能用”的代码
这里就不赘述Web Component的开发了,因为确实很简单,看代码就行了。
这是我最初写的 NumInput 组件(为简洁省略部分 CSS):
export class NumInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// ... 模板里用了 <input type="number" steop="0.1">
}
_onNumberInput(event) {
const newValue = this.clamp(event.target.value, this.min, this.max);
this.setAttribute('value', newValue);
this._dispatchEvent();
}
}
功能上:
- 支持
label、unit、min、max、step - 聚焦有高亮效果
- 值变化会派发
value-changed事件 - 还有后缀单位
看起来没问题?直到女同事问我:“为什么我输 - 没反应?”
我压根儿没想过手输,因为这个组件最开始的时候,还是带拉杆的,纯鼠标操作一点儿问题没有。
当然,现在UI变了。
问题一:type="number" 不让你输 “-” 和 “.”
首先,这不是一个bug,嗯。 我仔细研究了一下原生的数字框的机制。
1 实时校验,你每输入一个字符都会校验。 2 只能输入数字相关的,0-9 . -
那么问题来了,为什么她无法输入“-” 和 “.”呢?
实际上,并不是无法输入,只是时机和位置不对。 如果输入框中已经有一个数字1了,这个时候,你就可以在这个数字前面输入一个 “-”,在它后面输入一个“.”,这两种情况(-1和1.)都是合法的。
其余情况都是不合法的,所以无法输入。
结论:type="number"校验过于严苛,鼠标操作足矣,不适合手动输入。
重构第一步:放弃 type="number",拥抱 type="text"
使用text输入框,意味着之前数字框有的功能,我现在也都要也有,这是这个手动输入的校验规则要自定义。
我改成了:
<input type="text" inputmode="decimal" />
inputmode="decimal"能让移动端弹出带小数点的数字键盘,体验不降反升。
但光改类型不够,得自己控制输入内容。
宽松过滤,只拦非法字符
在 input 事件中,我只做一件事:
_onTextInput(e) {
let val = e.target.value;
val = val.replace(/[^0-9.\-+]/g, ''); // 只留数字、点、正负号
// 再处理符号位置、小数点数量...
e.target.value = val;
}
关键原则:
输入过程中,只过滤,不校验。
允许用户输-、.5、-12.,这些“中间状态”必须保留。
重构第二步:什么时候才该“认真”校验?
要保留用户输入的字符,又要在结束后校验,一般可能会想到节流,我觉得太麻烦了,不是指实现节流麻烦,而是节流这个逻辑本身,会一直后延,让js很麻烦。
所以,怎么判断:输入结束了?
我定义了两个“结束信号”:
- 失焦(
blur) - 按下回车(
Enter) 刚开始没想到这个,直到我输了数字没有反应,习惯性地回车了一下。
在这两个时机,调用同一个函数 _finalizeInput():
_finalizeInput() {
const raw = this.numberEl.value.trim();
// 如果是中间状态(如 '-'),不处理
if (raw === '' || raw === '-' || raw === '.') return;
let num = parseFloat(raw);
if (isNaN(num)) {
// 无效?回退到上次合法值
this.numberEl.value = this.getAttribute('value') || '';
return;
}
// clamp 到 [min, max]
num = Math.min(Math.max(num, this.min), this.max);
// 修正浮点精度(关键!)
num = this._roundToStepPrecision(num, this.step);
this.numberEl.value = String(num);
this.setAttribute('value', num);
this._dispatchEvent(); // 派发的是数字,不是字符串!
}
问题二:0.1 + 0.2 ≠ 0.3?
-
0.1 + 0.2→ 显示0.30000000000000004JavaScript 的浮点精度问题是老朋友了。
但用户不关心这些,他们只看到“我 step=0.1,怎么变出一串小数?”
解法:按 step 的小数位数四舍五入
_roundToStepPrecision(value, step) {
if (Number.isInteger(step)) return Math.round(value);
const decimalPlaces = step.toString().split('.')[1]?.length || 0;
const factor = 10 ** decimalPlaces;
return Math.round(value * factor) / factor;
}
-
step=0.1→ 保留 1 位 →0.30000000000000004→0.3 -
step=0.01→ 保留 2 位 →0.13 -
step=1→ 整数 →4
所有赋值路径(手动输入、上下键、外部设置)都走这个修正,彻底告别脏数字。
监听一下回车作为“确认”。
这里直接不仅走了失焦的逻辑,还主动失焦,避免二次“失焦”。
this.numberEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this._finalizeInput();
this.numberEl.blur(); // 自动失焦,统一交互
}
});
其他细节打磨
-
修复 typo:
steop="0.1"→step="0.1"(别笑,真有人写错) -
移除无用代码:原始代码里声明了
rangeEl但没用,删掉 -
事件传数字:
value-changed的detail.value是number类型,不是字符串 -
外部设置值也修正:
setAttribute('value', '0.30000000000000004')会自动转成0.3
最终效果
✅ 自由输入 -、.、-12.
✅ 按 ↑/↓ 按 step 精确增减
✅ 按 Enter 或失焦自动校验+修正
✅ 支持 min/max 限制
✅ 移动端弹出数字键盘
✅ 事件传出干净的数字
✅ 完全 Web Component,零依赖
结语
这个组件最终代码比最初长了近一倍,但用户体验提升是质的飞跃。
有时候,看似简单的功能,深挖下去全是坑。
但正是这些“小细节”,决定了产品是“能用”还是“好用”。
最后,女同事看了一眼说:“这个输入框终于不抽风了。”
我笑了笑,没告诉她,我让AI改了四版。
(完)