做一个 H5 页面:

  • 让用户填写一些信息,包含验证码等数字。
  • 禁止输入数字以外的字符。
  • 限制输入长度,比如不能超过6位。
  • 最好能直接调起数字键盘,不要让用户手动切换。

你会怎么做?

Web 输入框之殇

难以调起的数字键盘

常规做法自然是使用 <input> 输入框。

我们可以很容易实现限制字符长度,输入时移除不合法的字符:

<input type="text" maxlength="6" onkeyup="value = value.replace(/[^\d]/g, '')">

显然,现在还不会默认调起数字键盘,因为输入框的类型是 text

把输入框的类型改成 number 稳吗?

实际效果是这样的:

  • Android 调起数字键盘,同时允许切换到拼音 / 英文 / 标点符号键盘。😅
  • iOS 调起英文键盘,需手动切换到数字键盘。😨

那么 iOS 有办法调起数字键盘吗?

答案是肯定的:iOS 支持在 text 类型输入框,且使用 pattern 属性限制只能输入数字的情况下,默认调起数字键盘。

<input type="text" pattern="[0-9]*">

实际效果是这样的:

  • Android 调起英文键盘(不支持)。😨
  • iOS 调起数字键盘,且不能切换英文 / 拼音等键盘。😅

有同学可能问:难道不能对 Android 和 iOS 用不同的代码实现吗?

<!-- Android -->
<input type="number">
<!-- iOS -->
<input type="text" pattern="[0-9]*">

呃……虽说也就一个判断的事儿,问题是这里面其实还有不少其他副作用。

浏览器眼中的数字

思考一个问题:验证码是一个数字吗?

准确地说,我们在业务中需要用户输入的,其实都是:由数字 0-9 构成的字符串

这和浏览器理解的 <input type="number"> 式的数字输入,完全不是一回事儿 —— 浏览器眼中的 number 是数学上有意义的数。

我举几个例子:

  • 可以输入 e —— 数学中的常数,约等于 2.71828,也可以输入 + / -
  • 0 开头、包含多个 .、包含多个 e 都是非法数,值会变成空字符串。
  • 无法用 maxlength 限制长度,因为数没有字符长度的概念。
  • 浏览器可能在输入框的尾部提供“步进箭头”。

所以,对于 Android 设备来说,<input type="number"> 仍不是理想方案。

而另一边,面向 iOS 的 <input type="text" pattern="[0-9]*"> 方案问题也很明显:这个数字键盘没有小数点,也没有办法调出小数点。这就无法满足金额输入的需求。

我们不得不继续探索,最好能有一个纯 web 方案,即使在无原生增强(hybrid)的情况下,也能提供较好的数字输入体验。

抛弃系统键盘

为了能总是“调起正确的键盘”,并在 Android 和 iOS 下表现一致,我们首先参考微信小程序的原生键盘设计,在 web 下实现了包含4种风格的键盘组件:基础数字键盘,带小数点的数字键盘,身份证键盘以及金额键盘。

随之而来的问题是,这些键盘应该如何与 web 原生元素 <input> 联动?

在用户触摸输入框时,拦截系统键盘,取而代之以在 web 侧实现的键盘 —— 这个操作的权限对网页来说过大了,纯网页恐怕无法做到。

必须在 web 侧实现一个接近 <input> 体验的“虚拟输入框”和数字键盘组件联动。

我们不可能为 text 类型实现一个支持中 / 英文 / 符号输入的键盘,所以 text 类型的输入仍会由系统键盘完成。

这意味着虚拟输入框的体验必须有一个比较高的标准:

  • 虚拟输入框和 <input> 放在一个页面上不会违和,甚至能以假乱真。

  • 虚拟输入框和 <input> 交替聚焦(web / 原生键盘接力弹出),要避免不协调,或者用户同一时间看见两个“键盘”的尴尬。

  • 虚拟输入框过于接近页面底部时,页面要能像使用 <input> 那样被键盘“顶起”。

能实现吗?当时小朋友脑袋上冒了特别多的问号。

造一个输入框

