前述
最近负责的项目收到了一个用户的投诉,意思是在进入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 树准备好后再开始查询。
处理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是一次性填入数据