做停车场管理系统、门禁道闸、车主自助录入这类业务的人,应该都懂同一个痛:车牌号输入。手机系统键盘上没有省份简称面板,字母和数字要来回切换,新能源牌照还要专门找 D/F 标识位,用户录一个车牌要点十几次屏幕,还特别容易输错。这个问题在 UNI-app + Vue3 项目里尤其明显,因为要同时兼容小程序、App、H5 三端,直接用原生 input 很难做出统一体验。所以我干脆把车牌键盘封装成了独立组件,一套代码跑三端,哪里需要去哪里。这篇文章就把组件的设计思路、核心代码、调试流程和踩坑记录完整拆一遍,做停车、充电桩、ETC、汽车后市场这类业务的同学可以直接抄作业。
1. 组件化方案:为什么不用系统键盘
1.1 车牌输入的业务痛点
车牌号录入不是普通文本框那种“随便敲几个字符”的业务,它有一套非常严格的格式要求:第一位必须是省份简称,第二位是发牌机关代号字母,后面的部分是字母和数字的组合,而且不同车型长度还不一样。普通蓝牌是 7 位,新能源绿牌是 8 位,警车最后一位可能是“警”字,挂车最后可能是“挂”字。这种强规则输入,拿通用键盘来做就是灾难。
我做过一个停车场管理小程序,最早版本就是让用户在系统键盘上自己敲。结果用户反馈特别集中:找省份简称要翻好几页,切完数字切字母来回折腾,好不容易输完了提交时才发现新能源车要求输 8 位,后面 D/F 又不知道去哪找。后台数据的脏数据也很多,有把“湘”输成“相”的,有把数字 0 和字母 O 混淆的,还有少一位多一位的。后来我统计了一下,车牌输入的错误率一度超过 15%,这个比例放到道闸识别场景里就是系统反复匹配失败,客户体验非常糟糕。
所以车牌输入这类场景,最好是做一个专属键盘,就像日期选择器、身份证键盘、金额键盘一样,用户看到什么输什么,根本不给他犯错的机会。这也解释了为什么市面上的停车 App、充电 App、车主服务小程序,几乎都会自己套一个车牌键盘——不是跟风,是这个业务确实有硬需求。
1.2 方案选型:为什么是 uni-app + Vue3 自研
选 uni-app 没什么悬念,一套代码编译到微信小程序、支付宝小程序、H5 和 App,对做垂直业务的小团队来说性价比最高。尤其是车牌键盘这种组件,它的输入逻辑和校验规则是同一套,UI 是三端统一的,天然适合跨端封装。如果用原生小程序写一套、App 再写一套、H5 又写一套,光是维护字符集和校验逻辑就能烦死人。
再来说为什么用 Vue3 写。Vue3 的组合式 API 对这类有状态组件非常友好:牌号当前值、光标位置、面板类型、校验规则,这些逻辑用一个 setup 函数就能清晰聚拢,不需要像 Vue2 那样在 data、methods、computed、watch 之间来回跳。加上 defineProps、defineEmits 这些编译宏,组件对外接口一目了然。uni-app 从 HBuilderX 3.x 开始对 Vue3 的支持已经比较稳定,我实际用下来,在小程序端和 App 端的兼容性都达到了可以直接上生产的程度。
至于为什么不引第三方库,道理很简单:这种垂直键盘组件,第三方的实现五花八门,有的只做了省份和字母数字的简单拼接,有的绑死了某个设计稿的主题色,有的对 Vue3 和 uni-app 的适配只停留在“能编译”的程度。与其去 patch 别人的代码,不如自己写一个干净、可控、按业务配置的组件。自己写的还有一个好处,就是后续要扩展“长按删除”“点击某一位修改”“接入摄像头识别车牌自动填充”这类功能,都在自己手里,想加就加。
1.3 组件整体结构与数据流
整个组件我拆成了两块:展示区和键盘区。展示区是车牌号码的预览格子,一行多个小方框,每格对应一位字符,同时光标落在哪一位,哪一格就高亮。键盘区是真正可点击的按键面板,根据当前输入位置自动切换省份、字母、字母数字混合这些面板。
对外接口上,我用了 v-model 做双向绑定,父页面只需要维护一个车牌字符串。组件内部自己维护真实字符值和光标位置,每次用户点击按键,内部先校验,再更新字符,最后通过emit('update:modelValue', nextVal)把新值抛出去。这样父组件的表单校验、提交逻辑都不用关心键盘内部怎么工作,只管接收值就行。
数据流大概是:点击按键 → 判断当前光标位置 → 判断该位置允许哪些字符 → 写入字符串对应位置 → 光标后移 → 通知父组件更新值。反过来,删掉一位时按相同路径回退。整个链路很直白,这也是我为什么坚持在组件内部维护“当前输入位置”的原因——如果把光标状态也交给父组件管理,调用方会被迫理解车牌的格式规则,那这个组件的边界就糊了。
2. 车牌规则与键盘布局设计
2.1 国内车牌编码规则梳理
做车牌键盘之前,必须先把车牌号的编码规则彻底理清楚,偏掉一位都是硬伤。国内民用车牌的通用结构是“省份简称 + 发牌机关代号 + 序号”三部分组成,不同号牌类型的长度和字符集不太一样。
我把常见的号牌类型整理成了下面的规则表,项目按需取用:
| 号牌类型 | 总位数 | 第1位 | 第2位 | 第3位及以后 | 典型示例 |
|---|---|---|---|---|---|
| 普通蓝牌/黄牌 | 7 | 省份简称 | 字母 | 字母或数字 | 京A12345 |
| 新能源绿牌 | 8 | 省份简称 | 字母 | 字母或数字,末位常为 D/F | 京AD12345 |
| 警用汽车 | 7 | 省份简称 | 字母 | 字母或数字,末位为“警” | 京A1234警 |
| 教练汽车 | 7 | 省份简称 | 字母 | 字母或数字,末位为“学” | 京A1234学 |
| 挂车 | 7 | 省份简称 | 字母 | 字母或数字,末位为“挂” | 京A1234挂 |
| 港澳入出 | 7 | 省份简称 | 字母 | 字母或数字,末位为“港/澳” | 粤Z1234港 |
省份简称这部分就是国内省级行政区的简称,我做成一个常量数组:京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云陕甘青宁新,还有一个“渝”。实际业务里可能出现某个场景只需要某几个省份,比如本地化停车场项目,就可以通过provinceListprop 传入子集,让键盘更精简。
字母这块有个容易被忽略的细节:民用号牌序号里不使用字母 I 和 O,因为这两个字符跟数字 1 和 0 太像,容易识别错。所以主键盘里的字母集合应该是ABCDEFGHJKLMNPQRSTUVWXYZ,把 I 和 O 去掉。这个细节在车牌识别和自动抬杆场景里特别重要,识别出的结果本身就容易搞混,再让用户在键盘上手动输一个 I/O 出来,那后台系统十有八九会判定为非法号牌。
2.2 键盘分区与按键状态设计
车牌键盘的按键分区,最笨的做法是把所有字符平铺在一个大面板里,省份字符、字母、数字混在一起,用户自己找。但这样还是没解决“找字符”的效率问题。我的设计思路是按“输入位置”自动切换面板,用户根本不用手动切 tab。
具体来说,组件维护一个cursor光标,代表当前要填第几位。光标在第 0 位时,面板只显示省份简称;第 1 位时,只显示字母;第 2 位及以后,显示字母和数字混合面板;如果是新能源车牌且到了第 8 位,则面板只显示 D 和 F(当然这里也做成可配置,有的场景允许填任意字母数字)。用户每输一位,面板就自动跳到下一组允许的字符,几乎不可能输错。
还要处理一个细节:用户填到第 4 位时,突然发现第 2 位选错了字母,他不可能把后面全部删掉重来。所以组件支持点击预览区的任意一个格子,光标直接跳回那一位,键盘面板也随之切换到对应字符集。这个交互很关键,我做的第一个版本没支持,测试组小姐姐天天提 bug,说“改一个字母要把后三位全删了”。加了“点格子定位”之后,整体体验提升了一大截。
按键状态也分几种:正常可用、可选特殊面板(如新能源末位的 D/F)、删除键、清空键、确认键。删除和清空我放在工具栏底栏,确认键放在右下角;小屏手机上,确认键最好用高对比色,让用户一眼能看到“完成”入口。长按删除键我实现了连续删除,配合震动反馈(小程序端通过wx.vibrateShort,App 端通过uni.vibrateShort),手感会接近系统键盘。
2.3 输入状态机与校验规则
车牌输入的过程,天然就是一个状态机:当前输入位置决定合法字符集,合法字符集决定面板展示。我把这个状态机简化成几条规则:
- 光标在第 0 位时,只接受省份字符。
- 光标在第 1 位时,只接受大写字母(默认不含 I/O)。
- 光标在中间位时,接受字母和数字。
- 新能源模式且光标在最后一位时,默认只接受 D/F,可配置放开。
- 普通模式且开启特殊字符时,光标在最后一位也可接受“警/学/挂/港/澳/使/领”这些字。
校验函数写成独立方法isValidKey(key),点击任何按键都先过校验,不合法直接给个轻提示,不写入。这样不管外层传什么值进来,键盘都不会生成非法车牌。我最初把校验写在 handleTap 里面,后来发现业务方想把校验逻辑抽出去给后台表单复用,就把它提成了一个纯函数,传入位置、字符、配置,返回布尔值。这个改动很值得,后面后台管理系统做 Excel 批量导入校验时直接复用同一套逻辑,前后端规则完全一致。
还有一个容易被忽略的场景:用户把光标定位到某一位后,又切换到了另一个输入框,再切回来时光标状态要能正确恢复。做法是watch(visible),当键盘从隐藏变为显示时,把光标重置到字符串长度位置;如果字符串已满,则光标停在最后一位,并让最左边的空格保持可点击定位。这个“重新打开键盘后光标该停在哪”的细节,我踩过 bug,后面在常见问题部分再展开。
3. 核心代码实现与调试
3.1 组件骨架与 vue3 接口设计
组件我放在components/plate-keyboard/plate-keyboard.vue,利用 uni-app 的 easycom 机制,页面里直接写<plate-keyboard v-model="plate" />就能用,不需要手动 import。这里要注意,easycom 要求组件目录名和文件名一致,比如components/plate-keyboard/plate-keyboard.vue,否则自动扫描规则匹配不到。
Vue3 的组件对外接口用defineProps和defineEmits声明,代码很直观:
<script setup> import { ref, computed, watch } from 'vue' const props = defineProps({ // 车牌号,v-model 绑定的值 modelValue: { type: String, default: '' }, // 是否显示键盘 visible: { type: Boolean, default: false }, // 是否新能源车牌,决定最大长度和末位 D/F 面板 newEnergy: { type: Boolean, default: false }, // 普通车牌最大长度,默认 7 maxLength: { type: Number, default: 7 }, // 是否开放特殊字符(警/学/挂/港/澳/使/领) supportSpecial: { type: Boolean, default: false }, // 自定义省份列表,默认全部 provinceList: { type: Array, default: () => [] } }) const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']) // 内部车牌值,避免直接改 props const innerValue = ref(props.modelValue || '') // 光标位置 const cursor = ref(innerValue.value.length || 0) // 监听外部 v-model 变化 watch(() => props.modelValue, (val) => { if (val !== innerValue.value) { innerValue.value = val || '' } }) // 监听键盘开关,打开时重置光标 watch(() => props.visible, (val) => { if (val) { const len = innerValue.value.length cursor.value = len >= props.maxLength ? props.maxLength - 1 : len } }) </script>这里有个原则要守住:defineProps里拿到的 props 是只读的,绝对不能直接props.modelValue = xxx。所有值的变更统一走 emit,组件内部需要的可变副本用ref或reactive维护,再通过 watch 和外部同步。这个写法和 Vue2 时代的sync修饰符思路差不多,但在 Vue3 里更规范。
3.2 键盘数据生成与点击逻辑
字符集和面板数据,我用常量表加上几个 computed 派生。省份字符来自 props 或默认数组,字母和数字是固定的:
const DEFAULT_LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.split('') const NUMBERS = '0123456789'.split('') const SPECIAL_CHARS = ['警', '学', '挂', '港', '澳', '使', '领'] const provinceChars = computed(() => { return props.provinceList.length ? props.provinceList : defaultProvinceList }) const letterChars = computed(() => { return props.supportSpecial && cursor.value === props.maxLength - 1 ? [...DEFAULT_LETTERS, ...SPECIAL_CHARS] : DEFAULT_LETTERS }) const letterNumberChars = computed(() => { return [...DEFAULT_LETTERS, ...NUMBERS] }) const currentKeys = computed(() => { const idx = cursor.value if (idx === 0) return provinceChars.value if (idx === 1) return letterChars.value if (props.newEnergy && idx === props.maxLength - 1) { // 新能源末位只给 D/F return ['D', 'F'] } // 特殊字符要允许出现在末位 if (props.supportSpecial && idx === props.maxLength - 1) { return [...letterNumberChars.value, ...SPECIAL_CHARS] } return letterNumberChars.value })点击按键的核心函数是handleKeyTap(key),它要做三件事:校验、写入、联动光标:
function handleKeyTap(key) { const idx = cursor.value if (!isValidKey(key)) { uni.showToast({ title: '当前位不支持该字符', icon: 'none' }) return } const arr = innerValue.value.split('') arr[idx] = key const nextVal = arr.join('').slice(0, props.maxLength) innerValue.value = nextVal emit('update:modelValue', nextVal) // 光标后移,不越过最后一位 if (idx < props.maxLength - 1) { cursor.value += 1 } }删除键的逻辑就要考虑“光标所在位有值先删当前位,没有值则往前跳一位再删”,这样才符合“点哪改哪”的交互习惯:
function handleDelete() { const arr = innerValue.value.split('') if (arr[cursor.value]) { arr[cursor.value] = '' } else if (cursor.value > 0) { cursor.value -= 1 arr[cursor.value] = '' } const nextVal = arr.join('') innerValue.value = nextVal emit('update:modelValue', nextVal) }isValidKey就是前面 2.3 节状态机的代码化,我把它写成可独立复用的纯函数,放到了utils/plate.js里。这样后台管理端做车牌批量校验,也能直接 import 过去用,前后端规则保持统一。实际项目里我见过最坑的情况就是前端键盘放开某个字符,后台校验却不认,导致用户提交后打回重填,这锅不能让用户背。
3.3 在微信开发者工具与模拟器中调试
写 uni-app 组件最烦的就是“本地样式正常,一编译到小程序就变了”。我的习惯是先用 H5 端快速调试交互,再跑一套到微信开发者工具验证小程序兼容性,最后再用安卓模拟器看 App 端表现。
HBuilderX 里配置微信开发者工具路径很简单:顶部菜单工具 -> 设置 -> 运行配置,找到“微信开发者工具路径”,填上安装目录下的可执行文件路径。然后在项目顶部菜单选择运行 -> 运行到小程序模拟器 -> 微信开发者工具,编译完成后 HBuilderX 会自动拉起微信开发者工具并打开编译产物目录。如果一直提示“未配置路径”,多半是运行配置里填的路径层级不对,要选到cli.bat或微信开发者工具的主程序那层。
如果用 CLI 创建的 uni-app 项目,也可以直接用命令行编译:
npm run dev:mp-weixin编译输出的目录默认是dist/dev/mp-weixin,然后用微信开发者工具手动“导入项目”选中这个目录即可。命令行方式的优点是可以接 CI,比如提交代码后自动触发编译,微信开发者工具里用预览二维码拉真机测试。模拟器方面,HBuilderX 的运行 -> 运行到手机或模拟器能直接连接 MuMu、夜神这类常见的安卓模拟器,前提是模拟器开启了 ADB 调试端口。如果列表刷不出模拟器,先在终端执行adb devices,看到设备编号说明已连接,HBuilderX 里刷新一般就能出现。
调试车牌键盘时有个小技巧:在微信开发者工具里打开“模拟操作”面板,可以单独触发touchstart事件,适合排查按键事件的触发热区。因为车牌键盘的按键我用的是@touchstart而不是@click,这样响应更快,但也更容易出现误触,调试时要看一下热区是否命中到了旁边的按键。
3.4 样式适配与交互细节优化
车牌键盘是典型的底部弹出面板,样式上要注意三点:定位层级、安全区、尺寸适配。
定位我用position: fixed固定在底部,层级建议用足够大的z-index,因为页面里可能有弹窗、蒙层、底部导航,太低会被盖住。uni-app 小程序端固定定位没问题,H5 端如果外层套了 transform 容器,fixed 会变成相对定位,这一点容易踩坑。解决方法是把键盘组件放在页面根节点下,或者干脆用uni.createSelectorQuery计算容器位置。更稳妥的做法是在组件内部渲染一个独立的view节点,不同端表现差异小。
尺寸适配用 rpx 单位最省心,rpx 在 H5、小程序、App 端都会按屏幕宽度做等比换算。键盘高度我一般控制在页面高度的 40% 到 45%,height: 44vh左右比较舒适。字符按键的间距用百分比或 rpx,确保大屏小屏都能点中。底部还要预留 iPhone 横条安全区,用env(safe-area-inset-bottom)给工具栏加 padding,不然确认键会杵到 Home 条附近不好点。
交互细节上,除了长按删除、点击某一位定位修改之外,还有两个值得做的点。一个是按键按下去的视觉反馈,压暗背景或缩小一点,让用户觉得“这个键真的按到了”;另一个是确认按钮的状态联动,如果当前车牌长度没达到最低位,确认键置灰,达到后变成亮色。置灰判断用computed派生,每次 innerValue 变化自动更新,不需要手动调方法。
4. 常见问题排查与避坑实录
4.1 自定义键盘和系统输入法打架
不少第一次做自定义键盘的同学,会把车牌键盘和系统键盘混着用:页面上放一个真实的<input>展示车牌号,点击时弹自定义键盘,结果发现系统键盘也跟着弹出来,两个键盘叠在一起,非常难看。原因很简单,input 获得焦点就会触发系统键盘。
我的处理方式是彻底不用 input 承载车牌号,预览区用多个静态view格子展示字符,格子外层绑@tap事件来定位光标。如果业务规定必须用 input 提交表单值,也把 input 设为readonly或者动态控制focus为 false,避免系统键盘弹出。还有一个 trick:如果 input 被某些组件库强制接管焦点,可以在@focus回调里执行一次event.target.blur(),把焦点立刻踢掉,系统键盘就不会出来了。
4.2 直接修改 props 导致响应式丢失
Vue3 里经常有新人直接操作 props,然后发现页面怎么都不更新。比如在组件里写props.modelValue = nextVal,控制台直接报错或者静默失败。这是 Vue 响应式机制决定的,props 是单向数据流,必须通过事件通知父组件更新。
我踩过的一个真实 bug 是:车牌键盘内部用props.modelValue作为初始值,但组件没有维护内部副本,每次键盘打开直接读props.modelValue,结果父组件异步加载 A 车牌数据时,键盘还是显示上一次的 B 车牌字符串。最后加了一个 watch 监听 modelValue 变化并同步内部副本,问题才解决。所以组件里始终要有一个“内部可变副本 + 外部 v-model 同步”的桥接层,不要偷懒直接读写 props。
4.3 多实例状态互相污染
一个页面可能同时存在多个入口唤起车牌键盘,比如“进场记录”弹窗里一个,“手动补录”弹窗里一个。如果组件内部不小心用了模块级变量保存当前值,两个实例就会互相覆盖,弹出 A 的记录却显示 B 的号牌。
排查方法很简单:打开页面,先给车牌 A 输入到一半,关闭键盘,打开车牌 B 的键盘,看看 B 里是不是残留了 A 的字符。如果残留了,说明状态定义在了组件外部。正确的做法是用setup里的ref或reactive定义组件实例私有状态,而不是模块全局变量。多个键盘实例各自持有自己的 innerValue 和 cursor,互不干扰。项目里有跨页面共享车牌值需求时,可以引入 pinia 管理一个全局的tempPlate,但那个状态只用于跨路由暂存,键盘组件内部依然保持实例私有。
4.4 编译报错与 wgt 热更新不生效
uni-app 的 Vue3 项目偶尔会遇到编译报错,最常见的是Cannot read properties of undefined (reading 'craft')这类信息,出现在模板里某个变量还没有初始化。比如v-model="form.plate"但form对象在 setup 中没有先声明,或者接口返回数据还没到位模板就渲染了。解决方式是在模板渲染前给变量加兜底,或者用v-if控制组件挂载时机:
<plate-keyboard v-if="form" v-model="form.plate" />另外,如果车牌键盘组件跟 wgt 热更新一起用,有个容易踩的坑:组件文件打包进了 App 的原生安装资源里,单纯打 wgt 增量包覆盖业务代码,原生资源层没有重新生成,导致热更新后键盘还是旧版本。排查思路是确认 manifest.json 中 App 版本号和版本名称是否提升,wgt 包重新编译时有没有把components/plate-keyboard目录打进去,安装 wgt 包后有没有完全杀掉 App 进程再重启。实际项目里我在热更新后加了一句plus.runtime.restart(),强制 App 重启加载新资源,基本就能解决问题。
4.5 给组件继续加料:优化与扩展方向
车牌键盘这种组件,做完基础版之后还能继续打磨。我目前在用的版本比上面贴的基础逻辑多了几个功能:支持通过 prop 传入“已识别的车牌号”并在键盘预览区高亮差异位,方便用户修改 OCR 识别结果;支持主题配置,比如停车场项目用蓝绿色、充电桩项目用橙色,通过 CSS 变量实现一键换肤;还预留了一个customKeys插槽,某些项目需要放“通行证”“月卡”之类的快捷按钮,可以直接插到工具栏里。
还有一个有价值的方向是接入车牌识别摄像头。现在很多停车场小程序都支持拍照识别,识别结果直接填充到键盘预览区,用户只需要核对一两位不确定的字符。这个能力如果在键盘组件层面做,可以复用同一套校验逻辑,避免识别结果生成一个非法车牌。做法就是给组件加一个ocrValueprop,监听变化后自动把识别结果拆分成字符数组,并让光标落到第一个疑似错误的位置,用户一键修正即可提交。
最后分享一个我个人的小习惯:车牌键盘的确认键点击后,不要把键盘立即关掉。很多用户输完车牌会顺手点一下“确认”,随后才意识到还要核对一遍。点击确认后弹出确认弹窗或者轻微震动反馈,让用户看一眼预览区的完整号牌再操作下一步,能显著减少“输完了才发现错了要重来”的投诉。这个小改动不复杂,但对体验提升非常明显。
车牌键盘组件本身不是什么高深技术,难的是把输入规则、交互细节、跨端兼容都做到位。照着上面的思路做,至少停车场、充电桩、门禁这类项目里,车牌录入这块不会成为用户吐槽的重灾区。