史上最彻底的解决让前端头大的问题,数字输入限制

63 阅读2分钟

表单规范化利器:自定义指令 v-hasNumber 实战与思考

前言

在实际业务中,我们经常需要对输入框进行数字限制:

  • 禁止输入非数字字符
  • 控制小数位数
  • 禁止粘贴非法字符
  • 去除前导零
  • 保证数据最终格式符合接口要求

虽然我们可以通过 type="number" 或表单校验去限制,但这些方法存在一些缺陷,例如:

  • type="number" 仍允许输入 e.+- 等非预期字符
  • 表单校验只能事后提示,不能做到实时限制
  • 多个输入框都需要同样逻辑,重复冗余

于是我决定封装一个 Vue 自定义指令 v-hasNumber,专门用于“在输入阶段就限制非法内容”,确保用户输入即合法,开发体验更高效。

自定义指令的设计目标

  1. 通用性强:可用于 <el-input><input> 等输入组件
  2. 实时过滤:在用户输入时立即清理非法字符
  3. 灵活配置:支持整数、小数、小数位数、自定义格式
  4. 尽量不依赖组件内部事件(如 @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 }));
      });
    }
  });
}