如何封装一个短信验证码输入组件

696 阅读6分钟

需求分析

一个典型的短信验证码输入界面大概是下面这样,那么一个验证码输入组件(仅包含那6个输入框)应该怎么实现呢?

Snipaste_2024-06-14_09-28-21.png

我尝试了一下掘金和知乎的短信验证码输入的交互,初步总结出短信验证码输入组件的以下功能特性:

  1. 默认6位验证码(组件可以考虑支持扩展)。
  2. 点击组件内任意input,总是会focus从左侧开始第一个值为空的input。如果所有input值都不为空,则会focus最后一个input。
  3. 某个input输入非空值后自动focus其右侧相邻的input(若存在)。所有input都输入非空值后触发“完成”事件。
  4. 某个input值为空时,按下删除键会自动focus其左侧相邻的input(若存在),同时将后者值设为空。

实现

本文所有代码基于TypeScript + Vue3 script setup语法,并提供Vue SFC Playground在线链接方便演示效果。

实现feature1和v-model

首先实现feature1:默认6位验证码(组件可以考虑支持扩展)

我们可以为组件声明一个proplength,新建一个VertificationCode.vue组件。

+withDefaults(defineProps<{
+  length?: number
+}>(), {
+  length: 6,
+})

另外像这样的输入组件,我们通常会把组件的输入值与父组件做双向绑定,这样父组件就可以很方便的通过v-model来使用。

声明一个propmodelValue,这是一个字符串值。我们要把这个字符串值的prop转换成一个组件内部的字符串数组,通过v-for遍历这个数组,我们就可以渲染出一个个input。

const props = withDefaults(defineProps<{
+ modelValue: string
  length?: number
}>(), {
+ modelValue: '',
  length: 6,
})

+const tempValue = computed(() => {
+  const arr = props.modelValue.split('')
+  return [...Array(props.length)].map((_, i) => {
+    return !!arr[i] ? String(arr[i]) : ''
+  })
+})
+const localValue = ref(tempValue.value)
+<div class="vertification-code">
+  <span v-for="(item, index) in localValue" :key="index" +class="vertification-code-item">
+    <input
+      :value="item" 
+      class="vertification-code-input"
+    />      
+  </span>
+</div>

接下来是实现v-model,首先通过defineEmits定义update:modelValue事件,然后给每个input元素绑定input事件,在input事件里触发update:modelValue事件。另外还需要监听props.modelValue发生变化时更新localValue

+const emit = defineEmits<{
+  'update:modelValue': [value: string]
+}>()

+const handleInput = (index: number, e: Event) => {
+  const value = (e.target as HTMLInputElement).value || ''
+  localValue.value[index] = value.trim().charAt(value.length - 1)
+  update()
+}

+const update = () => {
+  const value = localValue.value.join('').trim()
+  emit('update:modelValue', value)
+}

+watch(tempValue, (newVal) => {
+  localValue.value = newVal
+})
<div class="vertification-code">
  <span v-for="(item, index) in localValue" :key="index" class="vertification-code-item">
    <input
      :value="item" 
      class="vertification-code-input"
+     @input="e => handleInput(index, e)"
    />      
  </span>
</div>

这样父组件就能通过v-model来使用子组件了。

+<script lang="ts" setup>
+import { ref } from 'vue'
+import VertificationCode from './VertificationCode.vue'
+
+const code = ref('')
+</script>
+
+<template>
+  <VertificationCode v-model="code" />
+  code: {{ code }}
+</template>

最后再加点样式:

+:root {
+  --vertification-code-size: 40px;
+  --vertification-code-gap: 10px;
+}
+.vertification-code {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: calc(var(--vertification-code-size) * v-bind(length) + var(--+vertification-code-gap) * v-bind(length - 1));
+}
+.vertification-code-item {
+  width: var(--vertification-code-size);
+  height: var(--vertification-code-size);
+  background-color: #f2f3f5;
+  border-radius: 2px;
+}
+.vertification-code-input {
+  width: var(--vertification-code-size);
+  height: var(--vertification-code-size);
+  background: none;
+  outline: none;
+  border: none;
+  border-radius: 0;
+  text-align: center;
+  -webkit-appearance: none;
+  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}

上面的css代码用到了CSS自定义变量Vue单文件组件CSS中的v-bind(),父组件可以通过修改CSS自定义变量的值来实现样式定制。

目前的效果见下面的gif图,双向绑定已经实现。不过目前的实现还有两个问题:

  1. 输入完需要手动focus到下一个input,我录制gif图时是用Tab键来切换的。
  2. 一个input内允许输入多个字符。

其实这两个问题也可以看成一个问题,因为只要输入完一个字符自动focus下一个input,第2个问题就不存在了。我们后面再来解决这个问题。

dom滚动 (1).gif

