input格式化输入框内容后定位错位问题

  • 最近公司有一个 汇率换算的小项目 用的 nuxt 服务端渲染,UI用的是饿了么的UI框架,总共 7 个输入框,数据是根据一个输入框中的值进行双向绑定换算的,这些都不是重点。
  • 需求是用户实时输入的时候 input输入框中的值是实时改变的,但是在实时改变的时候,由于数据是千分位格式化了的,当输入的时候,数据就会格式,导致光标会错位,从而导致输入错误
  • 出现这个BUG的时候,首先考虑到是因为格式化的原因,然后去格式化操作哪里debug数据,最后发现 数据格式化并没有问题,问题在于实时输入数据,然后又格式化数据,然后就是一顿 爆 百度 谷歌 必应 ,最后发现一个大佬的 文章,这里先贴出来

这段时间进行项目升级工作,期间遇到一个需求:针对十六进制数输入框(输入框内只能输入十六进制数),输入时在每一个字节间插入空格,便于阅读。基本效果如下图所示。

ios input光标高度 ios输入框光标错位_ios input光标高度

这个功能倒不是很难,只用将原始数据格式化后重新设置到输入框中即可。但是,这里却有一个问题:如果从数据中间某处开始编辑,编辑一次后光标就会跳转到最右,在修改时体验不是很好。

ios input光标高度 ios输入框光标错位_ios input光标高度_02

这个问题的原因也很容易理解,当我们把光标移到中间部分开始编辑时,数据的内容会因重新格式化导致出现差异,此时把数据设置到输入框中,浏览器无法得知光标应该定位到哪里,所有就只好设置到最右边。

想要修正这个问题,我们就得根据编辑时的光标位置计算出编辑并格式化之后期望光标出现的位置,然后手动设置。这里主要有两个问题:

  1. 如何获取/设置输入框光标的位置?
  2. 如何计算期望的位置?

获取/设置光标的位置

浏览器给我们提供了输入框的选中控制 API:

  • setSelectionRange():设置输入框选中区域,只有起始和结束在同一个位置,就可以设置光标位置。
  • selectionStart:选取区域的起始位置。
  • selectionEnd:选取区域的结束位置。

浏览器的支持情况还不错。

ios input光标高度 ios输入框光标错位_格式化数据_03

计算光标的期望位置

有了浏览器提供的 API,我们现在已经可以获取和设置光标的位置,那么如何计算出光标的期望位置呢?

起初的我设想的是在 keydown 事件中,获取当前光标的位置。如果是键入,那么光标位置 +1,如果是删除,那么光标位置不变
但是,并不是这么简单,应该格式化的原因我们需要把空格考虑进去,并不是每次键入都需要 +1,也并不是每次删除都不变。考虑到这个思路需要考虑的因素较多,最终放弃。

转变思路后,我开始想:我是如何判断光标的位置呢?我是根据字符来判断的。当输入一个字符后,光标应该在此字符的后面,无论进行了什么样的格式化操作;同理,当删除一个字符后,光标应该在此字符前一个字符的后面。
这个思路就比较简单,减少了可能出现的变量。

具体计算规则

  1. 监听 keydown 事件,获取当前光标的位置。
  2. 根据输入框的内容和光标位置,计算出当前光标位置的前一个字符的下标(不计算空格)。
  3. 判断用户按了哪个键,是退格还是[0-9a-f]。如果是退格,光标应该定位在前一个字符,否则定位到后一个字符,从而计算出期望光标定位在哪个字符后面(不计算空格)。
  4. 格式化数据,根据字符位置计算出光标位置(计算空格)。

这里需要注意几点:

  1. 当进行选中删除操作时,光标应该不需要前移。
  2. 其他输入框不支持的字符需要处理,例如:ctrl等功能键。
    scss
.container {
  text-align: center;
  padding-top: 46px;
}

#input {
  font-variant: tabular-nums;
  box-sizing: border-box;
  margin: 0;
  display: inline-block;
  padding: 4px 11px;
  width: 300px;
  height: 32px;
  font-size: 14px;
  line-height: 1.5;
  color: rgba(0, 0, 0, 0.65);
  background-color: #fff;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  transition: all .3s;
  
  &:focus {
    border-color: #40a9ff;
    outline: 0;
    box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
    border-right-width: 1px !important;
  }
}

HTML

<div class="container">
  <input type="text" id="input">
</div>

