Vue限制input仅能输入正整数或浮点数指令

7,152 阅读5分钟

通常限制一个input仅能输入指定字符我们会去监听其input事件,然后通过正则过滤掉非法字符。然而在vue中,仅仅修改input的value并不会同步到v-model上。在vue官方文档中有提到v-model其实是v-bindv-on合起来的语法糖,如下:

<input v-bind:value="value" v-on:input="$emit('input', $event.target.value)">

由此可以看出v-model的更新其实是基于监听input事件,但由于我们直接修改input的value并不会触发input事件,所以我们只须手工去触发一下这个事件即可。按照此逻辑可以先写出一个基础版的指令,如下:

Vue.directive('inputInt', {
  bind(el, binding, vnode) {
    let input = vnode.elm;
    input.addEventListener('input', () => {
      let oldValue = input.value;
      let newValue = input.value.replace(/[^\d]/g, '');
      // 判断是否需要更新,避免进入死循环
      if(newValue !== oldValue) {
        input.value = newValue
        input.dispatchEvent(new Event('input')) // 通知v-model更新
      }
    })
  }
})

需要注意的是,我们通过手工触发input事件会再次走到input事件监听中去,如此就成了死循环,所以此处需要判断是否需要更新v-model,进而确定是否需要手工去触发input事件。

以上代码看上去是ok的,但实际使用时会遇到一个很奇怪的现象:当用中文输入法时,尝试输入中文的字符确实会被过滤掉,但v-model并没有同步,再输入数字时又正常了(在element下若输入中文, v-model将永久不会再同步更新)。这个现象暴露出一个很明显的问题,当我们尝试输入中文时,每敲一个字母就会触发一次input事件,而我们期望的是在确认输入的时候才去校验。幸运的是浏览器提供了一组事件去处理这样的情况compositionstart compositionend

MDN释义如下:
compositionstart 事件触发于一段文字的输入之前(类似于 keydown 事件,但是该事件仅在若干可见字符的输入之前,而这些可见字符的输入可能需要一连串的键盘操作、语音识别或者点击输入法的备选词)。
当文本段落的组成完成或取消时, compositionend 事件将被触发 (具有特殊字符的触发, 需要一系列键和其他输入, 如语音识别或移动中的字词建议)。

我们可以借助这组事件去限制input事件的执行,忽略掉composition内的input事件。

input.addEventListener('compositionstart', () => {
  vnode.inputLocking = true;
});
input.addEventListener('compositionend', () => {
  vnode.inputLocking = false;
  input.dispatchEvent(new Event('input'));
});
input.addEventListener('input', () => {
  if (vnode.inputLocking) {
    return;
  }
  // ...
}

进一步思考一下,为了change事件能够同步获取到最终的值,我们在手工触发input后需要同时出发一个cahnge事件,加上input默认的change事件,一次输入可能会触发两次change事件(一个是用户输入的值,一个是修正后的值),而第一次change事件回传的值是未经处理的值,可能是不合法的,我们希望能去掉这种非法输入带来的代码负担,所以我们需要将默认的change事件屏蔽掉,统一由指令内部统一去触发。此时,我们可以在事件捕获阶段去掉默认的change事件。

一个完整的限制整数输入指令如下:

Vue.directive('inputInt', {
  bind(el, binding, vnode) {
    let input = vnode.elm;
    if (input.tagName !== 'INPUT') {
      input = input.querySelector('input');
    }
    if (!input) return;
    input.addEventListener('compositionstart', () => {
      vnode.inputLocking = true;
    });
    input.addEventListener('compositionend', () => {
      vnode.inputLocking = false;
      input.dispatchEvent(new Event('input'));
    });
    input.addEventListener(
      'input',
      e => {
        e.preventDefault(); // 阻止掉默认的change事件
        if (vnode.inputLocking) {
          return;
        }
        let oldValue = input.value;
        let newValue = input.value.replace(/[^\d]/g, '');
        if (newValue) {
          switch (binding.value) {
            case 'zeroBefore':
              break; // 数字随意输,不做处理,如 000013
            case 'zeroCan':
              newValue = Number(newValue).toString(); // 去掉开头0 正整数 + 0
              break;
            default:
              newValue = newValue.replace(/^\b(0+)/gi, ''); // (默认)去掉开头0 正整数
          }
        }
        // 判断是否需要更新,避免进入死循环
        if (newValue !== oldValue) {
          input.value = newValue;
          input.dispatchEvent(new Event('input')); // 通知v-model更新 vue底层双向绑定实现的原理是基于监听input事件
          input.dispatchEvent(new Event('change')); // 手动触发change事件
        }
      },
      true, // 在捕获阶段处理,目的是赶在change事件之前阻止change事件(非法输入在触发指令之前先触发了change,需要干掉)
    );
  },
});

至此,以上问题都以解决。当我们想要对更多情况做控制时,只需更改value的值即可。

扩展一个可以动态指定浮点数位数的浮点数指令

Vue.directive('inputFloat', {
  bind(el, binding, vnode) {
    let input = vnode.elm;
    if (input.tagName !== 'INPUT') {
      input = input.querySelector('input');
    }
    if (!input) return;
    input.addEventListener('compositionstart', () => {
      vnode.inputLocking = true;
    });
    input.addEventListener('compositionend', () => {
      vnode.inputLocking = false;
      input.dispatchEvent(new Event('input'));
    });
    input.addEventListener(
      'input',
      e => {
        e.preventDefault(); // 阻止掉默认的change事件
        if (vnode.inputLocking) {
          return;
        }
        let oldValue = input.value;
        let newValue = input.value;
        newValue = newValue.replace(/[^\d.]/g, '');
        newValue = newValue.replace(/^\./g, '');
        newValue = newValue
          .replace('.', '$#$')
          .replace(/\./g, '')
          .replace('$#$', '.');
        const decimal = Number(binding.value) || 2; // 默认两位小数
        const reg = new RegExp(`^(\\-)*(\\d+)\\.(\\d{${decimal}}).*$`);
        newValue = newValue.replace(reg, '$1$2.$3');
        if (newValue) {
          let arr = newValue.split('.');
          newValue = Number(arr[0]) + (arr[1] === undefined ? '' : '.' + arr[1]); // 去掉开头多余的0
        }
        // 判断是否需要更新,避免进入死循环
        if (newValue !== oldValue) {
          input.value = newValue;
          input.dispatchEvent(new Event('input')); // 通知v-model更新
        }
      },
      true,
    );
    // input 事件无法处理小数点后面全是零的情况 因为无法确定用户输入的0是否真的应该清除,如3.02。放在blur中去处理
    input.addEventListener('blur', () => {
      let oldValue = input.value;
      let newValue = input.value;
      if (newValue) {
        newValue = Number(newValue).toString();
      }
      // 判断是否需要更新,避免进入死循环
      if (newValue !== oldValue) {
        input.value = newValue;
        input.dispatchEvent(new Event('input')); // 通知v-model更新
      }
    });
  },
});

扩展到其他UI框架

其实扩展到其他框架很简单,整个指令对元素的依赖仅仅表现在节点的获取上,在不同的框架下更改获取input节点相应的代码即可。如在element下获取节点的代码为:

let input = vnode.elm.children[0];

在线预览 以上,tks~