最终我们完全用 web 技术再造了一个(专用于输入数字的)虚拟输入框组件。

整个输入框的结构都由 <div><span> 构成。

光标

组件内部维护一个“光标位置”状态,是模拟光标移动操作的基础。

虚拟输入框中的每个字符都包含左 / 右两块热区,当触摸左侧时,说明用户期望把“光标”移动到这个字符的前面,反之则是后面。

外层容器负责监听滑动手势,模拟光标微调操作。

聚焦和失焦

聚焦的逻辑很简单。用户触摸输入框时,根据触摸点初始化”光标“位置,弹出数字键盘与虚拟输入框联动。

失焦的逻辑就会稍微复杂一些了。

我们先梳理一下输入框需要在哪些情况下失去焦点:

  • 用户触摸输入框之外的区域时
  • 用户滚动页面时(即使触摸点落在输入框区域内)
  • 页面切换时

首先,监听 <body> 的触摸、滑动事件,如果手势的目标元素不是虚拟输入框 / 数字键盘(及其后代元素),应该失去焦点。

if (!el.contains(target) && !keyboardEl.contains(target)) {
  this._blur()
}

接着处理页面切换。前端路由一般使用 hash 模式实现,监听 hash 变化基本可以满足需求了。

window.addEventListener('hashchange', this._blur, false)

自动上推页面

如果输入框(的底边)比数字键盘(的顶边)距离视区顶部的距离更远,说明输入框下方的空间不够,需要对根元素(相当于页面)做一个向上偏移。

在偏移量的计算上,还需要考虑兼容 iOS 底部安全区(数字键盘高度的变化),以及在输入框和键盘之间留一点 buffer。

if (keyboardTop < bottom + cursorSpacing + safeAreaInsetBottom) {
  const translateY = -(bottom - keyboardTop + cursorSpacing + safeAreaInsetBottom)
  rootEl.style.transition = 'all .2s ease-out'
  rootEl.style.transform = `translateY(${translateY}px)`
}

防止“双键盘”

设想这样一种场景:页面上同时存在使用系统键盘的 <input>,和使用 web 内的数字键盘的虚拟输入框。这两种输入框交替聚焦会造成同一时间存在两个键盘的问题 —— 必须想办法让 web 键盘在系统键盘收起后再弹出。

不同手机的系统键盘,收起动画不尽相同,很难用一个固定的毫秒延迟解决问题。

目前的解决方案是:在虚拟输入框聚焦前,轮询视窗高度和屏幕高度的比例。若视窗高度不足屏幕高度的三分之二,则认为可能有系统键盘尚未收起,延迟聚焦。不是很严谨的判断,但基本解决了问题,即使偶现视觉 bug 也不明显。

const min = window.screen.height * 0.66
if (window.innerHeight < min) {
  let time = 0
  let interval = setInterval(() => {
    if (window.innerHeight > min || time >= 500) {
      clearInterval(interval)
      this._focus()
    } else {
      time += 50
    }
  }, 50)
}

降低使用成本

为了避免使用者需要费时间思考用哪个输入框,或者在需要调整样式时做重复工作,有必要再封装一下。

最终,我们把 web 原生与虚拟输入框装进了同一个组件 —— Input 中,共享清除按钮、提示文本实现,提供高度一致的 Props 和自定义事件。对于“键盘”,组件也会依据不同的输入框类型(type 属性),切换方案。

<!-- 文本类型:web 原生输入框 + 系统键盘 -->
<we-input type="text" />
<!-- 数字类型:web 虚拟输入框 + web 数字键盘 -->
<we-input type="number" />

后记

说了这么多优点,这个数字输入方案有缺点吗?当然有了。

比方说不支持系统键盘的“短信验证码填充”能力,还有一些细节仍有优化空间…… 然而 前端实现往往做不到“我全都要”,在输入这件事上我们还是选择了:给用户一个对的键盘。

最近我们正在一个面向第三方 app 开发的 hybrid 项目中使用它,全面用于姓名、手机号、身份证、验证码等各种字段的输入,目前看来效果还可以。