Babel

const input = document.querySelector('#input');
  let expectCharIndex; // 期望光标所处位置前一个字符的下标(不包括空格)

  input.onkeydown = function(e) {
    const curValue = input.value; // 当前值
    console.log('keydown 阶段,此时 value 为:', curValue || '空');
    const selectionStart = input.selectionStart; // 当前选中的区域起始位置
    const selectionEnd = input.selectionEnd; // 当前选中区域结束位置
    console.log('当前光标位置:', selectionStart);

    const code = e.keyCode || e.which || e.charCode;
    const preCharIndex = getPreviousCharIndex(curValue, selectionStart);
    console.log('当前光标前一个字符的下标:', preCharIndex);

    // 计算编辑之后光标前一个字符
    if (code === 8) {
      if (selectionStart !== selectionEnd) { // 选中区间删除,此时光标应该不前移动
        expectCharIndex = preCharIndex;
      } else {
        expectCharIndex = preCharIndex - 1;
      }
    } else {
      expectCharIndex = preCharIndex + 1;
    }

    console.log('格式化后,期望光标前一个字符的下标:', expectCharIndex);
  };

  input.onkeyup = function() {
    const value = formatValue(input.value);
    console.log('keyup 阶段,此时 value 为:', value);
    input.value = value;

    const cursorIndex = getCursorIndex(value, expectCharIndex);
    console.log('期望光标的位置:', cursorIndex);
    console.log('=============================');

    // TODO 增加判断,如果是功能键等则不设置
    setTimeout(() => {
      input.setSelectionRange(cursorIndex, cursorIndex);
    }, 10);
  };

  /**
   * 格式化输入框内容,每两个字节中间插入一个空格。
   *
   * @param {string} value - 输入框内容
   * @return {string}
   */
  function formatValue(value) {
    if (!value) {
      return value;
    }
    value = value.replace(/\s*/g, '');
    if (value.length <= 2) {
      return value;
    }
    const arr = [];
    for (let i = 0; i < value.length - 1; i += 2) {
      arr.push(value.charAt(i) + value.charAt(i + 1));
    }
    if (value.length & 1) {
      // 如果是奇数,还需要添加末尾的数
      arr.push(value.charAt(value.length - 1));
    }
    return arr.join(' ');
  }

  /**
   * 根据输入的内容和光标位置计算前一个字符的下标(不计算空格)
   *
   * @param {string} value - 格式化后的输入框内容
   * @param {number} cursorIndex - 当前光标的位置
   * @return {number} 光标前一个字符的下标,如果前面没有字符,则返回 -1。
   */
  function getPreviousCharIndex(value, cursorIndex) {
    let subStr = value.substring(0, cursorIndex);
    subStr = subStr.replace(/\s*/g, '');
    return subStr.length - 1;
  }

  /**
   * 根据格式化之后的值和期望光标位置的前一个字符下标,计算出光标的位置
   *
   * @param {string} value - 格式化之后的值
   * @param {number} charIndex - 光标前一个字符的下标
   * @return {number} 光标的位置
   */
  function getCursorIndex(value, charIndex) {
    if (charIndex < 0) {
      // 如果期望光标前面没有字符,则光标位置为 0
      return 0;
    }
    // 计算格式化之后,字符所在的位置
    let curCharIndex;
    let count = -1;
    for (let i = 0; i < value.length; i++) {
      if (value.charAt(i) !== ' ') {
        count += 1;
      }
      if (count === charIndex) {
        curCharIndex = i;
        break;
      }
    }
    return curCharIndex + 1;
  }
  • 但是采用这种方式的,就得修改数据格式化得方法,而且在改变光标位置得时候,还是会格式化数据,担心给公司在写一个 BUG最后就没采用这种方法。

我最后采用得方法是,当光标进入得时候,清除数据格式化,然后在进行计算,等失焦得时候,在格式化回来,并且实现了 用户实时输入,其他输入框得内容实时变化,而当前点击得输入框光标正常使用

v-on:input="currencyInput" v-on:focus="currFoucs" v-on:blur="currFormat"
v-on:input //实时处理数据得更新
 v-on:focus //鼠标聚焦 清除当前数据格式化
 v-on:blur  //当失焦得时候在格式化数据

大家可以去了解这三个事件得区别,因为是各个国家汇率得计算所以就没采用计算属性了,而是通过一个国家得汇率,进行汇率转换得。