在线预览本节代码和效果。

实现feature2

接下来实现feature2:点击组件内任意input,总是会focus从左侧开始第一个值为空的input。如果所有input值都不为空,则会focus最后一个input

首先我们要声明所有input的模板ref数组,还需要一个函数来focus指定索引的input。

+const inputRefs = ref<HTMLInputElement[]>([])
+const handleFocus = (index: number) => {
+  inputRefs.value && inputRefs.value[index].focus()
+}

接着我们给input的focus事件绑定一个处理函数focusNext,这个函数要遍历localValue。遍历到第一个空值时focus该索引对应的input,同时跳出循环;如果遍历完发现所有input值都不为空,则focus最后一个input。

+const focusNext = () => {
+  const len = localValue.value.length
+  for (let i = 0; i < len; i++) {
+    if (!localValue.value[i]) {
+      // 遍历到第一个空值,focus索引对应的input,跳出循环
+      handleFocus(i)
+      break
+    }
+    if (i === len - 1) {
+      // 如果所有input都不为空,则focus最后一个input
+      handleFocus(len - 1)
+    }
+  }
+}
<div class="vertification-code">
  <span v-for="(item, index) in localValue" :key="index" class="vertification-code-item">
    <input
+     ref="inputRefs"
      :value="item" 
      class="vertification-code-input"
      @input="e => handleInput(index, e)"
+     @focus="focusNext"
    />      
  </span>
</div>

在线预览本节代码和效果。

实现feature3

feature3:某个input输入非空值后自动focus其右侧相邻的input(若存在)。所有input都输入非空值后触发“完成”事件

这个feature其实包含两个功能:实现自动切换focus和完成事件。自动切换focus其实上一节开发的focusNext函数已经可以满足了,只需要在update中调用即可。而完成事件则需要先注册finish事件,然后在update中判断输入完后emit这个事件即可。

const emit = defineEmits<{
  'update:modelValue': [value: string]
+ 'finish': [value: string]
}>()

const update = () => {
  const value = localValue.value.join('').trim()
  emit('update:modelValue', value)
+ if (value.length === props.length) {
+   emit('finish', value)
+ }
+ focusNext()
}

在父组件中调用这个事件:

<script lang="ts" setup>
import { ref } from 'vue'
import VertificationCode from './VertificationCode.vue'

const code = ref('')

+const handleFinish = (value: string) => {
+  alert(`触发了finish事件:${value}`)
+}
</script>

<template>
- <VertificationCode v-model="code" />
+ <VertificationCode v-model="code" @finish="handleFinish" />
  code: {{ code }}
</template>

在线预览本节代码和效果。

实现feature4

feature4:某个input值为空时,按下删除键会自动focus其左侧相邻的input(若存在),同时将后者值设为空

要实现这个feature,只需要给input绑定keydown事件的处理函数handleKeydown,在这个函数内判断键入的是BackSpace删除键,并且当前input值为空,则清空其左侧相邻的input值,并调用update()即可。

+const handleKeydown = (index: number, e: KeyboardEvent) => {
+  const enum KEY_CODES {
+    Backspace = 'Backspace'
+  }
+  const keyCode = e.code || e.key
+  if (keyCode === KEY_CODES.Backspace && !localValue.value[index]) {
+    e.preventDefault()
+    localValue.value[index > 0 ? index - 1 : index] = ''
+    update()
+  }
+}
<div class="vertification-code">
  <span v-for="(item, index) in localValue" :key="index" class="vertification-code-item">
    <input
      ref="inputRefs"
      :value="item" 
      class="vertification-code-input"
      @input="e => handleInput(index, e)"
+     @keydown="e => handleKeydown(index, e)"
      @focus="focusNext"
    />      
  </span>
</div>

需要注意,删除键的keyCode是字符串BackSpace,但应该避免直接使用字符串字面量做判断,比如keyCode === 'BackSpace',这叫魔法字符串。我这里用的是ts中的枚举,由于这里只用到了这一种keyCode,所以直接在函数内维护了,实际的开发中可能会用到很多keyCode,应该把它抽离出去独立维护。

在线预览本节代码和效果。

总结

至此,一个短信验证码输入组件就封装完成了。整个组件并没有用到多么难懂高深的代码,但是分享了封装一个组件时的整体思路:先分析功能特性列个清单,然后再思考每个功能特性怎么实现,应该声明哪些prop和emit,乃至slot等,分步地实现一个个功能。我个人在日常的组件封装过程中也是采取这样的思路的。

这个组件的功能实际还可扩展更多的功能,比如支持设置组件的readonlydisabled等特性、支持过滤输入的文本内容如仅允许输入数字等,篇幅问题这里就不继续实现了,如果你感兴趣可以在我的实现的基础上继续开发完成这些功能。