
上周帮一个做停车场系统的朋友改他们的微信小程序需求听起来小得不能再小让用户输入车牌号码。我一开始也觉得不就一个输入框的事吗扔个input让用户敲完拿值就完了。结果真机一上手,问题全冒出来了——用户切到中文输入法得先敲拼音再选省份简称弹起来的系统键盘把下面的提交按钮整个盖住iOS 上系统输入法还热衷联想把车牌敲成了人名。最后我把这块整个推翻改成槽位视图 自定义键盘的结构重写了两天体验才算拿得出手。这篇就把这次改造的完整思路拆开讲包括车牌到底能用哪些字符、自定义键盘的行列怎么分配、新能源八位车牌怎么兼容、正则校验怎么写以及只在真机上才会暴露的那几个坑。适合已经写过微信小程序、现在要做车辆绑定、停车缴费、访客登记这类功能的人代码可以直接抄过去改。1. 先想清楚车牌输入为什么不能直接丢一个 input1.1 中文输入法让省份简称变成一道坎车牌的第一位是省份简称京、沪、粤、川这一类的汉字。这件事在 PC 端浏览器里还算能忍用户敲个拼音就能选但在手机上尤其是微信小程序的表单里用户要完成切输入法 → 敲拼音 → 翻候选词 → 点选四步才能填一个字。更麻烦的是各家输入法的候选词排序完全不一样有的把京放第一个有的把景放第一个用户一个手滑就填错了。还有个特别容易被忽略的点选了中文输入法之后用户接下来要填的字母和数字又得切回英文键盘。一趟车牌填下来用户要在中英文键盘之间来回切换至少两次。这不是技术问题是纯粹的体验损耗而恰恰是这种损耗决定了表单的完成率。我当时拿真机做了个小测试让十个同事各自录入同一个车牌用系统 input 平均耗时 21 秒换成自定义键盘之后降到 9 秒差了不止一倍。1.2 可用字符只有三十多个系统键盘给的却是全部车牌字符集其实窄得惊人。省份简称固定就那么几十个含使领馆等特殊号牌先不讨论发牌机关代号是 A 到 Z 的字母但明确排除 I 和 O——因为这两个字母跟数字 1 和 0 长得太像容易造成识别和抄录错误。剩下的序号位只能出现数字和字母同样不含 I 和 O。算一下这是个信息量极小的输入场景用户可能用到的字符总共只有 10 个数字加 24 个字母再加几十个汉字。而系统键盘会给他 26 个字母、10 个数字、标点、emoji、以及一整套中文输入法。让用户在这么大的字符池里捞那几个字符本身就是在为难人。自定义键盘的核心价值就在这把可选项从几百个压缩到三十几个用户不用找直接点就行。1.3 长度不固定槽位语义却固定普通燃油车牌是 7 位新能源车牌是 8 位而且不同位置的规则还不一样。第一位必须是省份简称第二位必须是字母第三位开始才是数字或字母的混合。新能源小型车的第三位还有特殊含义——D 代表纯电F 代表非纯电不是随便填的。这就意味着输入一个字符串这个模型根本套不上车牌。正确的模型是槽位模型先摆出 N 个格子每个格子有自己的可填字符集用户点到哪个格子键盘就切到对应的字符集。这样用户永远不会产生我填错了的困惑因为他根本填不进非法字符。这个模型一旦确立后面的代码结构就顺了。2. 槽位视图的搭建把输入框拆成一个个格子2.1 数据模型plateArr、activeIndex 和长度模式核心状态就三个plateArr存每个槽位的值activeIndex记录当前光标在哪个槽位slotCount决定是 7 格还是 8 格。这三者一确定整个组件的渲染逻辑就闭环了。data: { plateArr: [, , , , , , , ], // 统一按 8 位申请渲染时按 slotCount 截断 activeIndex: 0, slotCount: 7, plateType: normal // normal | energy }为什么不直接初始化长度为 7 的数组因为新能源切换的时候数组长度会变来回改数组长度会让wx:key对应的元素重新创建光标和动画状态全丢。统一按最大长度申请渲染层用wx:if或者数组切片控制显示个数切换时只改变slotCount数组本身不动元素复用就不会有闪烁。activeIndex的推进规则也得想清楚。用户点了一个键之后光标应该是往右推到下一个空位还是顺移一格我的做法是顺移一格如果下一位还有空位就移过去如果已经在最后一位就原地不动。有的实现是跳到下一个空槽那用户想修改中间某一位的时候会很别扭因为填完一个字符光标直接跑飞了。2.2 WXML 结构为什么这里不该用 input很多人第一反应是保留一个input只不过把disabled或者focus用起来靠 CSS 把它盖在格子下面。这个方案能跑但坑很多input在小程序里会触发键盘弹起的行为逻辑adjust-position参数控制不住的时候页面会跳动input的value和视图里的格子值不同步时会出现光标错位iOS 上disabled的input依然可能被系统识别为可聚焦元素。干脆一点直接全用view拼view classplate-input view wx:for{{plateArr}} wx:keyindex wx:if{{index slotCount}} classslot {{activeIndex index ? slot--active : }} {{item ? slot--filled : }} >.slot { position: relative; flex: 1; height: 88rpx; margin-right: 12rpx; border: 2rpx solid #e5e5e5; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 600; color: #222; transition: border-color .15s; } .slot:last-child { margin-right: 0; } .slot--active { border-color: #1677ff; box-shadow: 0 0 0 4rpx rgba(22, 119, 255, .12); } .slot__cursor { width: 3rpx; height: 44rpx; background: #1677ff; animation: cursorBlink 1s steps(1) infinite; } keyframes cursorBlink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }steps(1)是关键它让动画变成硬切换而不是渐变闪烁效果才像系统光标。如果你用默认的缓动光标会像是在呼吸观感很怪。3. 自定义键盘的行列怎么排3.1 省份简称那一行单独处理省份简称有 31 个不含港澳台地区的特殊号牌规则一行放不下两行又太占高度。我的方案是单独做一条横向滚动的区域放在键盘最上面高度控制在一行键的高度。scroll-view classprovince-bar scroll-x enable-flex view wx:for{{provinces}} wx:key*this classkey key--province >.keyboard { display: flex; flex-wrap: wrap; padding: 12rpx; background: #f5f6f8; padding-bottom: calc(12rpx env(safe-area-inset-bottom)); } .key { flex: 1 1 0; min-width: 0; height: 88rpx; margin: 6rpx; border-radius: 10rpx; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 34rpx; color: #222; } .key--pressed { background: #e8eaed; }padding-bottom那句是给全面屏机型留的env(safe-area-inset-bottom)在 iPhone 上会返回底部横条的高度不加这句键盘在最下面一排会被横条挡住一点点手指点不到边角。3.3 删除、清空、新能源切换三个功能键的摆放功能键的位置很影响手感。我见过把删除键放右下角的也见过放左下角的。从实际使用看删除键放在右下角更符合右手拇指的运动轨迹而且用户连续删除的时候手指不用移动太远。第 4 行只有 W、X、Y、Z 四个字母剩下 6 格的宽度正好分给功能键。我的分配是删除键占两格、清空键占两格、收起键盘占两格。view classkey key--func key--delete bindtaponDelete bindtouchstartonDeleteStart bindtouchendonDeleteEnd删除/view新能源切换这个功能键要不要放看业务。如果是纯停车缴费场景用户群体里新能源比例不低我会在键盘上方做一个普通 / 新能源的切换 Tab而不是塞进键盘里。因为塞进去用户很可能根本不会主动点导致 8 位车牌的用户输到第 7 位就卡住了。做成 Tab 的话两个选项一眼能看见。提示切换 Tab 时如果已经从 7 位切到 8 位之前填的内容要保留。不要清空重来那是最招人烦的交互之一。4. 手感调优把点击、震动、连续删除都做扎实4.1 震动反馈与按键的按下态小程序的bindtap有个天然的延迟问题虽然相比早年的 Web 好了很多但在低端 Android 上依然能感觉到一点粘滞感。解决方式是同时绑touchstart和touchend在touchstart里改按下态样式在touchend里还原并执行逻辑。震动反馈用wx.vibrateShort参数传type: light。这个 API 在 iOS 上表现比较克制Android 上要小心部分机型不支持type参数会直接震动一个比较重的时长。我的做法是包一层 try-catch失败就静默跳过不要因为这玩意儿报错。function haptic() { try { wx.vibrateShort({ type: light }); } catch (e) { // 部分机型不支持 type 参数忽略即可 } }震动要不要给我个人的判断是按键本身没有物理反馈的时候震动是唯一能告诉用户点到了的信号。车牌键盘键位密集误触概率不低一次轻微震动能让用户立刻意识到自己点错了比事后看着错误的字符发呆要好。但震动幅度千万别开大双击震那种量级只会让人烦躁。4.2 长按连续删除的计时逻辑用户填错一位要删删完之后想接着删下一位如果每次都要重新点删除键十几次下来手会很累。所以要支持长按连续删除。实现上就是touchstart起一个 500ms 的setTimeout超时之后再起一个 100ms 间隔的setIntervaltouchend时两个都清掉。onDeleteStart() { this._pressTimer setTimeout(() { this._repeatTimer setInterval(() { this.deleteOne(); }, 100); }, 500); }, onDeleteEnd() { clearTimeout(this._pressTimer); clearInterval(this._repeatTimer); this._pressTimer null; this._repeatTimer null; }这里的细节在于组件卸载时必须清掉定时器。用户在长按删除的中途点了返回detached生命周期里如果不清理定时器会继续跑操作一个已经销毁的组件轻则报错重则内存泄漏。detached() { clearTimeout(this._pressTimer); clearInterval(this._repeatTimer); }还有个小坑长按触发连续删除之后touchend上绑的单击删除逻辑还会再执行一次导致多删一位。解决办法是加个标志位长按一旦触发就置为 true单击逻辑里判断这个标志执行完再重置。4.3 setData 的写法直接决定键盘跟不跟手这是最容易出性能问题的地方。刚开始我图省事每次点击都重建整个数组// 不推荐 const arr this.data.plateArr.slice(); arr[i] key; this.setData({ plateArr: arr });7 个元素的数组单次重建看起来开销很小但用户连续快速点击的时候每次setData都要把整个数组序列化后传到视图层累积起来就会掉帧。正确的写法是用路径更新只传变化的那个字段// 推荐 this.setData({ [plateArr[ index ]]: key, activeIndex: nextIndex });这样传过去的数据量只有几个字节视图层也只更新那一个节点。我在一个低端 Android 机上做过对比测试连续快速输入 8 位车牌重建数组的方式有 3 到 4 次明显的卡顿路径更新的方式全程跟手。另外要避免在onKeyTap里同时做校验、计算、界面更新三件事。校验逻辑放到change事件触发之后再做键盘的点击路径上只做最必要的事情更新当前槽位、推进光标索引、触发事件。其他都是次要的。5. 车牌校验正则只是最后一道保险5.1 普通车牌与新能源车牌的规则差异先把规则理清楚不然后面的正则没法写。普通燃油车牌 7 位结构是省份简称 发牌机关代号字母 5 位序号。序号位可以是数字也可以是字母同样排除 I 和 O。新能源车牌 8 位。小型新能源车绿牌的结构是省份简称 发牌机关代号字母 类型标识D 或 F 5 位序号。其中 D 表示纯电动F 表示非纯电动。大型新能源车黄绿双拼的结构不一样是省份 字母 5 位数字 类型标识D 或 F类型标识在最后一位。类型位数结构备注普通燃油7省 字母 5 位序号含数字和字母无 I/O小型新能源8省 字母 D/F 5 位D 纯电F 非纯电大型新能源8省 字母 5 位数字 D/F类型标识在末位需要说明的是各地在号牌规则上会有些细微调整比如某些特殊用途号牌的结尾字符。实际项目里建议把规则做成可配置的或者至少留个注释标明规则来源和核对时间别写死了之后过两年规则变了没人知道去哪改。5.2 正则怎么写以及几个容易写错的地方先定义省份简称字符集再写三条正则。const PROVINCE 京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼; const PROVINCE_CLASS [ PROVINCE ]; // 字母集排除 I 和 O const LETTER A-HJ-NP-Z; const ALNUM A-HJ-NP-Z0-9; // 普通燃油车牌含挂车、教练车、警车等特殊尾字 const RE_NORMAL new RegExp( ^ PROVINCE_CLASS [ LETTER ] [ ALNUM ]{4} [ ALNUM 挂学警港澳] $ ); // 小型新能源 const RE_ENERGY_SMALL new RegExp( ^ PROVINCE_CLASS [ LETTER ] [DF] [ ALNUM ]{5} $ ); // 大型新能源 const RE_ENERGY_LARGE new RegExp( ^ PROVINCE_CLASS [ LETTER ] [0-9]{5} [DF] $ );容易写错的地方有几个。第一[A-Z]和[A-HJ-NP-Z]的区别很多人不在意但前者会把 I 和 O 放进来导致用户输入I的时候前端不报错等到后端校验才失败体验上就变成了我明明填完了却说我不对。第二^和$一定要加不加的话中间夹一段合法子串也能通过。第三新能源的正则里[DF]不能写成[DFdf]虽然用户输入的是大写但如果你在组件里做了toUpperCase那容错就交给那一步做正则保持严格。还有一个细节挂学警港澳这几个尾字在正则字符类里是可以直接写的但要确保文件的编码是 UTF-8否则中文会乱码导致整个正则失效。这个坑我在一个老项目里遇到过原因是构建工具二次编码排查了半天。5.3 边界用例清单与测试建议光写正则不够得把边界情况列出来一条条测。我整理了一份常用清单每次改校验逻辑都跑一遍用例期望结果说明京A12345通过普通车牌基础用例京A1234挂通过挂车尾字沪AD12345通过小型新能源纯电粤BF12345通过小型新能源非纯电京A1234I不通过含 I应被拦截京A1234O不通过含 O应被拦截京I12345不通过第二位是 I京A1234不通过位数不足京A123456不通过位数超出天津A1234不通过省份简称占两位京AD1234不通过新能源位数不足空字符串不通过边界测试的时候建议同时跑开发者工具和真机。原因是开发者工具的正则引擎和真机上的 JS 引擎在某些 Unicode 场景下表现会有差异中文省份简称正好踩在这个点上。我建议省份简称别用正则匹配改成用indexOf判断放在不在字符串里简单可靠还不受编码影响function isValidProvince(ch) { return PROVINCE.indexOf(ch) -1; }拆开判断还有一个好处出错的时候能给出具体的错误提示。省份简称不正确和格式不符合要求这两句话对用户的帮助完全不在一个量级上。6. 封装成自定义组件让业务方只管拿结果6.1 properties 与 triggerEvent 的接口约定组件化的时候把接口想清楚后面复用能省很多事。我给的属性就三个value用来做受控传入plateType指定普通还是新能源disabled控制禁用态。事件就两个change在每次输入变化时触发complete在填满时触发。Component({ properties: { value: { type: String, value: , observer: onValueChange }, plateType: { type: String, value: normal }, disabled: { type: Boolean, value: false } }, methods: { emitChange(arr) { const val arr.join(); this.setData({ _innerValue: val }); this.triggerEvent(change, { value: val }); if (val.length this.data.slotCount) { this.triggerEvent(complete, { value: val }); } } } });observer这块要小心死循环外部传进来的值设置到内部状态内部状态变化又触发change父组件监听change又更新value。解决方式是内部维护一个_innerValue在observer里先比对值相同就直接返回。另外triggerEvent的参数建议只传value这个字符串不要把内部数组暴露出去。数组是内部实现细节一旦暴露出去父组件可能会去改它导致状态不同步这种 bug 特别难查。6.2 在车辆绑定、停车缴费、访客登记里的复用姿势同一套组件在不同场景下的用法其实差别不小。车辆绑定场景通常要一次录多个车牌这时候建议把组件做成列表里的一项每个实例独立维护自己的状态别用一个全局的键盘去控制所有输入框那样焦点管理会很难写。停车缴费场景一般是单次录入用户输了就走这时候可以把键盘常驻在页面底部让它跟着页面的固定定位走用户不用再点一下才能弹出键盘。访客登记场景有个特殊需求车牌可能是访客临时报的用户不一定记得准。这时候在键盘旁边加一个看不清拍照识别的入口接一下 OCR 能力识别结果回填到槽位里。这个功能的实现本身不难难的是识别错了之后用户怎么改——所以槽位视图 自定义键盘的结构在这里特别有价值识别结果填进去之后用户想改哪一位就点哪一位不用担心把整串删掉重来。6.3 前端校验和服务端校验怎么分工前端的校验定位是即时反馈让用户在填的过程中就知道哪里不对减少提交失败的挫败感。所以前端要做的是字符集限制和长度检查这两件事在槽位模型下几乎自动完成了。后端要做的是权威校验包括号牌是否真实存在、是否与你系统里已有记录冲突、是否属于可服务的区域。这两层职责不能混。有一点要提醒前端校验结果绝对不能当作信任来源。我见过有项目直接拿前端算出来的车牌去查数据库结果用户手动改了一下请求参数就绕过去了。校验逻辑前后端各写一份前端为了体验后端为了安全两个都别省。7. 真机上才暴露的坑以及怎么排查7.1 iOS 和 Android 在布局与安全区上的差异env(safe-area-inset-bottom)在 iOS 上返回的值是准确的但在部分 Android 机型上会返回 0尤其是那些底部有虚拟导航栏的机型。如果键盘的底部内边距完全依赖这个值Android 上就会出现按键贴着屏幕底部、手指够不着的情况。我的处理方式是给一个保底的底部内边距再用max()取两者中较大的那个.keyboard { padding-bottom: max(24rpx, env(safe-area-inset-bottom)); }max()在小程序的样式里支持情况还不错如果项目要兼容很老的机型就退化成一个固定的 24rpx 内边距加一个额外的安全区占位view用 JS 判断系统后动态设高度。另一个差异在字体渲染上。iOS 上 36rpx 的字号看着很饱满Android 上同样的字号会显得偏小尤其是中文字符。可以在app.wxss里根据platform微调字号差个 2rpx 就能让两边观感接近。7.2 键盘遮挡与页面滚动的处理如果页面是用scroll-view做的主容器自定义键盘用position: fixed固定在底部那页面滚动到底部时最后几个表单项会被键盘盖住。解决办法是给scroll-view的内容区加一个等于键盘高度的底部占位scroll-view scroll-y classpage view classform.../view view classkeyboard-placeholder styleheight: {{keyboardHeight}}px;/view /scroll-view view classkeyboard styleposition: fixed; bottom: 0;.../viewkeyboardHeight不要写死用wx.createSelectorQuery量一下键盘实际渲染出来的高度再赋值。因为不同屏幕宽度下键位的高度是按比例缩放的写死会在小屏机上出问题。还有一个坑是键盘展开时页面会被顶起。这个问题在使用原生 input 的时候特别明显adjust-position默认是 true键盘一弹页面整体上移收起时又落回来动画很生硬。自定义键盘没有这个问题因为它根本不触发系统键盘但如果你页面里还有别的原生 input就要在聚焦那个 input 的时候把自定义键盘收起来避免两个键盘叠在一起。7.3 开发者工具正常、真机异常时的排查顺序这类问题排查起来最耗时间因为现象在真机上才出现改了代码还得重新编译上传。我总结了一套顺序基本能覆盖大部分情况先确认基础库版本。在页面onLoad里打一下wx.getSystemInfoSync().SDKVersion如果版本低于组件里用到的 API 最低要求行为不一致就很正常。真机和工具的默认基础库版本经常不一样。再看样式兼容性。开发者工具用的是浏览器内核真机上是各家的 WebViewgap、aspect-ratio、max()这些较新的 CSS 特性在低版本 WebView 上可能不支持。排查方法是在真机上开调试逐个注释掉可疑样式看现象变化。然后是事件顺序。touchstart/touchend/tap在真机上的触发顺序偶尔会有差异如果代码里同时绑了这几个事件做互斥很容易出现工具里没事真机上多执行一次的情况。用console.log打出时间戳看真机的实际顺序。最后才是逻辑问题。走到这一步基本能定位到具体是哪行代码了。真机调试建议用微信开发者工具的真机调试功能能直接看到console输出比反复上传体验版快很多。另外 iOS 上可以用 Safari 的远程调试看 WebView 内部Android 上可以用 Chrome 的chrome://inspect这两个手段在排查渲染层问题时比裸看日志有效得多。最后分享一个我在实际项目里的小习惯把车牌组件单独抽成一个页面页面上除了组件什么都不放再配上几个模拟填入的按钮普通车牌、新能源、含非法字符的用例。每次改了组件逻辑先在这个干净页面里把用例跑一遍确认没问题了再接回业务页面。这个页面大概只花二十分钟做但省下来的排查时间远超这个投入尤其是当业务页面本身还有十几个表单项的时候。