表单规范化利器:自定义指令 v-hasNumber 实战与思考
前言
在实际业务中,我们经常需要对输入框进行数字限制:
- 禁止输入非数字字符
- 控制小数位数
- 禁止粘贴非法字符
- 去除前导零
- 保证数据最终格式符合接口要求
虽然我们可以通过 type="number" 或表单校验去限制,但这些方法存在一些缺陷,例如:
type="number"仍允许输入e、.、+、-等非预期字符- 表单校验只能事后提示,不能做到实时限制
- 多个输入框都需要同样逻辑,重复冗余
于是我决定封装一个 Vue 自定义指令 v-hasNumber,专门用于“在输入阶段就限制非法内容”,确保用户输入即合法,开发体验更高效。
自定义指令的设计目标
- 通用性强:可用于
<el-input>、<input>等输入组件 - 实时过滤:在用户输入时立即清理非法字符
- 灵活配置:支持整数、小数、小数位数、自定义格式
- 尽量不依赖组件内部事件(如
@input),避免重复代码
基本用法
<el-input v-hasNumber />
<el-input v-hasNumber="{precision:2}" />
<el-input v-hasNumber="{precision:0,min:1}" />
核心代码(示例):
// | 字段 | 类型 | 默认值 | 说明 |
// | ----------- | ------ | --------- | ---- |
// | `precision` | number | 8 | 小数位数 |
// | `maxInt` | number | 10 | 整数位数 |
// | `min` | number | -Infinity | 最小值 |
// | `max` | number | Infinity | 最大值 |
// | `zero` | boolean | true | 是否补零|
import type { App, DirectiveBinding } from 'vue';
export function hasNumber(app: App<Element>) {
app.directive('hasNumber', {
mounted(el, binding: DirectiveBinding) {
const input = el instanceof HTMLInputElement ? el : el.querySelector('input');
if (!input) return;
function getConfig() {
const value = binding.value || {};
const hasPrecision = Object.prototype.hasOwnProperty.call(value, 'precision');
return {
maxInt: value.maxInt ?? 10,
precision: hasPrecision ? value.precision! : undefined,
hasPrecision,
defaultPrecision: 8,
min: value.min ?? -Infinity,
max: value.max ?? Infinity,
zero: value.zero !== false,
};
}
// 拦截负号 & 显式 precision=0 时的点
input.addEventListener('keydown', (e) => {
const { precision, hasPrecision, min } = getConfig();
if (min >= 0 && e.key === '-') e.preventDefault();
if (hasPrecision && precision === 0 && e.key === '.') e.preventDefault();
});
// 只截断,不 clamp、不 toFixed
input.addEventListener('input', () => {
let val = input.value || '';
const { maxInt, precision, hasPrecision, defaultPrecision } = getConfig();
// 只保留数字和点
const isNegative = val.startsWith('-');
val = val.replace(/[^0-9.]/g, '');
// 拆整数/小数
let [intPart = '', decPart = ''] = val.split('.');
intPart = intPart.slice(0, maxInt);
const effPrec = hasPrecision ? precision! : defaultPrecision;
decPart = effPrec > 0 ? decPart.slice(0, effPrec) : '';
// 只要用户输过 ‘.’ 就保留点
let newVal = (isNegative ? '-' : '') + intPart;
if (val.includes('.')) {
newVal += '.';
if (decPart) newVal += decPart;
}
if (newVal !== input.value) {
const pos = input.selectionStart ?? newVal.length;
input.value = newVal;
try { input.setSelectionRange(pos, pos); } catch {}
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// blur 时再 clamp + toFixed(有 precision 则补零,否则自然显示)
input.addEventListener('blur', () => {
const { hasPrecision, precision, min, max, zero } = getConfig();
let val = input.value;
if (!val || val === '-' || val === '.') {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
let num = parseFloat(val);
if (isNaN(num)) {
input.value = '';
} else {
// clamp 范围
if (num < min) num = min;
if (num > max) num = max;
const truncateDecimal = (num, precision) => {
const [intPart, decPart = ''] = num.toString().split('.');
if (precision <= 0 || !decPart) return intPart;
return `${intPart}.${decPart.slice(0, precision)}`;
};
// 最终格式化
input.value = hasPrecision
? (zero ? num.toFixed(precision!) : truncateDecimal(num, precision!))
: num.toString();
}
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
});
}