需求分析
一个典型的短信验证码输入界面大概是下面这样,那么一个验证码输入组件(仅包含那6个输入框)应该怎么实现呢?
我尝试了一下掘金和知乎的短信验证码输入的交互,初步总结出短信验证码输入组件的以下功能特性:
- 默认6位验证码(组件可以考虑支持扩展)。
- 点击组件内任意input,总是会focus从左侧开始第一个值为空的input。如果所有input值都不为空,则会focus最后一个input。
- 某个input输入非空值后自动focus其右侧相邻的input(若存在)。所有input都输入非空值后触发“完成”事件。
- 某个input值为空时,按下删除键会自动focus其左侧相邻的input(若存在),同时将后者值设为空。
实现
本文所有代码基于TypeScript + Vue3 script setup语法,并提供Vue SFC Playground在线链接方便演示效果。
实现feature1和v-model
首先实现feature1:默认6位验证码(组件可以考虑支持扩展)。
我们可以为组件声明一个prop叫length,新建一个VertificationCode.vue组件。
+withDefaults(defineProps<{
+ length?: number
+}>(), {
+ length: 6,
+})
另外像这样的输入组件,我们通常会把组件的输入值与父组件做双向绑定,这样父组件就可以很方便的通过v-model来使用。
声明一个prop叫modelValue,这是一个字符串值。我们要把这个字符串值的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图,双向绑定已经实现。不过目前的实现还有两个问题:
- 输入完需要手动focus到下一个input,我录制gif图时是用Tab键来切换的。
- 一个input内允许输入多个字符。
其实这两个问题也可以看成一个问题,因为只要输入完一个字符自动focus下一个input,第2个问题就不存在了。我们后面再来解决这个问题。
在线预览本节代码和效果。
实现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等,分步地实现一个个功能。我个人在日常的组件封装过程中也是采取这样的思路的。
这个组件的功能实际还可扩展更多的功能,比如支持设置组件的readonly和disabled等特性、支持过滤输入的文本内容如仅允许输入数字等,篇幅问题这里就不继续实现了,如果你感兴趣可以在我的实现的基础上继续开发完成这些功能。