前述

最近负责的项目收到了一个用户的投诉,意思是在进入phone number的时候没有收到短信已经发送的通知,于是我们的设计干脆一不做二不休重新制作了sms页面以及要求添加自动填充验证码的功能

web OTP API

自动填充功能在IOS上是毫无疑问没有任何问题的,是系统自带的,安卓怎么办?安卓现在有一个谷歌提供的web OTP API,但是兼容性极差,客户要求做,那就做吧:

在开始之前,我们要先注意几点:
1.版本要求:在 Android 设备上的 Chrome 84 或更高版本。IOS14以上(IOS 14的chrome不支持此功能
2.为input正确添加注释:为了跨浏览器兼容性,强烈建议将 autocomplete="one-time-code" 添加到希望用户输入 OTP 的input标签中。这样一来即使不支持OTP的IOS也可以识别出来并且加入到输入框中

根据谷歌的开发者文档提出的建议,该API的写法如下:

if ('OTPCredential' in window) {
  window.addEventListener('DOMContentLoaded', e => {
    const input = document.querySelector('input[autocomplete="one-time-code"]');
    if (!input) return;
    const ac = new AbortController();
    const form = input.closest('form');
    if (form) {
      form.addEventListener('submit', e => {
        ac.abort();
      });
    }
    navigator.credentials.get({
      otp: { transport:['sms'] },
      signal: ac.signal
    }).then(otp => {
      input.value = otp.code;
      if (form) form.submit();
    }).catch(err => {
      console.log(err);
    });
  });
}

功能检测与许多其他 API 相同。监听 DOMContentLoaded 事件将等待 DOM 树准备好后再开始查询。

WebOTP API 需要安全来源 (HTTPS)。HTTP 网站上的功能检测将失败。

处理OTP

WebOTP API 本身很简单。使用 navigator.credentials.get() 获取 OTP。WebOTP 向该方法添加了一个新的 otp 选项。它只有一个属性:transport ,其值必须是一个包含字符串 'sms' 的数组。

    navigator.credentials.get({
      otp: { transport:['sms'] }
    }).then(otp => {
 

当 SMS 消息到达时,会触发浏览器的权限流。如果授予权限,则返回的承诺将使用 OTPCredential 对象进行解析。

获取的 OTPCredential 对象的内容

{
  code: "123456" // 获取的 OTP
  type: "otp"  // `type` 始终为 "otp"
}

接下来,将 OTP 值传递给 input 字段。直接提交表单将省去需要用户点击按钮的步骤。

    navigator.credentials.get({
      otp: { transport:['sms'] }
      …
    }).then(otp => {
      input.value = otp.code;
      if (form) form.submit();
    }).catch(err => {
      console.error(err);
    });

如果用户手动输入 OTP 并提交表单,您可以在 options 对象中使用 AbortController 实例取消 get() 调用。

    const ac = new AbortController();
    …
    if (form) {
      form.addEventListener('submit', e => {
        ac.abort();
      });
    }
    …
    navigator.credentials.get({
      otp: { transport:['sms'] },
      signal: ac.signal
    }).then(otp => {

短信格式要求

  • 短信以人类可读文本开头(可选),包含一个 4 到 10 个字符的字母数字字符串,其中至少有一个数字,最后一行用于 URL 和 OTP。
  • 调用 API 的网站 URL 的域部分必须以 @ 开头。
  • URL 必须包含一个井号 ('#'),后跟 OTP。
Your OTP is: 123456.
@www.example.com #123456

以上这种格式就可以读取到OTP了

OK,这下基础知识介绍的都介绍了,来看看我在react项目里是怎么实现这个功能的吧:
使用到的技术:
recoil,tailwindcss,nextjs,Typescript
再来理一理客户的需求:
1.从一个页面到sms页面之后,会请求一次send message 的api,这个api一旦请求之后有30min的CD,在sms页面有一个resend按钮,他有30s的CD,验证码以用户最新收到的为准
2.验证码是六个小格子组成的六位数验证码输入框,不能通过点击调整聚焦的格子,必须填完第一个才有第二个的聚焦。
3.进入sms页面之后要自动聚焦第一个输入框(如果有30min的CD)如果没有CD的话不自动聚焦,而是等待 API完成之后点击dialog的ok键聚焦
(为什么用dialog提示用户后面有原因)

接下来来总结一下我做这个功能跌跌撞撞的过程:

全部代码:

直接上代码吧,一个完整的tsx组件

import clsx from 'clsx'
import React, { useState, useEffect, useRef } from 'react'
import { useEffectOnce } from 'react-use'

export type VerificationCodeInputProps = {
  codeLength: number
  onChange: (code: string) => void
  afterFocusInputEvent?: () => void
  focusInput?: boolean
  shouldFocusFirst?: boolean
  containerClassName?: string
}

const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
  codeLength,
  onChange,
  afterFocusInputEvent,
  focusInput,
  shouldFocusFirst,
  containerClassName,
}) => {
  const [code, setCode] = useState<string>('')
  const inputRefs = useRef<HTMLInputElement[]>([])

  useEffectOnce(() => {
    if (shouldFocusFirst) inputRefs.current[0]?.focus()
  })

  useEffect(() => {
    if (focusInput) {
      if (code.length === codeLength) return
      if (code.length > 0) {
        inputRefs.current[code.length].focus()
      } else {
        inputRefs.current[0]?.focus()
      }
      if (afterFocusInputEvent) {
        afterFocusInputEvent()
      }
    }
  }, [afterFocusInputEvent, code, codeLength, focusInput])

  const handleInputChange = (
    e: React.ChangeEvent<HTMLInputElement>,
    index: number
  ) => {
    const value = e.target.value.replace(/^\D/g, '')
    // fill 6 inputs and not allow to enter number
    if (value && code.length === codeLength) return

    // IOS chrome does not support one str match
    if (value.length !== 1 && !!value) {
      setCode(value)
      inputRefs.current[codeLength - 1].focus()
      return
    }

    setCode((prevCode) => {
      const newCode = prevCode.split('')
      newCode[index] = value.slice(-1)
      return newCode.join('')
    })

    if (value && index < codeLength - 1) {
      inputRefs.current[index + 1]?.focus()
    }
  }

  // To disable IOS Safari keyBroad top left arrow up and down event
  const handleInputFocus = (
    e: React.FocusEvent<HTMLInputElement>,
    index: number
  ) => {
    if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
      inputRefs.current.forEach((ref, idx) => {
        if (index !== idx) {
          ref.setAttribute('readonly', 'readonly')
        }
      })
    }
  }

  const handleInputBlur = (
    e: React.FocusEvent<HTMLInputElement>,
    index: number
  ) => {
    if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
      inputRefs.current.forEach((ref, idx) => {
        ref.removeAttribute('readonly')
      })
    }
  }

  const handleInputKeyDown = (
    e: React.KeyboardEvent<HTMLInputElement>,
    index: number
  ) => {
    if (e.key === 'Backspace' && !e.currentTarget.value && index > 0) {
      inputRefs.current[index - 1]?.focus()
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault()
    }
  }

  const handleInputPasteEvent = (
    e: React.ClipboardEvent<HTMLInputElement>,
    index: number
  ) => {
    e.preventDefault()
    const pasteData = e.clipboardData
      .getData('Text')
      .replace(/\D/g, '')
      .slice(0, codeLength - code.length)
    inputRefs.current[
      (code + pasteData).length === codeLength
        ? codeLength - 1
        : (code + pasteData).length
    ]?.focus()
    setCode(code + pasteData)
  }

  const handleInputMouseEvent = (
    e: React.MouseEvent<HTMLInputElement>,
    index: number
  ) => {
    const firstEmptyIndex = inputRefs.current.findIndex(
      (item) => item.value === ''
    )
    e.preventDefault()
    if (firstEmptyIndex !== -1) {
      inputRefs.current[firstEmptyIndex].focus()
    } else {
      inputRefs.current[codeLength - 1].focus()
    }
  }

  useEffect(() => {
    onChange(code)
  }, [code, onChange])

  useEffect(() => {
    if ('OTPCredential' in window) {
      let ac = new AbortController()
      setTimeout(() => {
        // abort after 10 minutes
        ac.abort()
      }, 10 * 60 * 1000)
      navigator.credentials
        .get({
          otp: { transport: ['sms'] },
          signal: ac.signal,
        } as CredentialRequestOptions)
        .then((otp) => {
          setCode((otp as any)?.code)
        })
        .catch((err) => {
          console.error(err)
        })
    }
  }, [])

  // input add tabIndex = -1 to disable IOS chrome keyBroad arrow buttons
  return (
    <div className={clsx('flex w-full', containerClassName)}>
      {Array.from({ length: codeLength }, (_, index) => (
        <input
          key={index}
          ref={(el) => {
            inputRefs.current[index] = el as HTMLInputElement
          }}
          className={clsx(
            'pb-1 w-10 h-14 text-center placeholder:text-accent rounded-lg border border-neutral-02 focus:border-primary focus:outline-none transition-colors duration-300 ease-in-out caret-primary',
            code[index] && 'border-primary'
          )}
          type="text"
          value={code[index] || ''}
          onChange={(e) => handleInputChange(e, index)}
          onKeyDown={(e) => handleInputKeyDown(e, index)}
          onPaste={(e) => handleInputPasteEvent(e, index)}
          onFocus={(e) => handleInputFocus(e, index)}
          onBlur={(e) => handleInputBlur(e, index)}
          onMouseDown={(e) => handleInputMouseEvent(e, index)}
          tabIndex={-1}
          inputMode={'numeric'}
          autoComplete="one-time-code"
        />
      ))}
    </div>
  )
}

export default VerificationCodeInput

问题1:六字验证码的输入框如何搭建

根据代码所示:我通过props的codeLength控制了验证码输入框需要几个,每一个输入框的最大长度为什么不控制?因为在IOS 部分版本的chrome中,有的自动填入是一次性把所有的值都填入进来,加入限制的话不好控制,容易出现bug。

问题2:为什么要加入tabIndex = -1这一段以及removeAttribute和setAttribute?

因为在IOS系统中键盘上有两个方向键的小按钮,他可以无视我们人为的焦点控制,任意移动焦点,我们设置readonly以及tabIndex之后可以有效规避焦点控制失效的问题

问题3:为什么在onChange事件里使用如此之多的if判断?

因为各个浏览器各不相同,在IOS中 safari是一次一次的填入数据,chrome是一次性填入数据

最后修改:2023 年 06 月 21 日
收款不要了,给孩子补充点点赞数吧